diff --git a/.agents/skills/trellis-before-dev/SKILL.md b/.agents/skills/trellis-before-dev/SKILL.md deleted file mode 100644 index 5f1c28a..0000000 --- a/.agents/skills/trellis-before-dev/SKILL.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -name: trellis-before-dev -description: "Discovers and injects project-specific coding guidelines from .trellis/spec/ before implementation begins. Reads spec indexes, pre-development checklists, and shared thinking guides for the target package. Use when starting a new coding task, before writing any code, switching to a different package, or needing to refresh project conventions and standards." ---- - -Read the relevant development guidelines before starting your task. - -Execute these steps: - -1. **Read current task artifacts**: - - `prd.md` for requirements and acceptance criteria - - `design.md` if present for technical design - - `implement.md` if present for execution order and validation plan - -2. **Discover packages and their spec layers**: - ```bash - python3 ./.trellis/scripts/get_context.py --mode packages - ``` - -3. **Identify which specs apply** to your task based on: - - Which package you're modifying (e.g., `cli/`, `docs-site/`) - - What type of work (backend, frontend, unit-test, docs, etc.) - - Any spec/research paths referenced by the task artifacts - -4. **Read the spec index** for each relevant module: - ```bash - cat .trellis/spec///index.md - ``` - Follow the **"Pre-Development Checklist"** section in the index. - -5. **Read the specific guideline files** listed in the Pre-Development Checklist that are relevant to your task. The index is NOT the goal — it points you to the actual guideline files (e.g., `error-handling.md`, `conventions.md`, `mock-strategies.md`). Read those files to understand the coding standards and patterns. - -6. **Always read shared guides**: - ```bash - cat .trellis/spec/guides/index.md - ``` - -7. **For a non-trivial task, state the change boundary before writing code.** Non-trivial means it touches more than one file, crosses a layer, changes a public interface, or edits code you did not just write. Write down: - - the smallest behavior gap between what happens now and what should happen - - where that behavior actually lives (not where it is easiest to intercept) - - which files you expect to change, and why each one is necessary - - what you are explicitly not doing in this task - - if a local refactor is needed, how you will show it did not change behavior - - A small, well-scoped change does not need this — do it directly. - - If the real scope turns out to be clearly larger than this, say so and why before continuing. Do not widen the change on your own. - -8. Understand the coding standards and patterns you need to follow, then proceed with your development plan. - -This step is **mandatory** before writing any code. diff --git a/.agents/skills/trellis-brainstorm/SKILL.md b/.agents/skills/trellis-brainstorm/SKILL.md deleted file mode 100644 index 096eeda..0000000 --- a/.agents/skills/trellis-brainstorm/SKILL.md +++ /dev/null @@ -1,200 +0,0 @@ ---- -name: trellis-brainstorm -description: "Guides collaborative requirements discovery before implementation. Creates task directory, seeds PRD, asks high-value questions one at a time, researches technical choices, and converges on MVP scope. Use when requirements are unclear, there are multiple valid approaches, or the user describes a new feature or complex task." ---- - -# Trellis Brainstorm - -## Non-Negotiable Planning Contract - -A request to build, implement, fix, refactor, or "go ahead" is not approval to leave planning. Task-creation consent is also not implementation approval. - -For every non-trivial task, the user must respond at least once after the initial request before implementation begins. If no clarification is needed, that response must approve the final planning summary described below. - -While any user-owned product, scope, UX, compatibility, risk, or acceptance decision remains unresolved, end the turn with exactly one highest-value question. Do not edit product code, dispatch implementation, or run `task.py start`. - -## Non-Negotiable Evidence Rule - -If a question can be answered by exploring the codebase, explore the codebase instead. - -This is mandatory. Before asking the user a question, first check whether the answer is already available in code, tests, configs, docs, existing specs, or task history. - -Do not ask the user to confirm facts that the repository can answer. Ask only for product intent, preference, scope, risk tolerance, acceptance behavior, or decisions that remain ambiguous after inspection. - -Repository evidence establishes current behavior and technical constraints. The user's intended behavior, feature scope boundaries, and UX preferences are never answerable by repository evidence alone, even when an existing pattern exists; existing patterns are options and recommendation evidence, not decisions. - ---- - -Use this skill during Phase 1 planning to turn the user's request into clear requirements and planning artifacts. - -## Preconditions - -Use this skill only after task-creation consent has been given and the user is ready to enter Trellis planning. - -If no task exists yet, create one: - -```bash -TASK_DIR=$(python3 ./.trellis/scripts/task.py create "" --slug ) -``` - -Use a concise title from the user's request. Use a slug without a date prefix. `task.py create` adds the `MM-DD-` directory prefix automatically. - -`task.py create` creates the default `prd.md`. Update that file with the current understanding before asking follow-up questions. - -## Planning Flow - -1. Capture the user's request and initial known facts in `prd.md`. -2. Inspect available evidence before asking questions: - - code, tests, fixtures, and configs - - README files, docs, existing specs, and domain notes - - related Trellis tasks, research files, and session history when present -3. Separate what you found into: - - confirmed facts - - product intent still needed from the user - - scope or risk decisions still needed from the user - - likely out-of-scope items -4. If a user-owned decision remains, ask the single highest-value question, include your recommendation and trade-off, then stop. Do not perform implementation work in the same turn. -5. After each user answer, update `prd.md`, recompute the decision inventory, and repeat from step 2. -6. When no user-owned decision remains, create or update `design.md` and `implement.md` for complex tasks. -7. Run the requirement convergence gate, then the PRD convergence pass. -8. Present the final planning summary and stop. Do not run `task.py start` or edit product code in the same turn. -9. Only a subsequent user message that explicitly approves the latest planning summary authorizes `task.py start` and implementation. If the artifacts change materially after approval, repeat the final review. - -Do not invent a project-specific product/spec hierarchy. If the repository already has product, domain, or spec docs, use them. If it does not, proceed with the evidence that exists. - -## Question Rules - -Ask only one question per message. - -Each question must include: - -- the decision needed -- why the answer matters -- your recommended answer -- the trade-off if the user chooses differently - -Do not ask process questions such as whether to search, inspect files, or continue brainstorming. Do the evidence work directly. Ask the user only when the remaining issue is a product decision, preference, scope boundary, or risk tolerance choice. - -Recommendations are not default selections. Never choose a recommended product decision on the user's behalf merely because the user asked for implementation. - -Do not manufacture clarification questions when the request and repository evidence already resolve every decision. In that case, proceed directly to the final planning summary, which still requires a subsequent explicit approval. - -The final review is a required phase-transition gate, not a prohibited process question. Task-creation consent, the initial implementation request, and approval given before the latest final summary do not satisfy this gate. - -## Thinking Framework: First Principles Analysis - -When requirements are vague, solutions feel over-engineered, or you're about to add complexity "because everyone does" — decompose to fundamental truths before reasoning upward. - -### Step 1: Restate the Problem - -Strip away implementation details to one sentence. - -> Bad: "We need to add Redis caching to the user profile endpoint" -> Good: "User profile data takes too long to load" - -### Step 2: List Fundamental Truths - -What is absolutely true (not opinion or convention)? - -| Category | Examples | -|----------|----------| -| **Physical constraints** | Network latency ≥ 0, disk I/O has limits | -| **Business rules** | "Users must see their own data" | -| **Technical invariants** | "Data must be consistent" | -| **User needs** | "The user wants X within Y seconds" | - -### Step 3: Challenge Assumptions - -For each component of the current plan: - -- **Fact or convention?** "We always use REST" — why? -- **What if we removed this?** If nothing breaks, it's unnecessary. -- **Solving the actual problem or a symptom?** Trace the causal chain. -- **Who benefits from this complexity?** If "nobody", simplify. - -### Step 4: Build Up from Truths - -1. Start with the minimum viable mechanism satisfying all truths -2. Add complexity only when a specific truth demands it -3. Each addition must answer: "Which truth requires this?" - -### Step 5: Validate - -- Does the solution solve the original problem? -- What assumptions need verification? -- What's the simplest experiment to test this? - -## Requirement Convergence Gate - -Before final review, verify all of the following: - -- the user outcome and product value are explicit -- in-scope and out-of-scope behavior are explicit -- acceptance criteria describe observable outcomes -- user-owned product, scope, UX, compatibility, and risk decisions are resolved -- blocking open questions are empty -- technical unknowns are researched or explicitly deferred without changing MVP behavior - -Lightweight tasks may omit `design.md` and `implement.md`; they may not skip evidence inspection, requirement convergence, final review, or fresh implementation approval. - -The final planning summary must show Goal, In Scope, Out of Scope, Acceptance Criteria, Key Decisions, relevant Risks or Deferred Items, and artifact status. - -## Artifact Rules - -`prd.md` records requirements and acceptance: - -- goal and user value -- confirmed facts -- requirements -- acceptance criteria -- out of scope -- open questions that still block planning - -`design.md` records technical design for complex tasks: - -- architecture and boundaries -- data flow and contracts -- compatibility and migration notes -- important trade-offs -- operational or rollback considerations - -`implement.md` records execution planning for complex tasks: - -- ordered implementation checklist -- validation commands -- risky files or rollback points -- follow-up checks before `task.py start` - -Lightweight tasks may have only `prd.md`. Complex tasks must have `prd.md`, `design.md`, and `implement.md` before `task.py start`. - -`implement.md` is not a replacement for `implement.jsonl`. On sub-agent-dispatch workflows, `implement.jsonl` and `check.jsonl` must each contain at least one real spec/research entry before `task.py start`; the seed `_example` row does not count. Inline workflows skip this JSONL gate because Phase 2 loads context through `trellis-before-dev`. - -## PRD Convergence Pass - -Before declaring planning ready or running `task.py start`, rewrite `prd.md` once against the final structure described in the artifact rules above. This is not optional cleanup; it is the final planning gate. - -The pass must be lossless: - -- Collapse repeated facts into one authoritative section. -- Fold temporary brainstorm sections such as `What I already know`, `Assumptions`, and resolved `Open Questions` into Goal, Background, Requirements, Technical Notes, or Acceptance Criteria. -- Remove resolved open questions instead of leaving empty or already-answered sections. -- Merge parallel bug and requirement lists when they describe the same work; keep each defect's severity, evidence, and file:line anchors on the owning requirement. -- Preserve every file:line anchor, decision, constraint, requirement ID, and acceptance-criteria mapping. -- Do not proceed to final review while any blocking open question remains. - -After the pass, read `prd.md` top to bottom and verify that no fact is repeated across sections unless the repetition adds new information. - -## Quality Bar - -Before declaring planning ready: - -- `prd.md` contains testable acceptance criteria. -- `prd.md` has passed the PRD convergence pass: no unresolved temporary brainstorm sections, no duplicate facts across sections, and no lost anchors, decisions, or acceptance mappings. -- Repository-answerable questions have already been answered through inspection. -- Blocking open questions are empty. -- Complex tasks have `design.md` and `implement.md`. -- Sub-agent-dispatch tasks have real curated entries in both `implement.jsonl` and `check.jsonl`; seed-only manifests are not ready. -- The latest final planning summary has been presented to the user. -- In a subsequent message, the user explicitly approved that summary for implementation. - -Do not start implementation merely because the user originally asked for implementation. diff --git a/.agents/skills/trellis-break-loop/SKILL.md b/.agents/skills/trellis-break-loop/SKILL.md deleted file mode 100644 index 1c8b397..0000000 --- a/.agents/skills/trellis-break-loop/SKILL.md +++ /dev/null @@ -1,188 +0,0 @@ ---- -name: trellis-break-loop -description: "Deep bug analysis to break the fix-forget-repeat cycle. Analyzes root cause category, why fixes failed, prevention mechanisms, and captures knowledge into specs. Use after fixing a bug to prevent the same class of bugs." ---- - -# Break the Loop - Deep Bug Analysis - -When debug is complete, use this for deep analysis to break the "fix bug -> forget -> repeat" cycle. - ---- - -## Analysis Framework - -Analyze the bug you just fixed from these 5 dimensions: - -### 1. Root Cause Category - -Which category does this bug belong to? - -| Category | Characteristics | Example | -|----------|-----------------|---------| -| **A. Missing Spec** | No documentation on how to do it | New feature without checklist | -| **B. Cross-Layer Contract** | Interface between layers unclear | API returns different format than expected | -| **C. Change Propagation Failure** | Changed one place, missed others | Changed function signature, missed call sites | -| **D. Test Coverage Gap** | Unit test passes, integration fails | Works alone, breaks when combined | -| **E. Implicit Assumption** | Code relies on undocumented assumption | Timestamp seconds vs milliseconds | - -### 2. Why Fixes Failed (if applicable) - -If you tried multiple fixes before succeeding, analyze each failure: - -- **Surface Fix**: Fixed symptom, not root cause -- **Incomplete Scope**: Found root cause, didn't cover all cases -- **Tool Limitation**: grep missed it, type check wasn't strict -- **Mental Model**: Kept looking in same layer, didn't think cross-layer - -### 3. Prevention Mechanisms - -What mechanisms would prevent this from happening again? - -| Type | Description | Example | -|------|-------------|---------| -| **Documentation** | Write it down so people know | Update thinking guide | -| **Architecture** | Make the error impossible structurally | Type-safe wrappers | -| **Compile-time** | Strict type checking, no escape hatches | Signature change causes compile error | -| **Runtime** | Monitoring, alerts, scans | Detect orphan entities | -| **Test Coverage** | E2E tests, integration tests | Verify full flow | -| **Code Review** | Checklist, PR template | "Did you check X?" | - -### 4. Systematic Expansion - -What broader problems does this bug reveal? - -- **Similar Issues**: Where else might this problem exist? -- **Design Flaw**: Is there a fundamental architecture issue? -- **Process Flaw**: Is there a development process improvement? -- **Knowledge Gap**: Is the team missing some understanding? - -### 5. Knowledge Capture - -Solidify insights into the system: - -- [ ] Update `.trellis/spec/guides/` thinking guides -- [ ] Update relevant `.trellis/spec/` docs -- [ ] Create issue record (if applicable) -- [ ] Create feature ticket for root fix -- [ ] Update check guidelines if needed - ---- - -## Output Format - -Please output analysis in this format: - -```markdown -## Bug Analysis: [Short Description] - -### 1. Root Cause Category -- **Category**: [A/B/C/D/E] - [Category Name] -- **Specific Cause**: [Detailed description] - -### 2. Why Fixes Failed (if applicable) -1. [First attempt]: [Why it failed] -2. [Second attempt]: [Why it failed] -... - -### 3. Prevention Mechanisms -| Priority | Mechanism | Specific Action | Status | -|----------|-----------|-----------------|--------| -| P0 | ... | ... | TODO/DONE | - -### 4. Systematic Expansion -- **Similar Issues**: [List places with similar problems] -- **Design Improvement**: [Architecture-level suggestions] -- **Process Improvement**: [Development process suggestions] - -### 5. Knowledge Capture -- [ ] [Documents to update / tickets to create] -``` - ---- - -## Core Philosophy - -> **The value of debugging is not in fixing the bug, but in making this class of bugs never happen again.** - -Three levels of insight: -1. **Tactical**: How to fix THIS bug -2. **Strategic**: How to prevent THIS CLASS of bugs -3. **Philosophical**: How to expand thinking patterns - -30 minutes of analysis saves 30 hours of future debugging. - -## Thinking Framework: Bayesian Reasoning - -When multiple root causes are plausible and evidence is incomplete, update your beliefs proportionally to new evidence rather than clinging to initial assumptions. - -### Step 1: Establish Priors - -Before investigating, state what you believe and why: - -| Hypothesis | Prior | Reasoning | -|------------|-------|-----------| -| H1: [cause A] | 40% | Most common for this pattern | -| H2: [cause B] | 30% | Plausible given environment | -| H3: [other] | 30% | Catch-all | - -Priors must sum to 100%. If you can't assign probabilities, investigate first. - -### Step 2: Observe Evidence - -Document what you found — be specific about reliability: - -- What exactly did you observe? -- How reliable? (test output > log message > user report > hunch) -- Could multiple hypotheses explain this? - -### Step 3: Update Beliefs - -For each hypothesis, ask: **How likely is this evidence if this hypothesis were true?** - -Direction of update matters more than calculation: -- Evidence strongly predicted by H1 → H1 probability increases -- Evidence contradicts H2 → H2 probability decreases -- Evidence equally likely under all → no update - -### Step 4: Seek Discriminating Evidence - -Don't gather more of the same. Find evidence that **differs strongly** between top hypotheses. - -> If H1 and H3 are close: "What would I see if H1 is true but not if H3 is true?" Then check for that. - -### Step 5: State Confidence - -| Confidence | Action | -|------------|--------| -| 90%+ | Proceed with fix, monitor | -| 70-90% | Proceed, add fallback check | -| 50-70% | Test hypothesis before committing | -| <50% | Need more evidence, don't guess | - -Never express binary certainty when evidence is incomplete. Use "most likely", "plausible but unlikely", "worth investigating". - -### Common Fallacies - -| Fallacy | Example | Correction | -|---------|---------|------------| -| **Base rate neglect** | "Test failed → code is broken" | How often do tests fail for other reasons? | -| **Confirmation bias** | "Must be a race condition, let me find race evidence" | Actively seek evidence AGAINST your top hypothesis | -| **Anchoring** | "Last time it was caching, probably caching again" | Establish priors from current context, not yesterday's bug | - ---- - -## After Analysis: Immediate Actions - -**IMPORTANT**: After completing the analysis above, you MUST immediately: - -1. **Update spec/guides** - Don't just list TODOs, actually update the relevant files: - - If it's a cross-platform issue → update `cross-platform-thinking-guide.md` - - If it's a cross-layer issue → update `cross-layer-thinking-guide.md` - - If it's a code reuse issue → update `code-reuse-thinking-guide.md` - - If it's domain-specific → update `backend/*.md` or `frontend/*.md` - -2. **Sync templates** - After updating `.trellis/spec/`, sync to `src/templates/markdown/spec/` - -3. **Commit the spec updates** - This is the primary output, not just the analysis text - -> **The analysis is worthless if it stays in chat. The value is in the updated specs.** diff --git a/.agents/skills/trellis-channel/SKILL.md b/.agents/skills/trellis-channel/SKILL.md deleted file mode 100644 index 511ee02..0000000 --- a/.agents/skills/trellis-channel/SKILL.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -name: trellis-channel -description: Use Trellis channel for live multi-agent collaboration, spawned workers, cross-agent review, progress inspection, forum channels, and channel log debugging. ---- - -# trellis-channel - -`trellis channel` is the local multi-agent collaboration runtime. Reach for it when agents need to talk through a durable event log, when a worker should be spawned as a peer process, when an in-flight worker needs interrupt / debugging, or when feedback should be recorded on a durable `--type forum` channel. - -Typical user signals: "和 codex/claude 讨论", "brainstorm with another agent", "spawn an implement/check worker", "let agent review", "open an issue board / changelog forum", "look at this thread", "channel is stuck / no output", "progress was truncated", "how do I write that channel command". - -This skill is an index. Load only the reference file for the current job — do not preload all of them. - -## First Commands - -```bash -trellis --version -trellis channel --help -trellis channel list --all -trellis channel list --scope global --all -``` - -If the user names a channel or thread, inspect it before asking for background: - -```bash -trellis channel forum --scope global -trellis channel thread --scope global -trellis channel context list --scope global --thread -``` - -## Route By User Intent - -| User intent | Read | -|---|---| -| "和 codex/claude 讨论一下", "brainstorm with another agent" | `references/workflows.md` | -| "派一个 implement/check agent", "让 agent review", "spawn a worker" | `references/workflows.md`, then `references/workers.md` | -| "开 issue 区 / topic 群 / changelog / board", "make a forum" | `references/forum.md` | -| "看看这个 thread / linked context", "inspect a thread" | `references/forum.md` | -| "channel 卡住了 / 没输出 / progress 被截断", "worker stalled" | `references/progress-debugging.md` | -| "具体命令怎么写", "what flags does X take" | `references/command-reference.md` | - -## Core Rules - -- New forum channels use `--type forum`. A `thread` is one item inside a forum channel. -- Use `--context-file` / `--context-raw` and `trellis channel context add/delete/list`. `--linked-context-*` is deprecated terminology. -- Use `--stdin` or `--text-file` for long messages. Do not put long mixed Chinese/English text in the positional shell argument. -- Pretty `messages` output is an operator dashboard and may truncate progress. Use `--raw` for audit. -- `--as` is the speaker or worker handle, depending on the command. Use explicit, stable names when multiple agents or sessions are involved. -- `--scope project` (default) operates on the current cwd's project bucket; `--scope global` operates on the shared `__global__` bucket. Pick scope deliberately — a global board is invisible from project listings unless `--scope global` is passed. -- For brainstorm, do multiple pressure-test rounds. One answer plus one confirmation is review, not brainstorm. -- **Dispatcher wait pattern**: use `--kind done` / `--kind turn_finished` (trellis-emitted system events), NOT a user `--tag` as the completion signal. CLI help lists `phase_done` / `question` as `--tag` examples but only `interrupt` is a reserved tag with hardcoded trellis behavior; the others are opaque user labels. Relying on a worker to run `send --tag ` is unreliable — LLM workers commonly write the tag string into prose instead of running the actual CLI command. See `references/command-reference.md` "tag vs kind". -- Forum channels are event-sourced. Do not parse `events.jsonl` first; use `forum`, `thread`, `messages --thread`, and `context list`. -- `@mindfoldhq/trellis-core` owns reusable channel/thread state, event append, seq allocation, context/title projection, reducers, and task helpers. The CLI owns flags, terminal rendering, prompts, worker lifecycle, and process exits. - -## Reference Files - -- `references/workflows.md` — canonical collaboration patterns A–F (peer brainstorm, spawned review, dispatch-and-wait, forum issue capture, interrupt-and-redirect, one-shot run). -- `references/forum.md` — forum channels, context, title, rename, changelog forums, thread filtering. -- `references/workers.md` — spawn, agent cards, context injection (`--file` / `--jsonl`), interrupts, kill semantics. -- `references/progress-debugging.md` — progress/raw inspection, stalled worker diagnosis, OOM guard, exit codes. -- `references/command-reference.md` — current CLI command reference (every subcommand, every flag, output conventions, scope/type model). - -## Not For - -- One static review where a markdown file and prompt are enough. -- Replacing normal tool calls with self-logging. -- Long-term memory retrieval. Use durable forum channels for actionable issues, and `trellis mem` (the `trellis-session-insight` skill) for session/history search. diff --git a/.agents/skills/trellis-channel/references/command-reference.md b/.agents/skills/trellis-channel/references/command-reference.md deleted file mode 100644 index 75def26..0000000 --- a/.agents/skills/trellis-channel/references/command-reference.md +++ /dev/null @@ -1,480 +0,0 @@ -# Command Reference - -Authoritative current command reference for `trellis channel` subcommands, -validated against the source in `packages/cli/src/commands/channel/` -(`index.ts` Commander wiring and each subcommand handler). - -Every subcommand accepts `--scope ` unless noted; `project` -is the default and resolves against the current cwd's project bucket. - -## Top-level - -``` -trellis channel -``` - -> Multi-agent collaboration runtime — spawn / coordinate / interrupt worker -> agents through a shared event log. - ---- - -## Create / List - -### `create ` - -```bash -trellis channel create - [--scope project|global] # default: project - [--type chat|forum] # default: chat - [--task ] # associated Trellis task dir - [--project ] - [--labels a,b,c] - [--description ] # stable channel description - [--context-file ] ... # repeatable - [--context-raw ] ... # repeatable - [--linked-context-file ] # [deprecated alias] - [--linked-context-raw ] # [deprecated alias] - [--cwd ] # recorded in create event - [--by ] # default: main - [--force] # overwrite existing channel - [--ephemeral] # hide from default list, prunable -``` - -Behavior: -- Appends a `create` event; immutable `type` (cannot mutate forum↔chat after). -- `--ephemeral` channels are hidden from `channel list` by default and are - the sweep target for `channel prune --ephemeral`. -- `--linked-context-*` are folded into `--context-*`; emit a deprecation - notice when used. - -### `list` - -```bash -trellis channel list - [--scope project|global] - [--json] - [--project ] # substring match on task field - [--all] # include ephemeral (suffix '*') - [--all-projects] # scan every project bucket -``` - -Behavior: -- Default scope: current cwd's project. `--all-projects` scans every bucket. -- Pretty mode prints `NAME WORKERS EVENTS LAST KIND TYPE TASK`, sorted by - recency, with a footer noting hidden ephemeral count. -- `--json` switches to a JSON array. - ---- - -## Chat Messages - -### `send [text]` - -```bash -trellis channel send [text] - --as # REQUIRED — author - [--scope project|global] - [--to ] # default: broadcast - [--stdin | --text-file ] # body from stdin or file - [--delivery-mode appendOnly|requireKnownWorker|requireRunningWorker] -``` - -Behavior: -- Body precedence: positional `[text]` → `--stdin` → `--text-file`. -- `--to` with one entry stores a string; multiple stores an array; omitted - means broadcast. -- `--delivery-mode` selects targeted-delivery validation: - - `appendOnly` (default-ish — just record), - - `requireKnownWorker` (the named target must have a `spawned` event), - - `requireRunningWorker` (the worker must currently be live). -- Prints the appended event as one JSON line on stdout. - -> **Note:** `send` has **no** `--tag` and **no** `--kind` flag. See -> [`tag-vs-kind`](#tag-vs-kind--how-event-shape-is-actually-controlled) below. - -### `messages ` - -```bash -trellis channel messages - [--scope project|global] - [--raw] # one JSON event per line - [--follow] # stream new events - [--last ] # last N matching events - [--since ] # seq > N - [--kind ] # one of CHANNEL_EVENT_KINDS - [--from ] # author filter - [--to ] # routing target filter - [--thread ] # forum-only - [--action ] # forum-only - [--no-progress] # hide progress events -``` - -Behavior: -- Auto-detects forum channels: with no filters it renders the thread board - instead of the event stream. `--thread` / `--action` are forum-only and - error against chat channels. -- `--kind` is validated against `CHANNEL_EVENT_KINDS` (single value, not - CSV — that's the `wait` side). - -### `wait ` - -```bash -trellis channel wait - --as # REQUIRED — self for filter ctx - [--scope project|global] - [--timeout ] # parsed by parseDuration - [--from ] # author CSV - [--kind ] # CSV, OR semantics - [--thread ] # forum filter - [--action ] # forum filter - [--to ] # default: own agent (broadcast + me) - [--include-progress] # also wake on progress events - [--all] # require every --from to match -``` - -Behavior: -- Streams matching events as JSON, one per line. -- Default `--to` filter is the caller's own agent (broadcast events still - match — broadcast + explicit-to-me). -- `--all` requires `--from` and blocks until every listed agent has produced - a matching event. -- **Timeout exits 124** and prints `timeout: still waiting on ...` to stderr - when `--all` was in play. - ---- - -## tag-vs-kind — how event shape is actually controlled - -There is **no `--tag` flag** anywhere in the v0.6.0 channel CLI; `--kind` is -not a legacy alias for any `--tag` flag. - -Concrete model in the current source: - -- `--kind` is the only event-type filter, and it is constrained to the - trellis-emitted whitelist (`CHANNEL_EVENT_KINDS` in - `packages/core/src/channel/internal/store/events.ts`): - - `create`, `join`, `leave`, `message`, `thread`, `context`, `channel`, - `spawned`, `killed`, `respawned`, `progress`, `done`, `error`, - `waiting`, `awake`, `undeliverable`, `interrupt_requested`, - `turn_started`, `turn_finished`, `interrupted`, `supervisor_warning` - - Passing anything else throws - `Invalid --kind ''. Must be one of: …`. -- `--kind` lives on `wait` (CSV, OR semantics) and `messages` (single - value). `send` and `run` cannot emit a custom kind — every `send` writes - a `message` event. -- Mid-turn worker abort is **not** a tag. It is the dedicated - `channel interrupt` command, which appends an `interrupt_requested` / - `interrupted` pair and provider-level interrupts the worker. - -Practical rule for dispatchers waiting on workers: - -- Use `--kind done,turn_finished` for "worker finished a turn" — these are - system events that the supervisor fires automatically. Do not depend on - the worker LLM remembering to emit any custom signal. -- Use `trellis channel interrupt` (the command) only when you actually want - mid-turn abort behavior. -- Do **not** invent user-side tags as completion signals. There is no - `--tag` filter; a worker writing a custom string into its final message - is just text inside a `message` event and cannot be matched by `wait`. - -Long bodies always go through stdin or a file: - -```bash -trellis channel send T --as A --stdin < /tmp/message.md -trellis channel send T --as A --text-file /tmp/message.md -``` - ---- - -## Interrupt - -### `interrupt [text]` - -```bash -trellis channel interrupt [text] - --as # REQUIRED — caller - --to # REQUIRED — target worker - [--scope project|global] - [--stdin | --text-file ] -``` - -Behavior: -- Appends an `interrupt` event with `reason: "user"` and a replacement - instruction body; supervisor performs provider-level interrupt where - supported (Claude `/interrupt`, Codex turn cancel). -- Prints the appended event JSON on stdout. - ---- - -## Workers - -### `spawn ` - -```bash -trellis channel spawn - [--scope project|global] - [--agent ] # loads .trellis/agents/.md - [--provider claude|codex] # overrides agent file - [--as ] # default: agent name - [--cwd ] - [--model ] - [--resume ] # session/thread id resume - [--timeout ] # auto-kill after duration - [--warn-before ] # supervisor_warning lead time - # default 5m, 0ms disables - [--file ] ... # glob, repeatable; inject content - [--jsonl ] ... # Trellis manifest, repeatable - [--by ] # spawn-event author - # default: TRELLIS_CHANNEL_AS env or 'main' - [--inbox-policy explicitOnly|broadcastAndExplicit] - # default explicitOnly - [--idle-timeout ] # OOM-guard idle TTL - # default 5m, 0 disables - [--max-live-workers ] # spawn-time live-worker budget - # default 6, 0 disables -``` - -Behavior: -- Provider is validated against the adapter registry - (`packages/cli/src/commands/channel/adapters/`); current: `claude`, - `codex`. -- Worker stays inbox-idle until the first `send --to `. -- Records a `spawned` event with `pid`, `provider`, `agent`, `files`, - `manifests`. -- OOM-guard precedence: CLI flag → env var - (`TRELLIS_CHANNEL_WORKER_IDLE_TIMEOUT`, - `TRELLIS_CHANNEL_MAX_LIVE_WORKERS`) → - `.trellis/config.yaml#channel.worker_guard` → built-in defaults. - -### `run [name]` - -```bash -trellis channel run [name?] - [--agent ] - [--provider claude|codex] - [--as ] - [--cwd ] - [--model ] - [--file ] ... # repeatable, glob - [--jsonl ] ... # repeatable - [--message | --message-file | --stdin] - [--timeout ] # default 5m -``` - -Behavior: -- One-shot. Auto-generates `run-` if `name` omitted. -- Creates an ephemeral channel (`createMode=run`), spawns a single worker, - sends the prompt, waits for `done`, prints the final assistant text to - stdout, then removes the channel on success. On failure the channel is - kept for inspection and exit code is 1. - -> `run` has **no** `--tag` flag. Completion is detected via the `done` -> event the supervisor emits. - -### `kill ` - -```bash -trellis channel kill - --as # REQUIRED — worker agent name - [--scope project|global] - [--force] # SIGKILL immediately -``` - -Behavior: -- Default path: SIGTERM → 8 s grace → SIGKILL escalation; the CLI writes a - `killed` event when SIGKILL was needed so the log stays truthful. -- Cleans `pid`, `worker-pid`, `config`, `spawnlock` sidecar files; keeps - `log`, `session-id`, `thread-id` for forensics / resume. - -### `rm ` - -```bash -trellis channel rm - [--scope project|global] -``` - -Behavior: -- Kills any live workers, then deletes the entire channel directory. -- Prints `Removed channel ''`. - -### `prune` - -```bash -trellis channel prune - [--scope project|global] # omitted: scan every project - [--all | --empty | --idle | --ephemeral] # mutually exclusive - [--yes] # actually delete (default: dry-run) - [--dry-run] # default true; redundant with default - [--keep ] # exclusion list -``` - -Behavior: -- Filter flags are mutually exclusive — error otherwise. -- Default is dry-run; `--yes` flips to real delete. -- Without `--scope`, scans **every** project bucket (intentional, repo-wide - cleanup); with `--scope project|global`, limited to that bucket. -- Live-worker channels are always skipped regardless of filter. -- Output: per-candidate line `name last-ts (reason)` plus a final summary. - ---- - -## Forum Channels - -### `post ` - -```bash -trellis channel post - --as # REQUIRED - [--scope project|global] - [--thread ] # required except action=opened - [--title ] - [--text | --stdin | --text-file ] - [--description ] # stable thread description - [--status ] - [--labels a,b] # REPLACES thread labels - [--assignees a,b] # REPLACES assignees - [--summary ] - [--context-file ] ... - [--context-raw ] ... - [--linked-context-file ] # [deprecated alias] - [--linked-context-raw ] # [deprecated alias] -``` - -Behavior: -- `` is free-form on the CLI surface; conventional values include - `opened`, `comment`, `status`, `labels`, `assignees`, `summary`, - `processed`. -- `action=rename` is rejected — use `thread rename` instead. -- `--labels` / `--assignees` are replace-semantics, not append. -- Output: appended event JSON on stdout. - -### `forum ` - -```bash -trellis channel forum - [--scope project|global] - [--status ] - [--raw] -``` - -Behavior: -- Lists threads (reduced state). `--status` filters by current thread - status. `--raw` prints one JSON per thread. - -### `thread ` / `thread rename` - -```bash -trellis channel thread - [--scope project|global] - [--raw] - -trellis channel thread rename - --as # REQUIRED - [--scope project|global] -``` - -Behavior: -- `thread ` shows one thread's timeline: - header ` [] `, then description / labels / - assignees / summary / timeline lines. `--raw` switches to raw events. -- `thread rename` is the only mutation; `post --action rename` is rejected. - ---- - -## Context / Title - -### `context add` / `context delete` / `context list` - -```bash -trellis channel context add <name> - [--as <agent>] # default: main - [--scope project|global] - [--thread <key>] # thread-level instead of channel-level - [--file <abs-path>] ... # repeatable - [--raw <text>] ... # repeatable - # at least one of --file or --raw - -trellis channel context delete <name> - [--as <agent>] # default: main - [--scope project|global] - [--thread <key>] - [--file <abs-path>] ... - [--raw <text>] ... - -trellis channel context list <name> - [--scope project|global] - [--thread <key>] - [--raw] # one JSON entry per line -``` - -Behavior: -- `add` / `delete` append a `context` event and print the event JSON. -- `list` projects current context entries; pretty output is - `file <path>` / `raw <truncated text>` lines, `(no context)` when empty. - -### `title set <name>` / `title clear <name>` - -```bash -trellis channel title set <name> - --title <text> # REQUIRED - [--as <agent>] # default: main - [--scope project|global] - -trellis channel title clear <name> - [--as <agent>] # default: main - [--scope project|global] -``` - -Behavior: -- Appends a `title` event projecting a stable display title onto the - channel. Output: event JSON. - ---- - -## Hidden / Internal - -| Command | Purpose | -|---|---| -| `channel __supervisor <channel> <worker> <config>` | Forked entry point invoked by `spawn`. Do not invoke directly. | -| `channel __parse-trace <adapter> <file>` | Dev helper — replays a recorded stream-json / wire trace through the matching adapter and prints the resulting channel events. Adapter is validated against the provider registry. | - ---- - -## Event Model - -`CHANNEL_EVENT_KINDS` (whitelist enforced by `parseChannelKind`): - -`create`, `join`, `leave`, `message`, `thread`, `context`, `channel`, -`spawned`, `killed`, `respawned`, `progress`, `done`, `error`, `waiting`, -`awake`, `undeliverable`, `interrupt_requested`, `turn_started`, -`turn_finished`, `interrupted`, `supervisor_warning`. - -`MEANINGFUL_EVENT_KINDS` (default-visible subset used by `wait` / -`messages` when no explicit `--kind` is given): - -`create`, `join`, `leave`, `message`, `thread`, `context`, `channel`, -`spawned`, `killed`, `respawned`, `done`, `error`. - -Non-meaningful kinds (e.g. `progress`, `waiting`, `awake`, -`supervisor_warning`, the `turn_*` / `interrupt*` set) still flow through -the store; opt in via `--kind` or `--include-progress`. - -Forum channels are event-sourced; use the CLI reducers -(`forum`, `thread`, `context list`) for state projection. - ---- - -## Output Conventions - -- **Mutations** (`send`, `interrupt`, `post`, `context add/delete`, - `title set/clear`, `thread rename`) print the appended event as one JSON - line on **stdout**. -- **Streaming reads** (`wait`, `messages --follow`) print one JSON event - per line on stdout. -- **Pretty reads** (`list`, `messages`, `forum`, `thread`, `context list`) - print colored, padded tables / timelines. -- **`run`** prints only the final assistant text on stdout (so callers can - pipe); diagnostic notes go to stderr. -- **Errors** go through `chalk.red("Error:")` to stderr and `exit 1`. -- **`wait` timeout** specifically exits **124**. - diff --git a/.agents/skills/trellis-channel/references/forum.md b/.agents/skills/trellis-channel/references/forum.md deleted file mode 100644 index 06b7f36..0000000 --- a/.agents/skills/trellis-channel/references/forum.md +++ /dev/null @@ -1,233 +0,0 @@ -# Forum Channels - -Forum channels are durable, topic-style channels. They are created with -`--type forum` at channel-creation time and are immutable after that. They are -not normal chat streams: the default read path is -**forum summary -> one thread timeline -> current context**. - -## Forum vs Regular Channel - -A channel's type is set with `--type` on `channel create` and never changes: - -- `chat` (default) — flat message timeline. `channel messages` always renders - the event stream. Forum-only flags such as `--thread` and `--action` are - rejected here. -- `forum` — thread-oriented. `channel messages` without filters renders a - thread-board summary instead of raw events. The `post`, `forum`, `thread`, - and `thread rename` subcommands only apply to forum channels. - -Both types share the same scope model (`--scope project` is the default; -`--scope global` puts the channel in the cross-project bucket). - -## Create A Forum Channel - -```bash -trellis channel create design-feedback \ - --type forum \ - --scope global \ - --description "Cross-project design feedback board." \ - --context-raw "One thread per design topic; close when resolved." \ - --by main -``` - -Use `--scope project` for a board scoped to one repo, `--scope global` for a -cross-project board. - -## Threads: Open, Comment, Status, Summary - -Threads live inside a forum channel. Each thread is identified by a stable -`--thread <key>` (lowercase kebab-case is conventional). The first action on -a thread is `opened`; everything afterwards uses the same `--thread` key. - -```bash -trellis channel post design-feedback opened \ - --scope global \ - --as main \ - --thread login-empty-state \ - --title "Empty state on the login screen" \ - --description "Track design feedback for the new login empty state." \ - --labels design,login \ - --context-raw "Spotted during the 0.4 release review." \ - --text-file /tmp/thread-open.md - -trellis channel post design-feedback comment \ - --scope global \ - --as reviewer \ - --thread login-empty-state \ - --text-file /tmp/review.md - -trellis channel post design-feedback status \ - --scope global \ - --as main \ - --thread login-empty-state \ - --status closed - -trellis channel post design-feedback summary \ - --scope global \ - --as main \ - --thread login-empty-state \ - --summary "Adopted the option-B layout; ticket TRELLIS-123 owns the fix." -``` - -Key distinctions: - -- `--description` is the **durable** thread description (the answer to "what - is this thread about?"). It is set on `opened` and edited by re-running - `post` with `--description`. -- `--text` / `--stdin` / `--text-file` is the **event body** — the comment or - payload attached to this specific timeline entry. -- `--labels` and `--assignees` are CSV and **replace** the current value; they - do not append. -- `--summary` is the rolling thread summary. Setting it on `status closed` is - the standard way to mark a thread resolved with context. - -`--thread` is required for every action except `opened` (where it is also -required in practice — there is no anonymous thread). - -## Read A Forum - -```bash -trellis channel messages design-feedback --scope global -trellis channel forum design-feedback --scope global --status open -trellis channel thread design-feedback login-empty-state --scope global -trellis channel messages design-feedback --scope global --raw --thread login-empty-state -``` - -If a peer says "I commented on the forum", run `channel forum` first to see -which thread changed, then drill into that thread with `channel thread <name> -<thread>`. Do not jump straight to ad-hoc `events.jsonl` parsing. - -## Context - -Context entries are durable background that should always be in scope when -reading a channel or a thread. They are **not** timeline events; they are -projected separately and replayed for every reader. - -Use the `context` subcommands. The legacy `--linked-context-file` / -`--linked-context-raw` flags on `create` and `post` are deprecated aliases -that fold into the canonical `--context-file` / `--context-raw`. - -### Add Context - -```bash -# Channel-level context (whole forum) -trellis channel context add design-feedback \ - --scope global \ - --raw "Upstream feedback board; please link tasks before opening threads." - -# Thread-level context (one thread) -trellis channel context add design-feedback \ - --scope global \ - --thread login-empty-state \ - --file "$PWD/.trellis/tasks/05-13-login-redesign/design.md" -``` - -- `--thread <key>` switches between channel-level and thread-level context. -- `--file` paths **must be absolute**; relative paths are rejected. -- `--raw` is plain text inline content. -- Both flags are repeatable; at least one is required for `add` / `delete`. -- `--as <agent>` records authorship; defaults to `main`. - -### List Context - -```bash -trellis channel context list design-feedback --scope global -trellis channel context list design-feedback --scope global --thread login-empty-state --raw -``` - -`--raw` on `list` emits one JSON entry per line (useful for piping); without -it you get a human-readable `file <path>` / `raw <truncated text>` listing. -An empty store prints `(no context)`. - -### Delete Context - -```bash -trellis channel context delete design-feedback \ - --scope global \ - --thread login-empty-state \ - --raw "stale note" -``` - -You delete by **value**, not by id: pass the same `--file` or `--raw` value -that was added. Repeat the flag to delete multiple entries in one call. - -### Reading Order - -When reading a thread, work top-down: - -1. Thread `description` (the durable "what is this about"). -2. Context entries (channel-level + thread-level). -3. Timeline (`opened`, `comment`, `status`, `summary`). - -If a context file is missing or unreadable, state that explicitly and -continue with the remaining data — do not fabricate the content. - -## Title Projection - -`title` projects a stable display title onto the channel without renaming the -storage address. The channel `name` you pass to every command stays the same. - -```bash -trellis channel title set design-feedback \ - --scope global \ - --title "Design feedback board" - -trellis channel title clear design-feedback --scope global -``` - -- `title set` requires `--title`. -- `--as <agent>` records authorship; defaults to `main`. -- This is a presentation-layer change. Tooling and scripts keep using the - original channel name. - -## Thread Rename - -`thread rename` is the correction path when a thread was opened with the -wrong key (typo, wrong slug convention, etc.). Threads do not support hard -deletion — rename is the supported corrective action. - -```bash -trellis channel thread rename design-feedback old-key new-key \ - --scope global \ - --as main -``` - -- `--as <agent>` is **required**. -- `post <name> rename` is rejected — you must use `thread rename`. - -## Deletion Discipline - -Do not model single-comment deletion or hard thread deletion as normal -workflow. Forum threads are append-only collaboration history. To correct -state, use: - -- `post ... status` to mark a thread closed / blocked / etc. -- `post ... summary` to record the resolution. -- `post ... --labels` to re-label (replaces the set). -- `thread rename` to correct a bad thread key. - -## Internal Changelog Pattern - -A common use of a global forum channel is an internal release / runtime -changelog. One thread per notable change keeps history searchable: - -```bash -trellis channel create release-notes \ - --type forum \ - --scope global \ - --description "Internal release and runtime changelog." \ - --context-raw "One thread per notable change; close when shipped." \ - --by main - -trellis channel post release-notes opened \ - --scope global \ - --as main \ - --thread release-2026-q1 \ - --title "Channel threads and forum UX in 0.6" \ - --description "Forum channel UX shipped in the 0.6 line." \ - --labels channel,release \ - --text-file /tmp/release-notes.md -``` - -Use stable, descriptive thread keys (e.g. `release-2026-q1`, -`runtime-event-schema-change`) so later readers can find them by name. diff --git a/.agents/skills/trellis-channel/references/progress-debugging.md b/.agents/skills/trellis-channel/references/progress-debugging.md deleted file mode 100644 index 3ed40d6..0000000 --- a/.agents/skills/trellis-channel/references/progress-debugging.md +++ /dev/null @@ -1,226 +0,0 @@ -# Progress And Debugging - -Pretty output is for operators. Raw output is the audit log. Subcommands -(`forum`, `thread`, `messages`, `context`) are the audit *interface* — reach -for them before grepping `events.jsonl` by hand. - -## Pretty vs `--raw` - -`trellis channel messages <channel>` renders a compact, human-readable view: -timestamps, identities, kind, and a short body. It is meant for operators -scanning a channel, not for diagnostics. - -Pretty output can and will truncate: - -- long progress deltas (`text_delta`, partial tool args) -- tool names and command lines -- multi-line status fields and structured `detail` blobs -- forum thread titles past the column budget - -When something looks "off" — a worker appears stuck, a progress line ends -mid-word, an action field shows `...` — switch to `--raw`. Raw mode emits -one JSON event per line exactly as it lives in `events.jsonl`, so nothing -is dropped. - -```bash -# Pretty (operator view) -trellis channel messages <channel> --kind done --last 10 -trellis channel messages <channel> --kind error --last 10 - -# Raw (diagnostic view) — one JSON per line -trellis channel messages <channel> --raw --kind progress --last 20 -trellis channel messages <channel> --raw --last 50 -``` - -Rule of thumb: never diagnose a worker from a truncated progress line. - -### Rebuild Streaming Text - -To reconstruct what a model actually streamed during a turn, concatenate -`detail.text_delta` from progress events: - -```bash -trellis channel messages <channel> --raw --kind progress --last 80 \ - | python3 -c 'import json,sys; [print((json.loads(l).get("detail") or {}).get("text_delta",""), end="") for l in sys.stdin if l.strip()]' -``` - -## Stalled Worker Diagnosis - -Symptom: `trellis channel list` shows the worker as running, but no new -events appear in `messages` and `wait` keeps timing out. - -Triage order: - -1. **Locate the channel files.** Use `list --all --all-projects` if you are - not sure which bucket the channel lives in. - - ```bash - trellis channel list --all --all-projects - CHAN=~/.trellis/channels/<bucket>/<channel> - ``` - -2. **Confirm the supervisor and worker PIDs are alive.** - - ```bash - cat "$CHAN/<worker>.pid" # supervisor PID - cat "$CHAN/<worker>.worker-pid" # actual CLI subprocess PID - ps -p "$(cat "$CHAN/<worker>.pid")" - ps -p "$(cat "$CHAN/<worker>.worker-pid")" - ``` - - If the supervisor PID is gone but the channel still lists the worker, - you have a ghost entry — clean it with - `trellis channel kill <name> --as <worker> --force`. - -3. **Tail the worker log.** This is the canonical place to see provider / - MCP / tool startup output that never makes it onto the channel. - - ```bash - tail -f "$CHAN/<worker>.log" - ``` - -4. **Check the last raw events.** A worker that emitted `progress` but no - `message`/`done` is usually mid-stream or blocked on a tool call: - - ```bash - trellis channel messages <channel> --raw --last 50 - ``` - -Common "alive but silent" causes: - -- Provider cold start before the first token (long, but eventually moves). -- A blocking MCP server during startup — visible in the worker log. -- Worker is waiting for a tool result whose subprocess hung. -- Prompt is huge / model is rate-limited; check provider-side errors in the - worker log. - -## Progress Event Interpretation - -A `progress` event represents an in-flight piece of work. Its shape varies -by `action` field, but the load-bearing fields are always under `detail`: - -- `detail.text_delta` — incremental model output (concatenate across events - to rebuild the streamed reply). -- `detail.tool_name`, `detail.tool_input` — tool call about to run or - currently running. -- `detail.status` — short string used by long-running actions - (`starting`, `running`, `flushing`, `done`). -- `detail.action` — semantic label (e.g. `status` for thread heartbeats). - -Progress events are **noisy** by design. `wait` ignores them unless you -pass `--include-progress`. When you do want to see them, prefer: - -```bash -trellis channel messages <channel> --raw --kind progress --last 80 -``` - -A stream that emits progress at a steady cadence but never closes with -`done`/`error`/`message` is the classic shape of a hung tool call — -inspect the worker log for the subprocess. - -## Wait Semantics (Quick Reference) - -`channel wait` watches `events.jsonl` from EOF and wakes on: - -- `message` -- `done` -- `error` -- `killed` -- `progress` only with `--include-progress` - -Useful filters: - -```bash -trellis channel wait T --as main --from check --kind done --timeout 15m -trellis channel wait T --as main --from check,check-cx --kind done --all --timeout 15m -trellis channel wait T --as worker --tag interrupt --timeout 1h -trellis channel wait T --as main --thread release-note --action status --timeout 10m -``` - -Exit codes: `0` matched, `124` timeout, `1`/`2` errors. On `wait --all` -timeout, stderr names the workers still missing. - -## Auditing `events.jsonl` — Use Subcommands, Not `grep` - -Every channel persists its full history at `$CHAN/events.jsonl`. It is -tempting to `tail` / `grep` / `jq` this file directly during debugging. -Don't make it a habit, and **never** do it for forum channels. - -Why subcommands first: - -- `messages` already replays the file with filters (`--kind`, `--from`, - `--last`, `--tag`, `--thread`, `--action`) and gives you `--raw` for the - exact JSON. Anything you would write a one-liner for, `messages` already - does. -- `wait` consumes the same file with EOF semantics — re-implementing that - with `tail -f | jq` will drop events under load and misorder them under - rotation. -- `context` materializes a worker's inbox view, including cursor state. - Hand-rolled filters do not respect `<worker>.inbox-cursor`. - -### Forum channels: never parse `events.jsonl` directly - -Forum channels multiplex many logical threads onto a single `events.jsonl`. -Each event carries `thread`, `action`, and tag fields that the forum -subcommands know how to fold together. Parsing the file by hand will: - -- Mix threads together and make a thread look incoherent. -- Miss thread lifecycle events (open / status / close) that change how - later events should be interpreted. -- Ignore worker inbox cursors, so you will "see" events a worker has - already consumed and assume they are pending. - -Use the forum-aware views instead: - -```bash -# List logical threads inside the forum channel -trellis channel forum list <channel> - -# Inspect one thread end-to-end -trellis channel thread show <channel> <thread> - -# Replay messages for a thread (supports --raw, --kind, --last) -trellis channel messages <channel> --thread <thread> --raw --last 100 - -# What a specific worker still has pending -trellis channel context <channel> --as <worker> -``` - -Direct reads of `events.jsonl` are reserved for the case where the CLI -itself is suspect — e.g. confirming an event was actually persisted, or -diffing against `<worker>.inbox-cursor` while debugging the supervisor. - -## Common Failures - -| Symptom | Cause | Fix | -|---|---|---| -| `trellis: command not found` | CLI not installed globally | `npm install -g @mindfoldhq/trellis` | -| `wait` exits immediately | wrong filter or identity collision | use distinct `--as`, inspect raw messages | -| zsh errors on message text | shell interpreted punctuation | use `--stdin` or `--text-file` | -| progress line is cut off | pretty output truncation | use `messages --raw --kind progress` | -| worker never speaks | provider startup / prompt / MCP delay | inspect `<worker>.log`, `ps`, raw events | -| channel not found in another cwd | project bucket mismatch | `cd` to project, use `--scope global`, or `list --all-projects` | -| ghost worker in list | supervisor died without cleanup | `trellis channel kill <name> --as <worker> --force` | -| forum thread looks scrambled | parsed `events.jsonl` directly | use `forum`, `thread`, `messages --thread` | - -## Storage Layout - -```text -~/.trellis/channels/ -└── <bucket>/ - └── <channel-name>/ - ├── events.jsonl - ├── <channel>.lock - ├── <worker>.log - ├── <worker>.pid - ├── <worker>.worker-pid - ├── <worker>.config - ├── <worker>.session-id - ├── <worker>.thread-id - ├── <worker>.inbox-cursor - └── <worker>.spawnlock -``` - -Agents normally use the CLI, not direct file reads. Direct file reads are -for debugging when CLI views are insufficient — and even then, never on a -forum channel's `events.jsonl`. diff --git a/.agents/skills/trellis-channel/references/workers.md b/.agents/skills/trellis-channel/references/workers.md deleted file mode 100644 index bcec98f..0000000 --- a/.agents/skills/trellis-channel/references/workers.md +++ /dev/null @@ -1,276 +0,0 @@ -# Workers And Agent Cards - -Use workers when a peer agent should execute independently and report back -through the channel event log. A worker is a registered child process (claude -or codex) attached to a channel; the supervisor forwards inbox messages to it -and translates its output back into channel events. - -## Spawn - -```bash -trellis channel create impl-task --by dispatcher --cwd /path/to/repo -trellis channel spawn impl-task --provider codex --as codex-impl --timeout 30m - -echo "Implement the schema for table X per .trellis/.../prd.md" \ - | trellis channel send impl-task --as dispatcher --to codex-impl --stdin - -trellis channel wait impl-task --as dispatcher --from codex-impl --kind done --timeout 30m -``` - -`spawn` forks a `channel __supervisor` worker that emits `spawned`, streams -`progress`, and should end with `done`, `error`, or `killed`. Workers stay -inbox-idle until a `send --to <worker>` (or a broadcast when -`--inbox-policy broadcastAndExplicit` is set) wakes them. - -Key `spawn` flags: - -- `--agent <name>` — load `.trellis/agents/<name>.md` (provider/model/as/system prompt defaults). -- `--provider <claude|codex>` — overrides the agent card; validated against the adapter registry. -- `--as <name>` — channel worker handle; defaults to the agent name. -- `--cwd <path>` — worker working directory (also the jail root for `--file`/`--jsonl`). -- `--model <id>` — model override. -- `--resume <id>` — resume an existing claude session / codex thread. -- `--timeout <duration>` — auto-kill after `30s` / `2m` / `1h`. -- `--warn-before <duration>` — supervisor_warning lead time (default `5m`; `0ms` disables). -- `--file <path>` (repeatable, glob-supported) — inject file content into the system prompt. -- `--jsonl <path>` (repeatable) — Trellis jsonl manifest (`{file, reason}` per line). -- `--by <agent>` — author of the `spawned` event (defaults to `$TRELLIS_CHANNEL_AS` or `main`). -- `--inbox-policy <explicitOnly|broadcastAndExplicit>` — default `explicitOnly`. -- `--idle-timeout <duration>` — OOM guard idle TTL (default `5m`; `0` disables). -- `--max-live-workers <n>` — spawn-time live-worker budget (default `6`; `0` disables). - -The success event `spawned` records `pid`, `provider`, `agent`, the injected -`files`, and the resolved `manifests` so later spectators can audit context. - -## Agent Cards - -`--agent <name>` resolves to `.trellis/agents/<name>.md`. The card name must -match `[A-Za-z0-9._-]+`. The default Trellis install ships two cards: - -- `.trellis/agents/check.md` — code-quality reviewer. -- `.trellis/agents/implement.md` — coding worker for implementation runs. - -```yaml ---- -name: check -description: Code quality check expert. -provider: claude ---- -``` - -Frontmatter fields populate `spawn` defaults (provider, model, `as`); the -markdown body becomes the worker's system-prompt role. Cards do **not** -auto-attach task files — context must be injected explicitly per spawn (see -below). - -Always inspect project cards before spawning a named agent: - -```bash -ls .trellis/agents -sed -n '1,100p' .trellis/agents/check.md -``` - -## Context Injection - -Two flags inject content into the worker's system prompt under a -`# CONTEXT FILES` block, assembled by `context-loader`: - -- `--file <path>` — repeatable, glob-supported (`*`, `**`). Each match is - read and concatenated. -- `--jsonl <path>` — repeatable Trellis manifest where every line is - `{"file":"<path>","reason":"<why>"}`. The reason is preserved as a header - comment above each file's content. - -Limits enforced by the loader: - -- 1 MB hard cap per file (oversize → error). -- 200 KB per-file warning to stderr. -- 500 KB total assembled-context warning to stderr. -- Path-traversal jail: all resolved paths must stay under `--cwd`. - -Example spawning a check agent against a task directory: - -```bash -TASK=.trellis/tasks/05-13-example -trellis channel spawn cr-example --agent check --provider codex --as check-cx \ - --file "$TASK/prd.md" \ - --file "$TASK/design.md" \ - --file "$TASK/implement.md" \ - --jsonl "$TASK/check.jsonl" \ - --cwd "$PWD" --timeout 30m -``` - -The `spawned` event records both the literal `files` array and any `manifests` -expanded from `--jsonl`, so the audit trail captures whatever the worker was -actually shown. - -## Names And Routing - -`--as` has two meanings: - -- `send` / `wait` / `interrupt`: speaker identity (author of the resulting event). -- `spawn`: the worker handle that other agents address with `--to`. - -Use explicit names when multiple workers or providers participate in one -channel: - -```bash -trellis channel spawn cr-feature --agent check --as check-claude -trellis channel spawn cr-feature --agent check --provider codex --as check-cx - -trellis channel wait cr-feature --as main \ - --from check-claude,check-cx --kind done --all --timeout 15m -``` - -`--all` requires `--from` and blocks until every listed worker has produced a -matching event; timeout exits with code **124** and prints -`timeout: still waiting on ...` to stderr. - -## Soft Interrupt — `interrupt` - -`channel interrupt` is the cooperative redirect: it appends an `interrupt` -event (reason `"user"`) and, where the adapter supports it, issues a -provider-level turn interrupt with a replacement instruction. Use it when the -worker should drop its current turn and act on new input immediately, without -losing its session. - -```bash -echo "Stop refactoring the parser — switch to fixing the failing test in src/foo.ts" \ - | trellis channel interrupt impl-task --as dispatcher --to codex-impl --stdin -``` - -Flags: - -- `--as <agent>` **(required)** — caller identity. -- `--to <agent>` **(required)** — target worker. -- `--scope <project|global>` — channel scope. -- `--stdin` / `--text-file <path>` / `[text]` — replacement instruction body. - -The appended event has `kind: "interrupt"` — downstream `wait` / `messages` -filters can subscribe with `--kind interrupt` to react to redirections (e.g. -to log the rerouting, or to gate other workers behind a coordinator's -correction). - -For low-priority hints that should wait for the worker's next turn, send a -plain tagged message instead: - -```bash -echo "Check this when you reach the next turn." \ - | trellis channel send impl-task --as dispatcher --to codex-impl \ - --stdin --tag question -``` - -## Hard Interrupt — `kill` + `--resume` - -Use `kill` when the worker must stop **now** (e.g. runaway loop, bad -instructions already in flight, or `interrupt` is not honored by the -adapter). The supervisor escalates SIGTERM → 8 s grace → SIGKILL; the CLI -writes a `killed` event when SIGKILL is needed so the event log stays -truthful. - -```bash -trellis channel kill impl-task --as codex-impl -trellis channel spawn impl-task --as codex-impl --provider codex \ - --resume "$(cat ~/.trellis/channels/<bucket>/impl-task/worker.session-id)" - -echo "STOP — new instructions: ..." \ - | trellis channel send impl-task --as dispatcher --to codex-impl --stdin -``` - -`kill` flags: - -- `--as <agent>` **(required)** — names the worker (positional `<name>` is the channel). -- `--scope <project|global>`. -- `--force` — SIGKILL immediately (also kills the inner worker pid). - -Side effects: cleans `pid`, `worker-pid`, `config`, `spawnlock` sidecar -files; keeps `log`, `session-id`, `thread-id` for forensics and resume. - -When `interrupt` will not converge, kill + `--resume` is the guaranteed -redirection path. - -## Worker OOM Guard - -The OOM guard prevents orphaned/idle workers from accumulating and exhausting -host resources. It runs at every `spawn` and enforces two policies per -project bucket: - -- **Idle TTL** — sweep workers whose last activity is older than the - configured threshold (default `5m`; `0` disables). -- **Live-worker budget** — refuse the new spawn if more than N workers are - already alive in the same project bucket (default `6`; `0` disables). - -Precedence (highest first): - -1. CLI flags: `--idle-timeout`, `--max-live-workers` on `spawn`. -2. Environment variables: `TRELLIS_CHANNEL_WORKER_IDLE_TIMEOUT`, - `TRELLIS_CHANNEL_MAX_LIVE_WORKERS`. -3. `.trellis/config.yaml` under `channel.worker_guard`. -4. Built-in defaults (`5m`, `6`). - -Cleanup notices are written to stderr at spawn time so operators can see which -idle workers were swept and why a new spawn was rejected. The guard does not -touch ephemeral / `channel run` workers any differently — they are subject to -the same idle TTL and budget. - -To audit current state, list workers via `channel list` (the `WORKERS` -column) and inspect per-channel `pid` / `worker-pid` sidecar files under -`~/.trellis/channels/<bucket>/<channel>/`. - -## Worker Inbox APIs - -The inbox is the channel surface workers wake on. Routing is controlled by -two knobs: - -- **Inbox policy** (`spawn --inbox-policy`): - - `explicitOnly` (default) — worker only wakes on `send --to <worker>` or - `interrupt --to <worker>`. - - `broadcastAndExplicit` — also wakes on broadcasts (`send` with no `--to`). -- **Delivery mode** (`send --delivery-mode`): - - `appendOnly` — append the event regardless of worker state. - - `requireKnownWorker` — fail if no worker named in `--to` was ever spawned. - - `requireRunningWorker` — fail if the named worker is not currently alive. - -Stricter delivery modes prevent silent message loss when callers expect a -running peer. - -Inbox-relevant subcommands: - -- `send <channel> [text]` — append a `message` event. - - `--as <agent>` **(required)** — author. - - `--to <agents>` — CSV; one → string, many → array; broadcast if omitted. - - `--stdin` / `--text-file <path>` / `[text]` — body source. - - `--delivery-mode <appendOnly|requireKnownWorker|requireRunningWorker>`. -- `interrupt <channel> [text]` — soft-interrupt redirect (see above). -- `wait <channel>` — block until matching events arrive. - - `--as <agent>` **(required)** — `self` for filter context. - - `--from <agents>` — CSV authors. - - `--kind <kind[,kind...]>` — CSV (OR semantics); supports `interrupt`, - `done`, `progress`, etc. - - `--to <target>` — defaults to own agent (broadcast + explicit-to-me). - - `--include-progress` — also wake on progress events. - - `--all` — require every `--from` agent to match (timeout → exit **124**). - - `--timeout <duration>` — `30s` / `2m` / `1h` / `1000ms`. -- `messages <channel>` — view / filter / follow the event stream. - - `--follow` to tail, `--kind` / `--from` / `--to` to filter, `--raw` for - JSON-per-line, `--no-progress` to hide progress noise. - -A typical dispatcher loop: - -```bash -# 1. Wake the worker. -echo "Run the failing test and report." \ - | trellis channel send impl-task --as dispatcher --to codex-impl --stdin \ - --delivery-mode requireRunningWorker - -# 2. Block until it finishes. -trellis channel wait impl-task --as dispatcher \ - --from codex-impl --kind done,error --timeout 30m - -# 3. Read the final answer. -trellis channel messages impl-task --from codex-impl --last 1 --raw -``` - -All event-emitting subcommands (`send`, `interrupt`, `post`, `context add` / -`delete`, `title set` / `clear`, `thread rename`) print the appended event as -a single JSON line on stdout, making the inbox layer easy to script against. diff --git a/.agents/skills/trellis-channel/references/workflows.md b/.agents/skills/trellis-channel/references/workflows.md deleted file mode 100644 index 3319764..0000000 --- a/.agents/skills/trellis-channel/references/workflows.md +++ /dev/null @@ -1,128 +0,0 @@ -# Workflows - -Use these patterns by intent. Prefer durable channels for multi-round work and -`channel run` for one-shot questions. - -## Pattern A: Multi-round Brainstorm - -Use when the user says "和 codex/claude 讨论一下", "brainstorm", or "拉一个 agent -进来一起看". - -```bash -trellis channel create brainstorm-storage-layer --by main \ - --task .trellis/tasks/05-XX-storage-adapter - -trellis channel spawn brainstorm-storage-layer \ - --agent architect --provider codex \ - --file .trellis/tasks/05-XX-storage-adapter/prd.md \ - --file .trellis/tasks/05-XX-storage-adapter/design.md \ - --as cx-arch --timeout 30m - -trellis channel send brainstorm-storage-layer \ - --as main --to cx-arch --text-file /tmp/brainstorm-r1.md - -trellis channel wait brainstorm-storage-layer \ - --as main --kind done --from cx-arch --timeout 10m -``` - -Do not stop after one answer. Read the answer, identify vague areas, send a -new probe, and repeat until the result is executable. - -Minimum round structure: - -1. Direction split: should this live in an existing mechanism or a new one? -2. MVP boundary: v1, v2, and what would force v2 back into v1. -3. Data contract: events, schema, metadata, state source of truth, compatibility. -4. CLI / UX contract: command names, flags, errors, defaults, ambiguity. -5. Cross-layer risk and tests: shared helpers, drift points, release-blocking tests. - -Optional rounds: - -- Operations: logs, debugging, stuck workers, kill/restart, recovery. -- Migration/release: breaking status, manifest, changelog, docs-site. -- Opposition review: ask the peer agent to argue against the current plan. - -Every probe should request concrete file paths, commands, schema, rejected -alternatives, and release-blocking issues. Reject hedging when a decision is -needed. - -## Pattern B: Implement / Check Agent - -Use when the user asks to dispatch implementation or review work. - -```bash -TASK=.trellis/tasks/05-12-foo -trellis channel create cr-foo --task "$TASK" --by main - -trellis channel spawn cr-foo \ - --agent check \ - --jsonl "$TASK/check.jsonl" \ - --file "$TASK/prd.md" \ - --file "$TASK/design.md" \ - --file "$TASK/implement.md" \ - --cwd "$PWD" --timeout 15m - -trellis channel send cr-foo --as main --to check --text-file /tmp/cr-brief.md -trellis channel wait cr-foo --as main --kind done --from check --timeout 15m -trellis channel messages cr-foo --kind message --from check --tag final_answer -``` - -For implement work, use `--agent implement` and send an implementation brief. -For check work, include the exact diff scope, relevant specs, and validation -already run. - -## Pattern C: Parallel Reviewers - -Use one channel and distinct worker names. - -```bash -trellis channel create cr-feature --by main --ephemeral - -trellis channel spawn cr-feature --agent check \ - --jsonl "$TASK/check.jsonl" --file "$TASK/prd.md" --file "$TASK/design.md" \ - --timeout 15m - -trellis channel spawn cr-feature --agent check --provider codex --as check-cx \ - --jsonl "$TASK/check.jsonl" --file "$TASK/prd.md" --file "$TASK/design.md" \ - --timeout 15m - -trellis channel send cr-feature --as main --to check --text-file /tmp/cr-brief.md -trellis channel send cr-feature --as main --to check-cx --text-file /tmp/cr-brief.md -trellis channel wait cr-feature --as main --kind done --from check,check-cx --all --timeout 15m -``` - -`--all` means every listed worker must emit a matching event. - -## Pattern D: One-shot Worker - -```bash -trellis channel run --provider codex --message "say hi in 3 words" --timeout 1m -trellis channel run --agent plan --message-file /tmp/plan-question.md --timeout 10m -``` - -On success, `run` removes the ephemeral channel. On error/timeout/killed, it -keeps the channel and prints the path for inspection. - -## Pattern E: Forum Channel - -Use for issue forums, topic-style feedback, release todos, agent findings, and -internal changelogs. Read `forum.md` for the full model. - -## Pattern F: Take Over Existing Thread - -If the user gives a forum/thread name, restore context yourself: - -```bash -trellis channel forum <board> --scope global -trellis channel thread <board> <thread> --scope global --raw -trellis channel context list <board> --scope global --thread <thread> -trellis channel messages <board> --scope global --raw --thread <thread> -``` - -Output a constraint summary, not a transcript dump: - -- user-level problem -- context files that affect this repo -- current-version versus future-version requirements -- whether current code/design satisfies it -- next action or comment to append diff --git a/.agents/skills/trellis-check/SKILL.md b/.agents/skills/trellis-check/SKILL.md deleted file mode 100644 index 7e70dc6..0000000 --- a/.agents/skills/trellis-check/SKILL.md +++ /dev/null @@ -1,111 +0,0 @@ ---- -name: trellis-check -description: "Comprehensive quality verification: spec compliance, lint, type-check, tests, cross-layer data flow, code reuse, and consistency checks. Use when code is written and needs quality verification, before committing changes, or to catch context drift during long sessions." ---- - -# Code Quality Check - -Comprehensive quality verification for recently written code. Combines spec compliance, cross-layer safety, and pre-commit checks. - ---- - -## Step 1: Identify What Changed - -```bash -git diff --name-only HEAD -git status -``` - -## Step 2: Read Task Artifacts and Applicable Specs - -Read the current task artifacts in order: - -- `prd.md` -- `design.md` if present -- `implement.md` if present - -```bash -python3 ./.trellis/scripts/get_context.py --mode packages -``` - -For each changed package/layer, read the spec index and follow its **Quality Check** section: - -```bash -cat .trellis/spec/<package>/<layer>/index.md -``` - -Read the specific guideline files referenced — the index is a pointer, not the goal. - -## Step 3: Run Project Checks - -Run the project's lint, type-check, and test commands. Fix any failures before proceeding. - -## Step 4: Review Against Checklist - -### Code Quality - -- [ ] Linter passes? -- [ ] Type checker passes (if applicable)? -- [ ] Tests pass? -- [ ] No debug logging left in? -- [ ] No suppressed warnings or type-safety bypasses? - -### Test Coverage - -- [ ] New function → unit test added? -- [ ] Bug fix → regression test added? -- [ ] Changed behavior → existing tests updated? - -### Spec Sync - -- [ ] Does `.trellis/spec/` need updates? (new patterns, conventions, lessons learned) - -> "If I fixed a bug or discovered something non-obvious, should I document it so future me won't hit the same issue?" → If YES, update the relevant spec doc. - -### Scope Discipline - -- [ ] Any tidying of code the task did not require? -- [ ] Any abstraction, config or extension point added for a case that does not exist yet? -- [ ] Any speculative fallback for a state that cannot occur? -- [ ] Any file changed that the acceptance criteria do not mention? -- [ ] Any workaround added at the caller instead of a fix where the behavior actually lives? - -## 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 the same value repeats, does it represent one stable concept whose callers must change together? Extract only then — two literals that merely happen to match today should stay separate. -- [ ] 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 every violation you find. Then: - -- Mechanical and local (lint nit, missing type, wrong import, dead branch, failing assertion) → fix in place, then re-run project checks. -- Design or judgment (naming a shared concept, moving a module boundary, changing a public interface, reassigning where behavior lives) → record the evidence and your recommendation, and stop. Do not rewrite it silently. - -If a fix would touch files outside the current task's scope, say so and stop instead of widening the change. diff --git a/.agents/skills/trellis-continue/SKILL.md b/.agents/skills/trellis-continue/SKILL.md deleted file mode 100644 index 4aceae7..0000000 --- a/.agents/skills/trellis-continue/SKILL.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -name: trellis-continue -description: "Resume work on the current task. Loads the workflow Phase Index, figures out which phase/step to pick up at, then pulls the step-level detail via get_context.py --mode phase. Use when coming back to an in-progress task and you need to know what to do next." ---- - -# Continue Current Task - -Resume work on the current task — pick up at the right phase/step in `.trellis/workflow.md`. - ---- - -## Step 1: Load Current Context - -```bash -python3 ./.trellis/scripts/get_context.py -``` - -Confirms: current task, git state, recent commits. - -## Step 2: Load the Phase Index - -```bash -python3 ./.trellis/scripts/get_context.py --mode phase -``` - -Shows the Phase Index (Plan / Execute / Finish) with routing + skill mapping. - -## Step 3: Decide Where You Are - -`get_context.py` shows the active task's `status` field. Route by `status` + artifact presence. This command replaces the user needing to remember the Trellis flow; it does not itself approve implementation. - -- `status=planning` + no `prd.md` → **1.1** (load `trellis-brainstorm`) -- `status=planning` + `prd.md` only → decide whether the task is lightweight or complex. Lightweight can move to **1.4** review; complex returns to **1.1** to add `design.md` + `implement.md`. -- `status=planning` + complex artifacts complete + sub-agent jsonl not curated (only the seed `_example` row) → **1.3** -- `status=planning` + required artifacts complete + required jsonl curated or inline mode → **1.4** (ask for start review; only run `task.py start` after user confirms) -- `status=in_progress` + implementation not started → **2.1** -- `status=in_progress` + implementation done, not yet checked → **2.2** -- `status=in_progress` + check passed → **3.3** (spec update) → **3.4** (commit) -- `status=completed` (rare; usually archived immediately) → archive flow - -Phase rules (full detail in `.trellis/workflow.md`): - -1. Run steps **in order** within a phase — `[required]` steps must not be skipped -2. `[once]` steps are already done if the required output exists. `prd.md` alone can be enough only for lightweight tasks; complex tasks also need `design.md` and `implement.md`. -3. You may go back to an earlier phase if discoveries require it - -## Step 4: Load the Specific Step - -Once you know which step to resume at: - -```bash -python3 ./.trellis/scripts/get_context.py --mode phase --step <X.X> --platform codex -``` - -Follow the loaded instructions. After each `[required]` step completes, move to the next. - ---- - -## Reference - -Full workflow and detailed phase steps live in `.trellis/workflow.md`. This command is only an entry point — the canonical guidance is there. diff --git a/.agents/skills/trellis-finish-work/SKILL.md b/.agents/skills/trellis-finish-work/SKILL.md deleted file mode 100644 index 5caebb5..0000000 --- a/.agents/skills/trellis-finish-work/SKILL.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -name: trellis-finish-work -description: "Wrap up the current session: verify quality gate passed, remind user to commit, archive completed tasks, and record session progress to the developer journal. Use when done coding and ready to end the session." ---- - -# Finish Work - -Wrap up the current session: archive the active task (and any other completed-but-unarchived tasks the user wants to clean up) and record the session journal. Code commits are NOT done here — those happen in workflow Phase 3.4 before you invoke this command. - -## Step 1: Survey current state - -```bash -python3 ./.trellis/scripts/get_context.py --mode record -``` - -This prints: - -- **My active tasks** — review whether any besides the current one are actually done (code merged, AC met) and should be archived this round. -- **Git status** — quick visual on what's dirty. -- **Recent commits** — you'll need their hashes in Step 4 for `--commit`. - -If `--mode record` surfaces other completed tasks not tied to the current session, surface them to the user with a one-shot confirmation: "These N tasks look done — archive them too in this round? [y/N]". Default is no; the current active task is always archived in Step 3 regardless. - -## Step 2: Sanity check — classify dirty paths - -Run: - -```bash -git status --porcelain -``` - -Filter out paths under `.trellis/workspace/` and `.trellis/tasks/` — those are managed by `add_session.py` and `task.py archive` auto-commits and will appear dirty as part of this skill's own work. - -For each remaining dirty path, decide whether it belongs to **the current task** or to **other parallel work** (e.g., another terminal window editing the same repo). Heuristics: - -- Paths referenced in the current task's `prd.md` / `implement.jsonl` / `check.jsonl` → current task -- Paths in code areas matching the task's stated scope, or that you remember editing this session → current task -- Paths in unrelated areas you have no recollection of touching this session → other parallel work - -Then route: - -- **Any remaining path looks like current-task work** — bail out with: - > "Working tree has uncommitted code changes from this task: `<list>`. Return to workflow Phase 3.4 to commit them before running ``finish-work` (Trellis command)`." - - Do NOT run `git commit` here. Do NOT prompt the user to commit. The user goes back to Phase 3.4 and the AI drives the batched commit there. -- **All remaining paths look unrelated** (other parallel-window work) — report them once and continue to Step 3: - > "FYI, dirty files outside this task's scope — leaving them for the other window: `<list>`." -- **Genuinely unsure** — ask the user once: "Are `<list>` this task's work I forgot to commit, or another window's? (commit / ignore)" — then route per their answer. - -## Step 3: Archive task(s) - -```bash -python3 ./.trellis/scripts/task.py archive <task-name> -``` - -At minimum: the current active task (if any). Plus any extra tasks the user confirmed in Step 1. Each archive produces a `chore(task): archive ...` commit via the script's auto-commit. - -If there is no active task and the user did not confirm any cleanup archives, skip this step. - -## Step 4: Record session journal - -```bash -python3 ./.trellis/scripts/add_session.py \ - --title "Session Title" \ - --commit "hash1,hash2" \ - --summary "Brief summary" -``` - -Use the work-commit hashes produced in Phase 3.4 (visible in Step 1's `Recent commits` list, or via `git log --oneline`) for `--commit`. Do not include the archive commit hashes from Step 3. This produces a `chore: record journal` commit. - -Final git log order: `<work commits from 3.4>` → `chore(task): archive ...` (one or more) → `chore: record journal`. diff --git a/.agents/skills/trellis-meta/SKILL.md b/.agents/skills/trellis-meta/SKILL.md deleted file mode 100644 index 91b23b6..0000000 --- a/.agents/skills/trellis-meta/SKILL.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -name: trellis-meta -description: "Understand and customize the local Trellis architecture inside a user project. Use when modifying .trellis plus platform hooks, settings, agents, skills, commands, prompts, workflows, the channel runtime (trellis channel), bundled runtime agents under .trellis/agents/, selectable workflow templates, registry-backed spec refresh, cross-session memory (trellis mem) generated by trellis init, or AI-facing bundled skills (trellis-channel, trellis-session-insight, trellis-spec-bootstrap) and bundled-skill auto-dispatch flow." ---- - -# Trellis Meta - -This skill is for local Trellis users who have already run `trellis init` in a project. After reading it, an AI should understand the Trellis architecture, operating model, and customization entry points inside that user project, then modify the generated `.trellis/` and platform directory files according to the user's request. - -Trellis v0.6 adds three architectural surfaces on top of the pre-v0.6 workflow / persistence / platform model. First, a multi-agent collaboration runtime: `trellis channel` coordinates multiple AI worker processes through project-scoped JSONL event logs at `~/.trellis/channels/<project>/<channel>/events.jsonl`, with worker OOM guard, forum/thread channels, durable idempotency keys, and bundled `.trellis/agents/{check,implement}.md` runtime definitions. Second, cross-session memory: `trellis mem list | search | context | extract | projects` reads raw Claude Code, Codex, and Pi Agent JSONL already on disk, slices by `--phase brainstorm|implement|all`, and never uploads anything. Third, a dual-package npm release: `@mindfoldhq/trellis` (CLI) and `@mindfoldhq/trellis-core` (SDK with `/channel`, `/task`, `/mem`, `/testing` subpaths) ship in lockstep on one version. Treat these as first-class customization surfaces alongside the per-platform integration files. - -The default operating scope is local files in the user project: - -- `.trellis/`: workflow, config, tasks, spec, workspace, scripts, bundled runtime agents, and runtime state. -- Platform directories: `.claude/`, `.codex/`, `.cursor/`, `.opencode/`, `.kiro/`, `.gemini/`, `.qoder/`, `.codebuddy/`, `.github/`, `.factory/`, `.pi/`, `.reasonix/`, `.kilocode/`, `.agent/`, `.devin/`, `.kimi-code/`, and similar directories. Pi additionally exposes a native `trellis_subagent` tool with `single` / `parallel` / `chain` dispatch modes, throttled progress cards, and `isTrellisAgent()` validation on top of the file layout. Reasonix stores both workflow skills and subagent skills as `.reasonix/skills/<name>/SKILL.md`; subagent skills carry `runAs: subagent` frontmatter. Kimi Code keeps workflow skills in the shared `.agents/skills/` layer, delivers commands plus agent prompts as `.kimi-code/skills/<name>/SKILL.md`, and installs the same agent prompts as custom sub-agents under `.kimi-code/agents/<name>.md`. -- Shared skill layer: `.agents/skills/`. -- User-owned channel store outside the project tree: `~/.trellis/channels/<project>/<channel>/events.jsonl`. -- Raw platform conversation logs queryable via `trellis mem`: `~/.claude/projects/`, `~/.codex/sessions/`, and `~/.pi/agent/sessions/` (OpenCode adapter degraded for the v0.6 line). - -Do not assume the user has the Trellis source repository. Do not default to modifying the global npm install directory or `node_modules` — both `@mindfoldhq/trellis` and `@mindfoldhq/trellis-core` ship as published packages sharing one version and one git tag per release. - -## How To Use - -1. Read `references/local-architecture/overview.md` first to establish the local Trellis system model. -2. If the request involves a specific AI tool, read `references/platform-files/platform-map.md` and the relevant platform file notes. -3. If the request involves multi-agent dispatch or channel workers, read `references/local-architecture/multi-agent-channel.md` and the bundled `.trellis/agents/` files. -4. If the user wants to change behavior, read `references/customize-local/overview.md`, then open the specific customization topic. -5. Before editing, read the actual files in the user project and treat local content as authoritative. - -## References - -### Local Architecture - -- `references/local-architecture/overview.md`: The layered local Trellis architecture (workflow / persistence / platform / channel runtime) and customization principles. -- `references/local-architecture/generated-files.md`: Files generated by `trellis init` and their customization boundaries, including `.trellis/agents/`. -- `references/local-architecture/workflow.md`: Phases, routing, workflow-state blocks, and selectable workflow templates (`native`, `tdd`, `channel-driven-subagent-dispatch`, marketplace) in `.trellis/workflow.md`. -- `references/local-architecture/task-system.md`: Task directories, active task, JSONL context, parent/child task trees, and task runtime. -- `references/local-architecture/spec-system.md`: How `.trellis/spec/` is organized, injected, and refreshed from a `registry.spec` source. -- `references/local-architecture/workspace-memory.md`: `.trellis/workspace/` journals plus `trellis mem` cross-session recall and the `@mindfoldhq/trellis-core/mem` SDK. -- `references/local-architecture/context-injection.md`: Hooks, sub-agent preludes, and channel-runtime worker inbox routing. -- `references/local-architecture/multi-agent-channel.md`: `trellis channel` subcommands, project-scoped event store, forum/thread channels, worker OOM guard, durable idempotency, and bundled `.trellis/agents/` runtime agents. -- `references/local-architecture/bundled-skills.md`: Auto-dispatched bundled skills (`trellis-meta`, `trellis-spec-bootstrap`, `trellis-session-insight`) and how `getBundledSkillTemplates()` ships them to every platform skill root. - -### Platform Files - -- `references/platform-files/overview.md`: How shared `.trellis/` files relate to platform directories and the four platform integration modes (hook-driven, agent prelude, main-session workflow, channel runtime). -- `references/platform-files/platform-map.md`: Platform directories and paths for skills, agents, hooks, and extensions across all supported platforms including Reasonix and Pi's native `trellis_subagent` extension. -- `references/platform-files/hooks-and-settings.md`: How settings/config files, hooks, plugins, and extensions connect to Trellis; covers `channel.worker_guard.*` and `codex.dispatch_mode`. -- `references/platform-files/agents.md`: Per-platform `trellis-research` / `trellis-implement` / `trellis-check` sub-agent files plus bundled `.trellis/agents/{check,implement}.md` for the channel runtime. -- `references/platform-files/skills-and-commands.md`: Differences between skills, commands, prompts, and workflows, plus how to change them. - -### Local Customization - -- `references/customize-local/overview.md`: Choose the right local customization entry point for the user's request. -- `references/customize-local/change-workflow.md`: Change phases, routing, next actions, workflow-state, and the selected workflow template. -- `references/customize-local/change-task-lifecycle.md`: Change task creation, status, archive behavior, parent/child links, archive slug collision handling, and lifecycle hooks. -- `references/customize-local/change-context-loading.md`: Change how tasks, specs, journals, hook context, channel inbox messages, and `trellis mem` recall are loaded. -- `references/customize-local/change-hooks.md`: Change platform hooks, settings, task lifecycle hooks (`hooks.after_*`), and shell session bridges. -- `references/customize-local/change-agents.md`: Change research, implement, and check agent behavior across platform sub-agents, bundled channel runtime agents, and the Codex `dispatch_mode` toggle. -- `references/customize-local/change-skills-or-commands.md`: Add or modify local skills, commands, prompts, and workflows; covers upstream bundled-skill auto-dispatch. -- `references/customize-local/change-spec-structure.md`: Adjust the project spec structure under `.trellis/spec/`, including registry-backed sources. -- `references/customize-local/add-project-local-conventions.md`: Put team rules into project-local specs or local skills. - -## Current Rules - -- `.trellis/workflow.md` is the local workflow source of truth; its initial content was selected from a workflow template (built-in `native`, `tdd`, `channel-driven-subagent-dispatch`, or a marketplace template) at `trellis init` time and can be re-selected via `trellis workflow --template <id>`. Missing `.trellis/agents/<name>.md` files referenced by the active template trigger a non-blocking stderr warning pointing at `trellis update`. -- `.trellis/config.yaml` is the project-level Trellis configuration entry point. It hosts task lifecycle hooks (`hooks.after_create` / `after_start` / `after_finish` / `after_archive`), journal shape (`session_commit_message` / `max_journal_lines` / `session_auto_commit`), channel worker guard (`channel.worker_guard.idle_timeout` / `max_live_workers`), Codex dispatch mode (`codex.dispatch_mode: inline | sub-agent`), and the spec registry block (`registry.spec.source` + `registry.spec.template`). -- `.trellis/spec/` stores the user's project-specific coding conventions and design constraints. When `registry.spec` is set, files are refreshed by `trellis update`; local edits surface as "modified by user" conflicts in `.trellis/.template-hashes.json`. -- `.trellis/tasks/` stores task PRDs, design notes, implement plans, research files, and JSONL context. Tasks form parent/child trees: `task.py create --parent <slug>`, `task.py add-subtask <parent> <child>`, `task.py remove-subtask <parent> <child>`, and `task.py list-context <task>`. `task.py create` rejects a slug already present in `.trellis/tasks/archive/**`. -- `.trellis/workspace/` stores **deliberately written** developer journals. Raw cross-session dialogue is **not** stored here — it lives on disk under `~/.claude/projects/`, `~/.codex/sessions/`, and `~/.pi/agent/sessions/` and is recovered via `trellis mem search|extract|context`. The bundled `trellis-session-insight` skill teaches when to reach for `mem`. -- `.trellis/agents/{check,implement}.md` are bundled, platform-agnostic channel runtime agent definitions loaded by `trellis channel spawn --agent <name>`. Editable; `trellis update` backfills missing ones. Editing the per-platform `trellis-implement.md` / `trellis-check.md` does **not** change channel-runtime worker behavior. -- `~/.trellis/channels/<project>/<channel>/events.jsonl` is the channel runtime event log per project per channel. User-owned, file-locked sequence numbering, durable `idempotencyKey` support; never under `.trellis/`. -- Bundled multi-file skills (`trellis-meta`, `trellis-spec-bootstrap`, `trellis-session-insight`, `trellis-channel`) are auto-dispatched to every platform skill root by `getBundledSkillTemplates()` in `packages/cli/src/templates/common/index.ts`. Dropping a new directory under `packages/cli/src/templates/common/bundled-skills/` (upstream) ships it to every platform on the next `trellis update`. -- Platform settings/config files decide which hooks, agents, skills, commands, prompts, and workflows actually run. Reasonix has no settings file — behavior is encoded inside skill frontmatter. -- `.trellis/.template-hashes.json` and `.trellis/.runtime/` are management/runtime state files. Confirm necessity before editing them. - -## Do Not - -- Do not treat Trellis upstream source code as the default target for local customization. -- Do not modify the global npm install directory or `node_modules/@mindfoldhq/trellis` or `node_modules/@mindfoldhq/trellis-core` to implement project needs; both packages ship in lockstep. -- Do not overwrite user-modified local files with default templates; check `.trellis/.template-hashes.json` first and prefer `.new` sidecar files over destructive overwrites. -- Do not put team-private project rules into any public bundled skill (`trellis-meta`, `trellis-spec-bootstrap`, `trellis-session-insight`, `trellis-channel`); put project rules in `.trellis/spec/`, a project-local skill, the current task, or the workspace journal — `trellis update` will overwrite anything inside a bundled skill directory. -- Do not hand-edit `~/.trellis/channels/<project>/<channel>/events.jsonl`; sequence numbers are assigned under a file lock and replay-safe writes go through the `trellis channel` CLI or the `@mindfoldhq/trellis-core/channel` SDK. -- Do not edit `.claude/agents/trellis-implement.md` (or any other per-platform sub-agent file) when the goal is to change channel runtime worker behavior — edit `.trellis/agents/<name>.md` instead. -- Do not describe removed or never-shipped mechanisms as current Trellis behavior; cross-check against the local `.trellis/config.yaml` and the installed CLI's `trellis --help` before claiming a knob exists. diff --git a/.agents/skills/trellis-meta/references/customize-local/add-project-local-conventions.md b/.agents/skills/trellis-meta/references/customize-local/add-project-local-conventions.md deleted file mode 100644 index 608aaa6..0000000 --- a/.agents/skills/trellis-meta/references/customize-local/add-project-local-conventions.md +++ /dev/null @@ -1,83 +0,0 @@ -# Add Project-Local Conventions - -Often the user does not need to change Trellis mechanics; they need local AI to understand their team's conventions. In that case, prefer `.trellis/spec/` or a project-local skill instead of editing `trellis-meta`. - -## Where To Put Things - -| Content type | Location | -| --- | --- | -| Rules code must follow | `.trellis/spec/<layer>/` | -| Cross-layer thinking methods | `.trellis/spec/guides/` | -| AI capability for a project-specific flow | Platform-local skill | -| One-off task material | `.trellis/tasks/<task>/` | -| Session summary | `.trellis/workspace/<developer>/journal-N.md` | - -## Create A Project-Local Skill - -If the user wants AI to know "how this project customizes Trellis," create a local skill: - -```text -.claude/skills/trellis-local/ -└── SKILL.md -``` - -Example: - -```md ---- -name: trellis-local -description: "Project-local Trellis customizations for this repository. Use when changing this project's Trellis workflow, hooks, local agents, or team-specific conventions." ---- - -# Trellis Local - -## Local Scope - -This skill documents this repository's Trellis customizations only. - -## Custom Workflow Rules - -- ... - -## Local Hook Changes - -- ... - -## Local Agent Changes - -- ... -``` - -For multi-platform projects, place equivalent versions in other platform skill directories, or use `.agents/skills/` for platforms that support the shared layer. - -## Write To `.trellis/spec/` - -If the content is a coding convention, write it to spec. Examples: - -```text -.trellis/spec/backend/error-handling.md -.trellis/spec/frontend/components.md -.trellis/spec/guides/cross-platform-thinking-guide.md -``` - -After writing it, update the corresponding `index.md` so AI can find the new rule from the entry point. - -## Make The Current Task Use New Conventions - -After writing a spec, add it to the current task context: - -```bash -python3 ./.trellis/scripts/task.py add-context <task> implement ".trellis/spec/backend/error-handling.md" "Error handling conventions" -python3 ./.trellis/scripts/task.py add-context <task> check ".trellis/spec/backend/error-handling.md" "Review error handling" -``` - -## Do Not Store Project-Private Rules In `trellis-meta` - -`trellis-meta` is a public skill for understanding Trellis architecture and local customization entry points. Put project-private content in: - -- `.trellis/spec/` -- a project-local skill -- the current task -- workspace journal - -This prevents future updates to Trellis's built-in `trellis-meta` from overwriting the team's own conventions. diff --git a/.agents/skills/trellis-meta/references/customize-local/change-agents.md b/.agents/skills/trellis-meta/references/customize-local/change-agents.md deleted file mode 100644 index 860c34c..0000000 --- a/.agents/skills/trellis-meta/references/customize-local/change-agents.md +++ /dev/null @@ -1,56 +0,0 @@ -# Change Local Agents - -When the user wants to change `trellis-research`, `trellis-implement`, or `trellis-check` behavior, edit platform agent files in the user project. - -## Read These Files First - -1. Target platform agent directory -2. `.trellis/workflow.md` Phase 2 / research routing -3. Current task `prd.md` -4. Current task `implement.jsonl` / `check.jsonl` -5. Relevant hook or agent prelude - -## Common Paths - -| Platform | Path | -| --- | --- | -| Claude Code | `.claude/agents/trellis-*.md` | -| Cursor | `.cursor/agents/trellis-*.md` | -| OpenCode | `.opencode/agents/trellis-*.md` | -| Codex | `.codex/agents/trellis-*.toml` | -| Kiro | `.kiro/agents/trellis-*.json` | -| Gemini CLI | `.gemini/agents/trellis-*.md` | -| Qoder | `.qoder/agents/trellis-*.md` | -| CodeBuddy | `.codebuddy/agents/trellis-*.md` | -| Factory Droid | `.factory/droids/trellis-*.md` | -| Pi Agent | `.pi/agents/trellis-*.md` | -| Reasonix | `.reasonix/skills/trellis-*/SKILL.md` (subagent frontmatter) | -| ZCode | `.zcode/agents/trellis-*.md` | - -Use the actual paths in the user project as authoritative. - -## Common Needs - -| Need | Which agent to edit | -| --- | --- | -| Research must write files, not only reply in chat | `trellis-research` | -| Certain local specs must be read before implementation | `trellis-implement` + `implement.jsonl` configuration rules | -| Specific commands must run during checking | `trellis-check` | -| Agent must not modify certain directories | The corresponding agent's write boundary instructions | -| Agent output format must be fixed | The corresponding agent's final/reporting instructions | - -## Modification Principles - -1. **Preserve role boundaries**: research investigates and persists; implement writes implementation; check reviews and fixes. -2. **Do not hard-code project specs into agents**: long-term specs belong in `.trellis/spec/`; agents are responsible for reading them. -3. **Make read order explicit**: active task -> PRD -> info -> JSONL -> spec/research. -4. **Make write boundaries explicit**: which directories may be written and which may not. -5. **Synchronize across platforms**: when the user configured multiple platforms, decide whether to change only the current platform or all platform agents. - -## Agent Pull Platforms - -If an agent file contains a prelude for "read task/context after startup," do not remove those steps when editing. Otherwise the agent will work only from chat context and bypass Trellis's core mechanism. - -## Hook Push Platforms - -If context is injected by a hook, the agent file should still retain responsibility boundaries. Do not remove PRD/spec requirements from the agent just because a hook injects context. diff --git a/.agents/skills/trellis-meta/references/customize-local/change-context-loading.md b/.agents/skills/trellis-meta/references/customize-local/change-context-loading.md deleted file mode 100644 index 002a259..0000000 --- a/.agents/skills/trellis-meta/references/customize-local/change-context-loading.md +++ /dev/null @@ -1,84 +0,0 @@ -# Change Local Context Loading - -Context loading determines when AI reads workflow, task, spec, research, workspace, and git status. Read this page when the user says "AI does not know the current task," "the agent did not read specs," or "there is too much/too little context." - -## Read These Files First - -1. `.trellis/workflow.md` -2. `.trellis/scripts/get_context.py` -3. `.trellis/scripts/common/session_context.py` -4. `.trellis/scripts/common/task_context.py` -5. `.trellis/scripts/common/active_task.py` -6. Current platform hooks or agent files -7. The current task's `implement.jsonl` / `check.jsonl` - -## Context Sources - -| Source | Purpose | -| --- | --- | -| `.trellis/workflow.md` | Workflow and next-action hints. | -| `.trellis/tasks/<task>/prd.md` | Current task requirements. | -| `.trellis/tasks/<task>/design.md` | Complex task technical design. | -| `.trellis/tasks/<task>/implement.md` | Complex task execution plan. | -| `.trellis/tasks/<task>/implement.jsonl` | Spec/research to read before implementation. | -| `.trellis/tasks/<task>/check.jsonl` | Spec/research to read during checking. | -| `.trellis/spec/` | Project specs. | -| `.trellis/workspace/` | Session records. | -| git status | Current working tree changes. | - -## Common Needs And Edit Points - -| Need | Edit point | -| --- | --- | -| Inject more/less information in new sessions | `session_context.py` or the platform `session-start` hook. | -| Change hints on each user input | `[workflow-state:STATUS]` block in `.trellis/workflow.md`. The `inject-workflow-state` hook is parser-only and reads the block verbatim. | -| Agent did not read specs | Task JSONL, agent prelude, `inject-subagent-context` hook. | -| Active task is lost | `active_task.py` and platform session identity propagation. | -| Change JSONL validation rules | `task_context.py`. | - -## JSONL Rules - -`implement.jsonl` / `check.jsonl` are the key context loading interface: - -```jsonl -{"file": ".trellis/spec/backend/index.md", "reason": "Backend conventions"} -{"file": ".trellis/tasks/04-28-x/research/api.md", "reason": "API research"} -``` - -Include only spec/research files. Do not put code files that will be modified into these manifests; agents read code files themselves during implementation. - -## Change Session Context - -If the user wants every new session to see more project state, edit: - -- `.trellis/scripts/common/session_context.py` -- the corresponding platform `session-start` hook - -Context cannot grow without bound. Prefer injecting indexes and paths so the AI can read detailed files on demand. - -## Change Sub-Agent Context - -First determine which mode the platform uses: - -- hook push: edit the `inject-subagent-context` hook. -- agent pull: edit the read steps in the corresponding `trellis-implement` / `trellis-check` agent file. - -In both modes, make sure the agent ultimately reads: - -1. active task -2. the corresponding JSONL -3. spec/research referenced by the JSONL -4. `prd.md` -5. `design.md` if present -6. `implement.md` if present - -## Troubleshooting Order - -```bash -python3 ./.trellis/scripts/task.py current --source -python3 ./.trellis/scripts/task.py list-context <task> -python3 ./.trellis/scripts/task.py validate <task> -python3 ./.trellis/scripts/get_context.py --mode packages -``` - -Confirm the task and JSONL are correct before editing hooks/agents. diff --git a/.agents/skills/trellis-meta/references/customize-local/change-hooks.md b/.agents/skills/trellis-meta/references/customize-local/change-hooks.md deleted file mode 100644 index 79aa5c5..0000000 --- a/.agents/skills/trellis-meta/references/customize-local/change-hooks.md +++ /dev/null @@ -1,57 +0,0 @@ -# Change Local Hooks - -Hooks are the automation layer that connects a platform to Trellis. When the user wants to change "when context is injected," "how shell commands inherit a session," or "which files are read before an agent starts," hooks are usually the edit point. - -## Read These Files First - -1. Target platform settings/config, such as `.claude/settings.json`, `.codex/hooks.json`, `.cursor/hooks.json`, `.trae/hooks.json` -2. Target platform hooks directory -3. `.trellis/scripts/common/active_task.py` -4. `.trellis/scripts/common/session_context.py` -5. `.trellis/workflow.md` - -## Common Hook Types - -| Hook | Purpose | -| --- | --- | -| session-start | Injects a Trellis overview when a session starts, clears, or compacts. | -| workflow-state | Injects a state hint on each user input. | -| sub-agent context | Injects PRD/spec/research before an agent starts. | -| shell session bridge | Lets `task.py` commands in shell see the same session identity. | - -## Modification Steps - -1. Find the hook registration in settings/config. -2. Confirm the registered script path exists. -3. Read the hook script and identify inputs, outputs, and called `.trellis/scripts/`. -4. Modify hook behavior. -5. If the hook depends on workflow content, synchronize `.trellis/workflow.md`. - -## Example: Change New-Session Injection Content - -First find the session-start hook: - -```text -.claude/settings.json -.claude/hooks/session-start.py -``` - -If the hook ultimately calls `.trellis/scripts/get_context.py` or `session_context.py`, editing the local script is usually more robust than hard-coding content in the hook. - -## Example: Agent Did Not Read JSONL - -First confirm: - -```bash -python3 ./.trellis/scripts/task.py current --source -python3 ./.trellis/scripts/task.py validate <task> -``` - -If the task and JSONL are correct, determine whether the platform uses hook push or agent pull. For hook push, edit `inject-subagent-context`; for agent pull, edit the agent file. - -## Notes - -- Settings handle registration, hook scripts handle behavior; inspect both together. -- Different platforms support different hook events. Do not directly copy another platform's settings. -- Hooks should read project-local `.trellis/`; they should not depend on Trellis upstream source paths. -- Hook failures should produce visible errors so AI does not silently lose context. diff --git a/.agents/skills/trellis-meta/references/customize-local/change-skills-or-commands.md b/.agents/skills/trellis-meta/references/customize-local/change-skills-or-commands.md deleted file mode 100644 index 9e25eff..0000000 --- a/.agents/skills/trellis-meta/references/customize-local/change-skills-or-commands.md +++ /dev/null @@ -1,123 +0,0 @@ -# Change Local Skills, Commands, Prompts, And Workflows - -When the user wants to change AI entry points, auto-trigger rules, or explicit command behavior, edit skills, commands, prompts, or workflows in local platform directories. - -Before editing, classify the skill you are about to touch: - -- **Bundled upstream skill** — `trellis-meta`, `trellis-spec-bootstrap`, `trellis-session-insight`, `trellis-channel`. Source of truth lives in the Trellis CLI repo under `packages/cli/src/templates/common/bundled-skills/<name>/`; auto-dispatched to every platform's skill root by `getBundledSkillTemplates()` on `trellis init` / `trellis update`. Local edits here are tracked by `.trellis/.template-hashes.json` and will be flagged on the next update. -- **Project-local skill** — anything else under `.{platform}/skills/`. Owned by the user; not refreshed by `trellis update`. - -The remainder of this file uses "skill" for the local file; the override and conflict rules differ between the two cases. - -## Read These Files First - -1. `.trellis/workflow.md` -2. Target platform skill/command/prompt/workflow directory -3. Related agent or hook files -4. Whether project rules already exist in `.trellis/spec/` -5. `.trellis/.template-hashes.json` — confirms whether the skill you are about to edit is upstream-owned (entry present) or project-local (entry absent) - -## Which Entry Type To Choose - -| Goal | Recommendation | -| --- | --- | -| AI should automatically know a capability | Add or modify a skill. | -| User wants to trigger manually with a command | Add or modify a command/prompt/workflow. | -| Team project conventions | Prefer `.trellis/spec/` or a project-local skill — never a bundled skill directory. | -| Tweak a bundled skill (`trellis-meta` et al.) for the user's own project | Create a project-local sibling skill (different name) that overrides intent, or edit `.trellis/spec/`. Edits inside the bundled skill directory survive only until the next `trellis update` and will need a "keep" choice each time. | -| Contribute the change back upstream | Edit `packages/cli/src/templates/common/bundled-skills/<name>/` in the Trellis CLI repo, not the deployed copy. | -| Change Trellis flow semantics | Synchronize `.trellis/workflow.md`. | - -## Modify A Skill - -A skill is usually: - -```text -<skill-name>/ -├── SKILL.md -└── references/ -``` - -`SKILL.md` should be short and responsible for triggering/routing. Put long content in `references/` so AI can read it on demand. - -The frontmatter description should specify when to use the skill. Example: - -```yaml -description: "Use when customizing this project's deployment workflow and release checklist." -``` - -Do not write vague descriptions such as "helpful project skill"; they can trigger incorrectly. - -### Bundled vs. Project-Local - -The same directory shape is used by two very different ownership models: - -| Aspect | Bundled (`trellis-meta`, `trellis-spec-bootstrap`, `trellis-session-insight`, `trellis-channel`) | Project-local | -| --- | --- | --- | -| Source of truth | `packages/cli/src/templates/common/bundled-skills/<name>/` in Trellis CLI repo | Inside the user project itself | -| Dispatch | Auto-dispatched to every platform skill root by `getBundledSkillTemplates()` (`packages/cli/src/templates/common/index.ts`) on `trellis init` / `trellis update` | Created by the user (or another skill) and never moved | -| Hash tracking | Every file recorded in `.trellis/.template-hashes.json`; conflict prompt on update | Not tracked | -| Editing locally | Allowed but will be marked "modified by user" on next update | Free editing | -| The right way to customize | Add a *new* project-local skill with a *different* name that supplements (or supersedes) the bundled one | Edit the file directly | - -If the goal is "make my project's AI behave differently when discussing release notes," the answer is almost always a project-local skill, not surgery on `trellis-meta/`. - -## Modify A Command/Prompt/Workflow - -Explicit entry points should state: - -- How the user triggers it. -- Which `.trellis/` files to read. -- Which scripts to run. -- How to report after completion. - -If a command only repeats workflow rules, prefer making it reference/read `.trellis/workflow.md` instead of maintaining a second copy of the flow. - -## Common Paths - -| Platform | Entry directories | -| --- | --- | -| Claude Code | `.claude/skills/`, `.claude/commands/` | -| Cursor | `.cursor/skills/`, `.cursor/commands/` | -| OpenCode | `.opencode/skills/`, `.opencode/commands/` | -| Codex | `.agents/skills/`, `.codex/skills/` | -| Gemini CLI | `.agents/skills/`, `.gemini/commands/` | -| Kiro | `.kiro/skills/` | -| Qoder | `.qoder/skills/`, `.qoder/commands/` | -| CodeBuddy | `.codebuddy/skills/`, `.codebuddy/commands/` | -| GitHub Copilot | `.github/skills/`, `.github/prompts/` | -| Factory Droid | `.factory/skills/`, `.factory/commands/` | -| Pi Agent | `.agents/skills/` | -| Reasonix | `.reasonix/skills/` (no separate commands dir; slash commands built into the platform) | -| ZCode | `.zcode/skills/`, `.zcode/commands/` | -| Kilo / Antigravity / Devin | workflows + skills | - -Every directory above is a deploy target for the four bundled skills. Each platform receives a full copy on `trellis init` and refresh on `trellis update`; nothing has to be wired by hand. - -## Add A Project-Local Skill - -If the user wants to document team-private customizations, create a project-local skill — never put project-private content into a bundled skill directory, since `trellis update` will overwrite it. - -```text -.claude/skills/project-trellis-local/ -└── SKILL.md -``` - -For multi-platform projects, add equivalent versions in each platform skill directory, or use `.agents/skills/` on platforms that support the shared layer (Codex, Gemini CLI). - -Pick a name that does **not** collide with the bundled set: - -- `trellis-meta` -- `trellis-spec-bootstrap` -- `trellis-session-insight` -- `trellis-channel` - -A reused name causes `getBundledSkillTemplates()` to overwrite the project-local copy on the next update. A common convention is to prefix the project name: `acme-trellis-deploy`, `acme-trellis-onboarding`. - -## Notes - -- Do not mix every platform's syntax into one file. -- Do not change only one platform entry point while claiming all platforms are supported. -- Do not hide long-term engineering conventions inside a command; write them to `.trellis/spec/`. -- Do not hand-edit files inside `trellis-meta/`, `trellis-spec-bootstrap/`, `trellis-session-insight/`, or `trellis-channel/` under any `.{platform}/skills/` directory expecting the change to persist — they are bundled and refreshed by `trellis update`. Either contribute upstream or add a project-local skill that complements them. -- After `trellis update` reports a "modified by you" conflict on a bundled skill file, choose **keep** only if you accept maintaining the divergence by hand; otherwise accept the overwrite and re-apply the intent as a project-local skill. diff --git a/.agents/skills/trellis-meta/references/customize-local/change-spec-structure.md b/.agents/skills/trellis-meta/references/customize-local/change-spec-structure.md deleted file mode 100644 index ee9a176..0000000 --- a/.agents/skills/trellis-meta/references/customize-local/change-spec-structure.md +++ /dev/null @@ -1,83 +0,0 @@ -# Change Local Spec Structure - -When the user wants to change the engineering conventions AI follows, add new spec layers, or adjust monorepo package mapping, edit `.trellis/spec/` and `.trellis/config.yaml`. - -## Read These Files First - -1. `.trellis/config.yaml` -2. `.trellis/spec/` -3. `.trellis/workflow.md` planning artifact guidance and Phase 3.3 -4. Current task `implement.jsonl` / `check.jsonl` - -## Common Needs - -| Need | Edit location | -| --- | --- | -| Add backend/frontend/docs/test spec layer | `.trellis/spec/<layer>/` or `.trellis/spec/<package>/<layer>/` | -| Add shared thinking guides | `.trellis/spec/guides/` | -| Adjust monorepo packages | `packages` in `.trellis/config.yaml` | -| Change default package | `default_package` in `.trellis/config.yaml` | -| Control spec scanning scope | `spec_scope` in `.trellis/config.yaml` | -| Make a task read a new spec | Task `implement.jsonl` / `check.jsonl` | - -## Add A Spec Layer - -Single-repository example: - -```text -.trellis/spec/security/ -├── index.md -└── auth.md -``` - -Monorepo example: - -```text -.trellis/spec/webapp/security/ -├── index.md -└── auth.md -``` - -`index.md` should include: - -- What code this layer applies to. -- Pre-Development Checklist. -- Quality Check. -- Links to specific guideline files. - -## Update Context - -Adding a spec does not mean every task automatically reads it. The current task must reference it in JSONL: - -```bash -python3 ./.trellis/scripts/task.py add-context <task> implement ".trellis/spec/webapp/security/index.md" "Security conventions" -python3 ./.trellis/scripts/task.py add-context <task> check ".trellis/spec/webapp/security/index.md" "Security review rules" -``` - -## Change Monorepo Packages - -Example `.trellis/config.yaml`: - -```yaml -packages: - webapp: - path: apps/web - api: - path: apps/api -default_package: webapp -``` - -After editing, run: - -```bash -python3 ./.trellis/scripts/get_context.py --mode packages -``` - -Use this output to confirm AI can see the correct packages and spec layers. - -## Notes - -- Specs are user project conventions and can be changed according to project needs. -- Do not put temporary task information into specs; put temporary information in the task. -- Do not put long-term conventions only in agents or commands; preserve them in specs. -- After changing spec structure, check whether existing task JSONL files still point to files that exist. diff --git a/.agents/skills/trellis-meta/references/customize-local/change-task-lifecycle.md b/.agents/skills/trellis-meta/references/customize-local/change-task-lifecycle.md deleted file mode 100644 index a7a340f..0000000 --- a/.agents/skills/trellis-meta/references/customize-local/change-task-lifecycle.md +++ /dev/null @@ -1,90 +0,0 @@ -# Change Local Task Lifecycle - -Task lifecycle includes creation, start, context configuration, finish, archive, parent/child tasks, and lifecycle hooks. The default customization targets are `.trellis/tasks/`, `.trellis/config.yaml`, and `.trellis/scripts/`. - -## Read These Files First - -1. `.trellis/workflow.md` -2. `.trellis/config.yaml` -3. `.trellis/scripts/task.py` -4. `.trellis/scripts/common/task_store.py` -5. `.trellis/scripts/common/task_utils.py` -6. The current task's `.trellis/tasks/<task>/task.json` - -## Common Needs And Edit Points - -| Need | Edit point | -| --- | --- | -| Automatically sync an external system after task creation | `hooks.after_create` in `.trellis/config.yaml`. | -| Automatically update status after task start | `hooks.after_start` in `.trellis/config.yaml`. | -| Run a script after task finish | `hooks.after_finish` in `.trellis/config.yaml`. | -| Clean external resources after archive | `hooks.after_archive` in `.trellis/config.yaml`. | -| Change default task fields | `.trellis/scripts/common/task_store.py`. | -| Change task parsing/search | `.trellis/scripts/common/task_utils.py`. | -| Change active task behavior | `.trellis/scripts/common/active_task.py`. | - -## lifecycle hooks - -`.trellis/config.yaml` supports: - -```yaml -hooks: - after_create: - - "python3 .trellis/scripts/hooks/my_sync.py create" - after_start: - - "python3 .trellis/scripts/hooks/my_sync.py start" - after_finish: - - "python3 .trellis/scripts/hooks/my_sync.py finish" - after_archive: - - "python3 .trellis/scripts/hooks/my_sync.py archive" -``` - -Hook commands receive the `TASK_JSON_PATH` environment variable, pointing to the current task's `task.json`. Hook failures should usually warn, but not block the main task operation. - -## Change Task Fields - -If the user wants to add project-local fields, prefer putting them under `meta` in `task.json` to avoid breaking existing scripts' assumptions about standard fields. - -Example: - -```json -"meta": { - "linearIssue": "ENG-123", - "risk": "high" -} -``` - -If standard fields really need to change, inspect every local script that reads `task.json`. - -## Change Active Task - -Active task is session-level state stored in `.trellis/.runtime/sessions/`. Do not fall back to a global `.current-task` model. If the user wants to change active task behavior, edit: - -- `.trellis/scripts/common/active_task.py` -- platform hooks or shell session bridges -- active task descriptions in `.trellis/workflow.md` - -### `task.py create` Sets the Active Pointer - -`cmd_create` in `.trellis/scripts/common/task_store.py` calls `set_active_task` best-effort right after writing the new task directory. The behavior: - -- When the calling shell carries session identity (`TRELLIS_CONTEXT_ID` env var, or any platform-specific session env that `resolve_context_key` recognizes — see `active_task.py:_ENV_SESSION_KEYS`), the per-session pointer at `.trellis/.runtime/sessions/<context_key>.json` is rewritten to point at the new task. The task's `status=planning` and `[workflow-state:planning]` fires on the very next `UserPromptSubmit`. -- When session identity is unavailable (raw CLI invocation outside an AI session, or a platform that doesn't propagate identity to shell), the task directory is still created and `status=planning` is still written, but the active pointer is left untouched. The user can attach the task later with `task.py start <dir>` once they're back in an AI session. - -This makes `[workflow-state:planning]` the live breadcrumb during the brainstorm and JSONL curation work that follows `task.py create`. The pre-R7 behavior left the breadcrumb stuck on `no_task` until `task.py start`, so the planning block was effectively dead text. - -If you fork `task.py` to add a new creation path (e.g. an external import that bypasses `cmd_create`), audit whether your path also calls `set_active_task`. Without that call, your created tasks will not surface as active. The full status writer table is in `.trellis/spec/cli/backend/workflow-state-contract.md`. - -## Modification Steps - -1. Confirm the current task with `python3 ./.trellis/scripts/task.py current --source`. -2. Read the current task's `task.json` and confirm status and fields. -3. For configuration needs, edit `.trellis/config.yaml` first. -4. For script behavior needs, then edit `.trellis/scripts/`. -5. If the AI flow changed, synchronize `.trellis/workflow.md`. - -## Do Not - -- Do not directly edit `.trellis/.runtime/sessions/` to "fix" business state. -- Do not hard-code project-private fields into scripts; prefer `meta`. -- Do not default to asking the user to fork Trellis CLI. diff --git a/.agents/skills/trellis-meta/references/customize-local/change-workflow.md b/.agents/skills/trellis-meta/references/customize-local/change-workflow.md deleted file mode 100644 index 337c985..0000000 --- a/.agents/skills/trellis-meta/references/customize-local/change-workflow.md +++ /dev/null @@ -1,65 +0,0 @@ -# Change Local Workflow - -When the user wants to change Trellis phases, next-action hints, whether to create tasks, whether to use sub-agents, or when to check/wrap up, edit `.trellis/workflow.md` first. - -## Read These Files First - -1. `.trellis/workflow.md` -2. Entry files for the current platform, such as skills/commands/prompts/workflows -3. The current task's `task.json` and `prd.md` - -## Common Needs And Edit Points - -| Need | Edit point | -| --- | --- | -| Change phase names or phase order | `Phase Index` and the corresponding Phase sections. | -| Change whether to create a task when there is no task | `[workflow-state:no_task]` state block. | -| Change the next step during planning | Phase 1 and `[workflow-state:planning]`. | -| Change whether an agent is required during in_progress | Phase 2 and `[workflow-state:in_progress]`. | -| Change wrap-up after completion | Phase 3 and `[workflow-state:completed]`. | -| Change which skill a user intent triggers | `Skill Routing` table. | - -## Modification Steps - -1. Find the relevant section in `.trellis/workflow.md`. -2. When changing rules, keep explicit trigger conditions and next actions. -3. If adding or renaming a skill/agent, synchronize the corresponding files in platform directories. -4. Workflow-state changes only need an edit to the `[workflow-state:STATUS]` block in `.trellis/workflow.md`. The hook is parser-only — it reads whatever you put in the block. Keep the opening and closing tags' STATUS strings identical (`[workflow-state:foo]…[/workflow-state:foo]`); mismatched STATUS pairs are silently dropped. -5. Make the AI reread `.trellis/workflow.md`; do not keep using rules from the old conversation. - -## Example: Relax Task Creation Requirements - -To change when task creation can be skipped, usually edit `[workflow-state:no_task]`: - -```md -[workflow-state:no_task] -Task is not required when the answer is a one-reply explanation, no files are changed, and no research is needed. -[/workflow-state:no_task] -``` - -If the formal Phase 1 flow also needs to change, synchronize the Phase 1 section. - -## Example: One Platform Does Not Use Sub-Agents - -If the user wants only one platform to avoid sub-agents, first confirm whether that platform has a separate group in the workflow. Then change Phase 2 routing for that platform group instead of deleting all `trellis-implement` / `trellis-check` instructions across platforms. - -## `/trellis:continue` Route Table - -`/trellis:continue` resumes a task by deciding which phase step to load next. The decision combines `task.json.status` with the presence of artifacts inside the task directory. The mapping is fixed in the command itself; forks that add custom statuses must extend both the workflow.md tag block and this table. - -| `status` | Artifact state | Resume at | -| --- | --- | --- | -| `planning` | `prd.md` missing | Phase 1.1 (load `trellis-brainstorm`) | -| `planning` | lightweight task with `prd.md` complete | ask for start review, then run `task.py start` | -| `planning` | complex task missing `design.md` or `implement.md` | complete missing planning artifacts | -| `planning` | complex task has `prd.md`, `design.md`, and `implement.md` | ask for start review, then run `task.py start` | -| `in_progress` | no implementation in conversation history | Phase 2.1 (`trellis-implement`) | -| `in_progress` | implementation done, no `trellis-check` run | Phase 2.2 (`trellis-check`) | -| `in_progress` | check passed | Phase 3.3 (spec update) → 3.4 (commit) | -| `completed` | task is still in active tree | Phase 3.5 (run `/trellis:finish-work` to archive) | - -When you add a custom status (e.g. `in-review`), add a `[workflow-state:in-review]` block in `.trellis/workflow.md` for the per-turn breadcrumb AND extend this route table — usually by editing the `/trellis:continue` command file (`.{platform}/commands/trellis/continue.md` or equivalent) to add a row that decides where to resume from. Without the route entry, `/trellis:continue` will fall through to a default branch and the user will not land on the step you intended. - -## Notes - -`.trellis/workflow.md` is the local project workflow, not an immutable template. The user can adapt it to team habits. After editing it, platform entry files may still contain old descriptions, so inspect them too. diff --git a/.agents/skills/trellis-meta/references/customize-local/overview.md b/.agents/skills/trellis-meta/references/customize-local/overview.md deleted file mode 100644 index b75d208..0000000 --- a/.agents/skills/trellis-meta/references/customize-local/overview.md +++ /dev/null @@ -1,55 +0,0 @@ -# Local Customization Overview - -This directory is for local AI working in a user project where Trellis was installed through npm and `trellis init` has already been run. The AI should modify generated `.trellis/` and platform directories inside the project, not Trellis CLI upstream source code. - -## First Determine What The User Actually Wants To Change - -| User wording | Read first | -| --- | --- | -| "Change the Trellis flow / phases / next prompt" | `change-workflow.md` | -| "Change task creation, status, archive, or hooks" | `change-task-lifecycle.md` | -| "AI did not read context / change injected content" | `change-context-loading.md` | -| "A platform hook is not behaving as expected" | `change-hooks.md` | -| "Change implement/check/research agent behavior" | `change-agents.md` | -| "Add a skill/command/workflow/prompt" | `change-skills-or-commands.md` | -| "Adjust the project spec structure" | `change-spec-structure.md` | -| "Add team conventions and local notes" | `add-project-local-conventions.md` | - -## General Operation Order - -1. **Confirm platform and directories**: inspect which directories exist, such as `.claude/`, `.codex/`, `.cursor/`, `.zcode/`. -2. **Confirm the current active task**: run `python3 ./.trellis/scripts/task.py current --source`. -3. **Read the local source of truth**: prefer `.trellis/workflow.md`, `.trellis/config.yaml`, and relevant platform files. -4. **Modify narrowly**: edit only files related to the user's request. -5. **Synchronize semantics**: if a shared flow changes, check whether platform entry points also need changes; if a platform entry changes, check whether `.trellis/workflow.md` still agrees. - -## Local File Priority - -| Layer | Files | -| --- | --- | -| Workflow | `.trellis/workflow.md` | -| Project configuration | `.trellis/config.yaml` | -| Task material | `.trellis/tasks/<task>/` | -| Project specs | `.trellis/spec/` | -| Runtime scripts | `.trellis/scripts/` | -| Platform integration | `.claude/`, `.codex/`, `.cursor/`, `.opencode/`, `.zcode/`, and similar directories | -| Shared skill | `.agents/skills/` | - -## Things Not To Do By Default - -- Do not edit the global npm install directory. -- Do not edit `node_modules/@mindfoldhq/trellis`. -- Do not assume the user has the Trellis GitHub repository. -- Do not overwrite local files already modified by the user with default templates. -- Do not put team project rules into public `trellis-meta`; project rules belong in `.trellis/spec/` or a local skill. - -## When To Inspect Upstream Source - -Switch to an upstream source-code perspective only when the user explicitly expresses one of these goals: - -- "I want to open a PR to Trellis" -- "I want to change npm package publish contents" -- "I want to fork Trellis" -- "I want to modify the generation logic for `trellis init/update`" - -Otherwise, default to modifying local Trellis files inside the user project. diff --git a/.agents/skills/trellis-meta/references/local-architecture/bundled-skills.md b/.agents/skills/trellis-meta/references/local-architecture/bundled-skills.md deleted file mode 100644 index d8f9763..0000000 --- a/.agents/skills/trellis-meta/references/local-architecture/bundled-skills.md +++ /dev/null @@ -1,159 +0,0 @@ -# Bundled Skills - -"Bundled skills" are multi-file built-in skills shipped inside the Trellis CLI npm package. Unlike marketplace skills (which a user installs separately into their own `.claude/skills/` or other platform skill root), bundled skills are written automatically into every supported platform's skill root by `trellis init` and kept in sync by `trellis update`. They are part of Trellis itself, not third-party content. - -A bundled skill is a directory under `packages/cli/src/templates/common/bundled-skills/<skill>/` that already contains its own `SKILL.md` (with YAML frontmatter) plus optional `references/`, assets, or other supporting files. Trellis copies the whole directory tree as-is into each platform's skill root, so references stay lazy-loadable instead of being flattened into one oversized `SKILL.md`. - -## What Counts As Bundled (vs. Adjacent Concepts) - -| Source path | Type | How it ships | -| --- | --- | --- | -| `templates/common/bundled-skills/<name>/` | Bundled skill (multi-file) | Whole directory copied to every platform skill root | -| `templates/common/skills/<name>.md` | Single-file workflow skill | Wrapped with frontmatter, written as `<root>/<name>/SKILL.md` | -| `templates/common/commands/<name>.md` | Slash command / prompt | Written to each platform's command directory (`.claude/commands/trellis/`, `.cursor/commands/trellis-*.md`, `.gemini/commands/trellis/*.toml`, etc.) | -| `templates/<platform>/skills/` | Platform-specific skill | Written only into that platform's directory (e.g. `.codex/skills/`) | -| User skills under `.claude/skills/<my-skill>/` etc. | Marketplace or user-authored | Not managed by Trellis at all | - -The Trellis CLI never touches anything that is not produced by one of its own template loaders. Anything a user drops into a platform skill root by hand is left alone. - -## Current Bundled Skills (v0.6.0) - -The set is discovered at runtime by listing directories under `templates/common/bundled-skills/`: - -| Skill | Purpose | -| --- | --- | -| `trellis-meta` | This skill. Explains the local Trellis architecture and customization entry points to an AI working inside a user project. | -| `trellis-session-insight` | Wraps the `trellis mem` CLI so an AI knows when and how to reach into past Claude Code / Codex / Pi Agent conversation logs. | -| `trellis-spec-bootstrap` | Platform-neutral workflow for creating or refreshing `.trellis/spec/` from the real codebase (with optional GitNexus / ABCoder integration). | -| `trellis-channel` | Capability skill teaching an AI when to reach for `trellis channel` for multi-agent collaboration, forum/thread persistent boards, and dispatcher-wait patterns. | - -The list is discovered at runtime, so adding a new directory under `bundled-skills/` is the only step required to register a new skill (see "Adding a New Bundled Skill" below). - -## Where Bundled Skills Land Per Platform - -A platform's whole file set — commands, workflow skills, agents, hooks, bundled skills — is described exactly once, by `collect<Platform>Templates()` in `packages/cli/src/configurators/<platform>.ts`. For bundled skills that description is two calls: `resolveBundledSkills(ctx)` reads every directory under `templates/common/bundled-skills/`, resolves placeholders, and returns a flat list of `{relativePath, content}` entries; `collectSkillTemplates(<skillsRoot>, <workflowSkills>, <bundledSkills>)` folds them into the platform's `Map<filePath, content>` under `<skillsRoot>/<skill>/<relativePath>`. - -All 21 platforms receive the full bundled-skill set: - -| Platform | Bundled skill root | -| --- | --- | -| Claude Code | `.claude/skills/<skill>/` | -| Cursor | `.cursor/skills/<skill>/` | -| OpenCode | `.opencode/skills/<skill>/` | -| Codex | `.agents/skills/<skill>/` | -| Gemini CLI | `.agents/skills/<skill>/` | -| Pi | `.agents/skills/<skill>/` | -| Kimi | `.agents/skills/<skill>/` | -| Kilo | `.kilocode/skills/<skill>/` | -| Kiro | `.kiro/skills/<skill>/` | -| Antigravity | `.agent/skills/<skill>/` | -| Devin | `.devin/skills/<skill>/` | -| Qoder | `.qoder/skills/<skill>/` | -| Codebuddy | `.codebuddy/skills/<skill>/` | -| Copilot | `.github/skills/<skill>/` | -| Droid | `.factory/skills/<skill>/` | -| Reasonix | `.reasonix/skills/<skill>/` | -| ZCode | `.zcode/skills/<skill>/` | -| Trae | `.trae/skills/<skill>/` | -| OMP | `.omp/skills/<skill>/` | -| Grok | `.grok/skills/<skill>/` | -| Snow | `.snow/skills/<skill>/` | - -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, collect<Platform>Templates())`. For 18 of the 21 platforms the registry entry in `configurators/index.ts` is literally `fromTemplates(collect<Platform>Templates)`, which *is* that composition. Claude Code, Codex and ZCode spell out a `configure` of their own, each for work a `Map<path, content>` cannot express (an opt-in `--with-statusline` flag, an intentionally empty `.codex/skills/` directory, a one-shot console notice) — none of them restates the file list. -2. `trellis update` → `collectPlatformTemplates(platformId)` (in `configurators/index.ts`) → the same map, used to detect drift and to populate `.trellis/.template-hashes.json`. - -Because both consumers read the one description, init and update cannot disagree about which files a bundled skill produces. - -## Dispatch Wiring (Code Path) - -The mechanism that auto-dispatches bundled skills to platform skill roots lives in two files: - -1. `packages/cli/src/templates/common/index.ts` - - `listDirectories("bundled-skills")` enumerates the on-disk skills. - - `listBundledSkillFiles(skillDir)` walks each skill's directory recursively and returns `{relativePath, content}` for every file. - - `getBundledSkillTemplates()` returns the cached `CommonBundledSkill[]`. - -2. `packages/cli/src/configurators/shared.ts` - - `resolveBundledSkills(ctx)` flattens that list into `ResolvedSkillFile[]` with `<skill>/<relativePath>` paths and resolved placeholders. - - `collectSkillTemplates(skillsRoot, workflowSkills, bundledSkills)` returns workflow skills and bundled skill files together as a `Map<filePath, content>` rooted at `skillsRoot`. - - `writeTemplateMap(cwd, files)` is the single writer that puts a collected map on disk. - -Every platform that supports skills reaches those two helpers from its own `collect<Platform>Templates()` — either directly (`claude.ts`, `codex.ts`, `copilot.ts`, `gemini.ts`, `grok.ts`, `kimi.ts`, `kiro.ts`, `omp.ts`, `opencode.ts`, `pi.ts`, `reasonix.ts`, `snow.ts`, `zcode.ts`) or through `collectBothTemplates(ctx, cmdPath, skillRoot)` in `shared.ts`, which makes the same two calls on behalf of platforms that have both a commands directory and a skills root (`antigravity.ts`, `codebuddy.ts`, `cursor.ts`, `devin.ts`, `droid.ts`, `kilo.ts`, `qoder.ts`, `trae.ts`). - -## Adding a New Bundled Skill - -The shape and dispatch wiring are already generic, so adding a skill requires only file changes plus distribution verification. - -1. **Create the directory tree.** - - ``` - packages/cli/src/templates/common/bundled-skills/<my-skill>/ - SKILL.md # YAML frontmatter + body - references/ # optional - <topic>.md - assets/ # optional (anything readable as utf-8) - ``` - -2. **Write a valid `SKILL.md` header.** The frontmatter must include at minimum: - - ```yaml - --- - name: <my-skill> - description: "When the AI should reach for this skill. Triggering phrases go here." - --- - ``` - - The `description` is what each platform's auto-trigger mechanism matches against, so it should describe the user-intent triggers, not the skill's internals. - -3. **Use placeholders where appropriate.** Bundled skill content runs through `resolvePlaceholders(file.content, ctx)`. Any `{{platform_name}}`, `{{python_cmd}}`, etc. token supported by `resolvePlaceholders` will be substituted per platform. - -4. **No dispatch wiring is required.** `listDirectories("bundled-skills")` discovers the new directory automatically, so all platforms receive it on the next `trellis init` or `trellis update`. - -5. **Verify the distribution path** before shipping. Skipping any of these steps has historically caused features to be documented as bundled while the published npm tarball was missing the files: - - - Source files exist on the branch being tagged. - - `pnpm --filter @mindfoldhq/trellis build` copies the asset into `dist/templates/common/bundled-skills/<skill>/`. - - `npm pack --dry-run --json` includes the expected `dist/**` paths. - - In a fresh temp project, `trellis init` writes `.claude/skills/<skill>/SKILL.md`, `.agents/skills/<skill>/SKILL.md`, `.zcode/skills/<skill>/SKILL.md`, etc. - - `.trellis/.template-hashes.json` lists the generated files. - - `trellis update --dry-run` in that temp project reports "Already up to date!". - -6. **Add a migration manifest entry** if the skill is added in a release that other projects will upgrade into. Without an explicit manifest entry the file will land via the standard "missing file" branch of `trellis update`, but a manifest makes the change visible in the changelog. - -## Overriding a Bundled Skill Locally - -There is no formal "project-local skill" mechanism (e.g. `.trellis/skills/`). Bundled skills are platform-rooted, so any override is platform-rooted too. - -The supported pattern relies on the existing template-hash diff in `trellis update`: - -1. Edit the local file directly. Example: `.claude/skills/trellis-meta/SKILL.md`. -2. The file's hash now diverges from the entry in `.trellis/.template-hashes.json`. -3. The next `trellis update` detects the user modification and leaves the file untouched (Trellis never overwrites user-modified files without an explicit `--force`). - -Caveats: - -- The override only applies to the one platform whose directory you edited. To override the same skill across, for example, Claude Code and Codex, you must edit both `.claude/skills/<name>/` and `.agents/skills/<name>/`. -- A future `trellis update --force` will overwrite local edits. Keep the override under version control so it can be reapplied if needed. -- Marketplace skills installed under the same platform skill root with a different folder name (e.g. `.claude/skills/my-custom-meta/`) are untouched by Trellis and are the cleaner option when the goal is to add behavior, not to mutate the bundled skill. -- Team-private conventions belong in `.trellis/spec/` or in a separate marketplace-style local skill, not in modifications to `trellis-meta` itself. See `customize-local/add-project-local-conventions.md`. - -## Removing a Bundled Skill From a Project - -There is no per-project opt-out flag for bundled skills. Two options: - -1. **Delete the directory in each platform skill root.** `trellis update` will see the file missing, compare against `.template-hashes.json`, and treat the deletion the same as any other user modification — it will not silently re-create the directory unless `--force` is passed. - -2. **Pin a Trellis version that did not ship the skill.** The bundled-skill set is determined at build time, so installing an older release of the CLI is the only way to permanently exclude a skill that the current release ships. - -A third option — globally disabling all bundled skills — is not supported. The dispatch is unconditional: `collect<Platform>Templates()` takes no arguments, so there is nowhere for a flag to enter. Adding one would mean changing that signature across all 21 platforms plus `collectPlatformTemplates` in `configurators/index.ts`. - -## Operating Rules - -- Treat `templates/common/bundled-skills/` as the single source of truth for what bundled skills exist. Do not hand-maintain platform-by-platform skill lists. -- Do not add platform-specific logic inside a bundled `SKILL.md`. If a behavior is platform-specific, put it in `templates/<platform>/skills/` instead. -- Do not couple bundled skills to a specific CLI binary (e.g. `trellis mem`) without surfacing the dependency in the skill's description and references — users on older releases may not have the command. -- Do not store project-private content in a bundled skill. Bundled skills are public, shipped to every user; project rules belong in `.trellis/spec/` or a local skill. diff --git a/.agents/skills/trellis-meta/references/local-architecture/context-injection.md b/.agents/skills/trellis-meta/references/local-architecture/context-injection.md deleted file mode 100644 index 4a7517b..0000000 --- a/.agents/skills/trellis-meta/references/local-architecture/context-injection.md +++ /dev/null @@ -1,68 +0,0 @@ -# Local Context Injection System - -Trellis context injection aims to make AI read the right files at the right time instead of relying on model memory. In a user project, injection is implemented by `.trellis/` scripts together with platform hooks, agents, and skills. - -## Injected Context Types - -| Type | Source | Purpose | -| --- | --- | --- | -| session context | `.trellis/scripts/get_context.py` | Current developer, git status, active task, active tasks, journal, packages. | -| workflow context | `.trellis/workflow.md` | Current Trellis flow and next action. | -| spec context | `.trellis/spec/` + task JSONL | Specs that must be followed during implementation/checking. | -| task context | `.trellis/tasks/<task>/prd.md`, `design.md`, `implement.md`, `research/` | Current task requirements, design, execution plan, and research. | -| platform context | Platform hooks/settings/agents | Lets different AI tools read the files above through their own mechanisms. | - -## session-start - -Platforms with session-start support inject a Trellis overview when a session starts, clears, compacts, or receives a similar event. Injected content usually includes: - -- workflow summary. -- current task status. -- active tasks. -- spec index paths. -- developer identity and git status. - -If the user feels the AI does not know the current task in a new session, first check whether the platform's session-start hook or equivalent mechanism is installed and running. - -## workflow-state - -workflow-state is a lightweight hint injected around each user turn. Based on current task status, it selects a block from `.trellis/workflow.md`, such as `no_task`, `planning`, `in_progress`, or `completed`. - -If the user wants to change "what the AI should do next in a given state," edit the corresponding state block in `.trellis/workflow.md` first. - -## sub-agent context - -Implement and check agents need task context. Trellis has two loading modes: - -1. **hook push**: a platform hook injects jsonl-referenced files plus `prd.md`, `design.md` if present, and `implement.md` if present before the agent starts. -2. **agent pull**: the agent definition instructs the agent to read the active task, jsonl context, and task artifacts after startup. - -In both modes, JSONL files in the task directory are the manifest for spec/research context. Task artifacts are read separately in this order: `prd.md` -> `design.md if present` -> `implement.md if present`. - -## JSONL Reading Rules - -`implement.jsonl` and `check.jsonl` contain one JSON object per line: - -```jsonl -{"file": ".trellis/spec/backend/index.md", "reason": "Backend rules"} -``` - -Readers should skip seed rows without a `file` field. When configuring JSONL, the AI should include only spec/research files, not pre-register code files that will be modified. - -## Active Task And Context Key - -Active task state lives in `.trellis/.runtime/sessions/` and is isolated per session. Hooks try to resolve the context key from platform events, environment variables, transcript paths, or `TRELLIS_CONTEXT_ID`. - -If shell commands cannot see the same context key, `task.py current --source` may report no active task. In that case, check whether the platform passes session identity into the shell instead of hand-writing a global current-task file. - -## Local Customization Points - -| Need | Edit location | -| --- | --- | -| Change session-start injected content | The platform's `session-start` hook or plugin file. | -| Change per-turn workflow-state rules | `[workflow-state:STATUS]` block in `.trellis/workflow.md`. The platform workflow-state hook parses these blocks verbatim and embeds no fallback text. | -| Change how sub-agents read context | Platform agent definitions, the `inject-subagent-context` hook, or agent preludes. | -| Change JSONL validation/display | `.trellis/scripts/common/task_context.py`. | -| Change active task resolution | `.trellis/scripts/common/active_task.py`. | - -When modifying context injection, verify two things: new sessions can see the correct task, and sub-agents can see the correct task artifacts/spec/research. diff --git a/.agents/skills/trellis-meta/references/local-architecture/generated-files.md b/.agents/skills/trellis-meta/references/local-architecture/generated-files.md deleted file mode 100644 index b460224..0000000 --- a/.agents/skills/trellis-meta/references/local-architecture/generated-files.md +++ /dev/null @@ -1,80 +0,0 @@ -# Local Files Generated After Init - -`trellis init` writes the Trellis runtime into the user project. Later, `trellis update` tries to update Trellis-managed template files, but it uses `.trellis/.template-hashes.json` to determine which files have already been modified by the user. - -This page only describes files that are visible and editable inside the user project. - -## `.trellis/` - -```text -.trellis/ -├── workflow.md -├── config.yaml -├── .developer -├── .version -├── .template-hashes.json -├── .runtime/ -├── scripts/ -├── spec/ -├── tasks/ -└── workspace/ -``` - -| Path | Usually editable? | Notes | -| --- | --- | --- | -| `.trellis/workflow.md` | Yes | Local workflow documentation and AI routing rules. | -| `.trellis/config.yaml` | Yes | Project configuration, hooks, packages, journal line limits, and related settings. | -| `.trellis/spec/` | Yes | Project specs, intended to be updated regularly by users and AI. | -| `.trellis/tasks/` | Yes | Task material and research artifacts, maintained by the task workflow. | -| `.trellis/workspace/` | Yes | Session records, usually written by `add_session.py`. | -| `.trellis/scripts/` | Carefully | Local runtime. It can be customized, but only after understanding the call chain. | -| `.trellis/.runtime/` | No | Runtime state, usually written automatically by hooks/scripts. | -| `.trellis/.developer` | Carefully | Current developer identity. | -| `.trellis/.version` | No | Trellis version record used by update/migration logic. | -| `.trellis/.template-hashes.json` | No | Template hash record. Do not hand-write business rules here. | - -## Platform Directories - -Different platforms generate different directories. Common categories: - -| Category | Example paths | Purpose | -| --- | --- | --- | -| hooks | `.claude/hooks/`, `.codex/hooks/`, `.cursor/hooks/` | Inject session context, workflow-state, and sub-agent context. | -| settings | `.claude/settings.json`, `.codex/hooks.json`, `.qoder/settings.json`, `.trae/hooks.json` | Tell the platform when to run hooks or plugins. | -| agents | `.claude/agents/`, `.codex/agents/`, `.kiro/agents/`, `.zcode/agents/` | Define agents such as `trellis-research`, `trellis-implement`, and `trellis-check`. | -| skills | `.claude/skills/`, `.agents/skills/`, `.qoder/skills/`, `.zcode/skills/` | Skills that auto-trigger or can be read by AI. | -| commands/prompts/workflows | `.cursor/commands/`, `.github/prompts/`, `.devin/workflows/`, `.zcode/commands/` | Explicit user-invoked command or workflow entry points. | - -When modifying a platform directory, also confirm whether `.trellis/workflow.md` still describes the same flow. - -## Meaning Of Template Hashes - -`.trellis/.template-hashes.json` records the content hash from the last time Trellis wrote a template file. `trellis update` uses it to distinguish three cases: - -| Case | Update behavior | -| --- | --- | -| File was not modified by the user | It can be updated automatically. | -| File was modified by the user | Prompt the user to overwrite, keep, or generate `.new`. | -| File is no longer a current template | It may be deleted, renamed, or preserved according to migration rules. | - -When an AI customizes local Trellis files, it does not need to maintain hashes manually. It is normal for Trellis update to recognize the result as "modified by the user." - -## Local Customization Boundaries - -Editable by default: - -- `.trellis/workflow.md` -- `.trellis/config.yaml` -- `.trellis/spec/**` -- `.trellis/scripts/**` -- Platform hooks, settings, agents, skills, commands, prompts, and workflows - -Do not edit by default: - -- Global npm install directory -- `node_modules/@mindfoldhq/trellis` -- Trellis GitHub repository source code -- Concrete state files under `.trellis/.runtime/**` -- Hash contents inside `.trellis/.template-hashes.json` - -Switch to the Trellis CLI source-code perspective only when the user explicitly wants to contribute upstream. diff --git a/.agents/skills/trellis-meta/references/local-architecture/multi-agent-channel.md b/.agents/skills/trellis-meta/references/local-architecture/multi-agent-channel.md deleted file mode 100644 index 6df61eb..0000000 --- a/.agents/skills/trellis-meta/references/local-architecture/multi-agent-channel.md +++ /dev/null @@ -1,69 +0,0 @@ -# Local Multi-Agent Channel Runtime - -`trellis channel` is the local multi-agent collaboration runtime shipped with the Trellis CLI. It lets the main AI session spawn peer workers (Claude Code, Codex, or any agent definition under `.trellis/agents/`), exchange durable messages through an event log, and coordinate review or brainstorm loops without hand-stitching shell pipelines. - -This reference covers how channels are wired into the user project so an AI customizing the project knows what to edit. For runtime usage (commands, forum/thread patterns, worker spawn flags), defer to the bundled `trellis-channel` capability skill. - -## Local System Model - -The channel runtime spans three local surfaces: - -1. **Storage layer** in the user's home directory: durable event logs and worker state files. -2. **Agent definitions** inside the project at `.trellis/agents/`: platform-agnostic role cards consumed by `trellis channel spawn --agent <name>`. -3. **Project configuration** in `.trellis/config.yaml`: worker guard thresholds and other channel knobs. - -## Core Paths - -| Path | Purpose | -| --- | --- | -| `~/.trellis/channels/<project>/<channel>/events.jsonl` | Per-channel append-only event log. Sequence-locked, replay-safe. | -| `~/.trellis/channels/<project>/<channel>/<channel>.lock` | Channel-level write lock. | -| `~/.trellis/channels/<project>/<channel>/<worker>.spawnlock` | Per-worker spawn lock used by the OOM guard. | -| `~/.trellis/channels/<project>/<channel>/.seq` | Sequence sidecar for ordered event assignment. | -| `~/.trellis/channels/_global/<channel>/...` | Channels created with `--scope global`. The project bucket is replaced by a shared key. | -| `.trellis/agents/check.md` | Default Check Agent role definition consumed by `--agent check`. | -| `.trellis/agents/implement.md` | Default Implement Agent role definition consumed by `--agent implement`. | -| `.trellis/config.yaml` (`channel.*` block) | Worker guard thresholds and channel defaults. | - -The project bucket name is derived from the absolute project path (slashes flattened, non-alphanumerics replaced with `-`), matching Claude Code's `~/.claude/projects/<sanitized-cwd>/` convention. Override with `TRELLIS_CHANNEL_ROOT` (root directory) or `TRELLIS_CHANNEL_PROJECT` (bucket name) for testing or sandboxing. - -## When To Reach For The Channel Runtime - -Channels are heavier than a single Bash call or a one-shot sub-agent dispatch. Use them only when at least one of these conditions holds: - -- The work needs **two or more agents to converse** through more than one turn (cross-AI brainstorm, peer review, dispatcher + worker). -- A worker should run as a **peer process** that the main session can interrupt, watch progress on, or wait for asynchronously. -- The conversation must be **durable and inspectable** later (forum/thread channels, issue boards, decision trails). -- Multiple workers must **share an event log** so each can see what the others reported. - -Prefer cheaper primitives when: - -- A single-shot Bash command or single Agent tool call is enough -> do that directly. -- The user just needs a static review against a file -> read the file and reply inline. -- The need is "remember what we discussed last week" -> use `trellis mem` instead of a channel. - -## Customization Points - -| Need | Edit location | -| --- | --- | -| Change default channel worker idle timeout | `channel.worker_guard.idle_timeout` in `.trellis/config.yaml`. Accepts `5m`, `30s`, etc. Set `0` to disable idle cleanup. | -| Change live worker budget | `channel.worker_guard.max_live_workers` in `.trellis/config.yaml`. Set `0` to disable the spawn-time budget check. | -| Override worker guard per spawn | Pass `--idle-timeout` / `--max-live-workers` on `trellis channel spawn`, or set `TRELLIS_CHANNEL_WORKER_IDLE_TIMEOUT` / `TRELLIS_CHANNEL_MAX_LIVE_WORKERS` in the environment. | -| Change what the default Check or Implement worker does | Edit `.trellis/agents/check.md` or `.trellis/agents/implement.md`. These are platform-agnostic role cards; the channel runtime injects them when `--agent check|implement` is passed. | -| Add a new role card | Drop `<name>.md` into `.trellis/agents/`. `trellis channel spawn --agent <name>` will pick it up. | -| Relocate channel storage (CI sandbox, ephemeral runs) | Set `TRELLIS_CHANNEL_ROOT=/path/to/dir`. Channel events move with it; existing channels stay at the old root. | -| Switch storage scope | Pass `--scope project` (default) or `--scope global` on every channel subcommand. The bucket directory changes; nothing else does. | - -Precedence for the worker guard is: CLI flag > environment variable > `.trellis/config.yaml` > built-in default. Built-in defaults are `idle_timeout: 5m` and `max_live_workers: 6`. - -## Relationship To Other Local Layers - -- **Workflow layer**: workflows that use channel dispatch (such as `channel-driven-subagent-dispatch`) instruct the main agent to call `trellis channel spawn --agent check` or `--agent implement` instead of a platform sub-agent. If `.trellis/agents/check.md` or `implement.md` is missing, `trellis workflow --template <id>` prints a non-blocking warning at install time. Restore them with `trellis update` if they are deleted by accident. -- **Task layer**: channel workers do not own task state. The supervising main session passes the active task path through the worker inbox; the worker resolves task artifacts from disk. -- **Spec layer**: workers read `.trellis/spec/` the same way the main session does. Channel runtime does not bypass spec context loading. -- **Platform integration layer**: channel runtime is platform-neutral. It does not depend on `.claude/`, `.codex/`, or any other platform directory. The adapters that normalize provider output (Claude `stream-json`, Codex `app-server`) live inside the Trellis CLI binary, not in the project. -- **Platform sub-agent files vs. channel workers**: editing `.claude/agents/trellis-implement.md` (and its peers in other platform `.X/agents/` directories) does NOT change channel-runtime worker behavior — channel workers load `.trellis/agents/<name>.md`. The platform-specific agent files are for direct sub-agent dispatch from the main AI session, not for channel-spawned workers. See `platform-files/agents.md` for the per-platform agent surface, and the `trellis-meta/SKILL.md` rule that codifies this split. - -## Runtime Usage - -For command syntax, forum/thread patterns, worker handles, progress inspection, and the `--kind done` / `--kind turn_finished` dispatcher wait pattern, load the bundled `trellis-channel` skill (auto-installed under each platform's skills directory after `trellis init` / `trellis update`). This reference only covers the local file layout and customization knobs; it does not duplicate command syntax that may change between releases. diff --git a/.agents/skills/trellis-meta/references/local-architecture/overview.md b/.agents/skills/trellis-meta/references/local-architecture/overview.md deleted file mode 100644 index e97cab8..0000000 --- a/.agents/skills/trellis-meta/references/local-architecture/overview.md +++ /dev/null @@ -1,51 +0,0 @@ -# Local Trellis Architecture Overview - -`trellis-meta` is for user projects that have already run `trellis init`. The user's machine usually has only the npm-installed `trellis` command plus the Trellis files generated inside the project; it may not have the Trellis CLI source code. - -Therefore, when an AI uses this skill, the default customization target is local files inside the user project: - -- `.trellis/`: workflow, tasks, specs, memory, scripts, and runtime state. -- Platform directories: `.claude/`, `.codex/`, `.cursor/`, `.opencode/`, `.kiro/`, `.gemini/`, `.qoder/`, `.codebuddy/`, `.github/`, `.factory/`, `.pi/`, `.kilocode/`, `.agent/`, `.devin/`, `.reasonix/`, `.zcode/`, and similar directories. -- Shared skill layer: `.agents/skills/`. - -Do not default to guiding the user to fork the Trellis CLI repository. Treat upstream source code as the operating target only when the user explicitly says they want to change Trellis upstream source, publish an npm package, or contribute a PR. - -## Local System Model - -Trellis provides three layers inside a user project: - -1. **Workflow layer**: `.trellis/workflow.md` defines phases, routing, next actions, and prompt blocks. -2. **Persistence layer**: `.trellis/tasks/`, `.trellis/spec/`, and `.trellis/workspace/` store tasks, specs, and session memory. -3. **Platform integration layer**: hooks, settings, agents, skills, commands, prompts, and workflows in platform directories connect the Trellis workflow to different AI tools. - -All three layers live inside the user project, so an AI can read and modify them directly. - -## Core Paths - -| Path | Purpose | -| --- | --- | -| `.trellis/workflow.md` | Workflow phases, skill routing, and workflow-state prompt blocks. | -| `.trellis/config.yaml` | Project configuration, task lifecycle hooks, monorepo package configuration, and journal configuration. | -| `.trellis/spec/` | The user's project-specific coding conventions and thinking guides. | -| `.trellis/tasks/` | Each task's PRD, technical notes, research files, and JSONL context. | -| `.trellis/workspace/` | Per-developer journals and cross-session memory. | -| `.trellis/scripts/` | Local Python runtime used by commands, hooks, and context injection. | -| `.trellis/.runtime/` | Session-level runtime state, such as the current task pointer. | -| `.trellis/.template-hashes.json` | Template hashes for Trellis-managed files, used by update to determine whether local files were modified by the user. | - -## AI Customization Principles - -1. **Find the local source of truth first**: Do not edit from memory. Read `.trellis/workflow.md`, `.trellis/config.yaml`, the relevant platform directory, and related task files first. -2. **Edit the user project, not the npm package cache**: Modify generated files inside the project, not `node_modules` or the global npm install directory. -3. **Keep platform files aligned with `.trellis/`**: If workflow routing changes, also check whether platform skills or commands still describe the same flow. -4. **Put project-specific rules in `.trellis/spec/` or a local skill**: Do not put team conventions into `trellis-meta`. -5. **Preserve user changes**: If a file was already modified locally, work from the current content instead of overwriting it with a default template. - -## How To Use This Directory - -- To understand which files exist after init, read `generated-files.md`. -- To change phases, routing, or next actions, read `workflow.md`. -- To change the task model, JSONL context, or active task behavior, read `task-system.md`. -- To change coding convention injection, read `spec-system.md`. -- To understand journals and cross-session memory, read `workspace-memory.md`. -- To change hooks or sub-agent context loading, read `context-injection.md`. diff --git a/.agents/skills/trellis-meta/references/local-architecture/spec-system.md b/.agents/skills/trellis-meta/references/local-architecture/spec-system.md deleted file mode 100644 index 38fdf14..0000000 --- a/.agents/skills/trellis-meta/references/local-architecture/spec-system.md +++ /dev/null @@ -1,102 +0,0 @@ -# Local Spec System - -`.trellis/spec/` is the user's project-specific engineering spec library. Trellis is not about making AI memorize conventions; it injects relevant specs or requires the AI to read them at the right time. - -## Directory Model - -A common single-repository structure: - -```text -.trellis/spec/ -├── backend/ -│ ├── index.md -│ └── ... -├── frontend/ -│ ├── index.md -│ └── ... -└── guides/ - ├── index.md - └── ... -``` - -A common monorepo structure: - -```text -.trellis/spec/ -├── cli/ -│ ├── backend/ -│ │ ├── index.md -│ │ └── ... -│ └── unit-test/ -│ ├── index.md -│ └── ... -├── docs-site/ -│ └── docs/ -│ ├── index.md -│ └── ... -└── guides/ - ├── index.md - └── ... -``` - -`index.md` is the entry point for each layer. It should list the Pre-Development Checklist and Quality Check. Specific guidelines live in other Markdown files in the same directory. - -## Package Configuration - -`.trellis/config.yaml` can declare packages: - -```yaml -packages: - cli: - path: packages/cli - docs-site: - path: docs-site - type: submodule -default_package: cli -``` - -The AI can run: - -```bash -python3 ./.trellis/scripts/get_context.py --mode packages -``` - -This command lists packages and spec layers for the current project. Use this output as the reference when configuring context JSONL. - -## How Specs Enter Tasks - -Before a task enters implementation, planning may write relevant specs into `implement.jsonl` / `check.jsonl` when the task needs spec or research context beyond the task artifacts: - -```jsonl -{"file": ".trellis/spec/cli/backend/index.md", "reason": "CLI backend conventions"} -{"file": ".trellis/spec/cli/unit-test/conventions.md", "reason": "Test expectations"} -``` - -Sub-agents or platform preludes read these JSONL files and load the referenced specs. On platforms without sub-agent support, the AI should read the relevant specs directly according to the workflow. - -## What Specs Should Contain - -Specs should contain executable engineering conventions for the project, not generic best practices: - -- Where files should live. -- How error handling should be expressed. -- Input/output contracts for APIs, hooks, and commands. -- Patterns that are forbidden. -- Cases that require tests. -- Project-specific pitfalls and how to avoid them. - -When the AI learns a new rule during implementation or debugging, it should update `.trellis/spec/` rather than only summarizing it in chat. - -## Local Customization Points - -| Need | Edit location | -| --- | --- | -| Add a new spec layer | `.trellis/spec/<package>/<layer>/index.md` and corresponding guideline files. | -| Change monorepo spec mapping | `packages` / `default_package` / `spec_scope` in `.trellis/config.yaml`. | -| Change which specs AI reads before implementation | The task's `implement.jsonl`. | -| Change which specs AI reads during checking | The task's `check.jsonl`. | -| Change when specs should be updated | Phase 3.3 in `.trellis/workflow.md` and the `trellis-update-spec` skill. | - -## Boundaries - -`.trellis/spec/` is the user's project specification, not a permanent copy of Trellis built-in templates. The AI should encourage the user to update it according to the actual project code instead of treating Trellis default templates as immutable documents. diff --git a/.agents/skills/trellis-meta/references/local-architecture/task-system.md b/.agents/skills/trellis-meta/references/local-architecture/task-system.md deleted file mode 100644 index 7133495..0000000 --- a/.agents/skills/trellis-meta/references/local-architecture/task-system.md +++ /dev/null @@ -1,130 +0,0 @@ -# Local Task System - -The Trellis task system is stored entirely under `.trellis/tasks/` in the user project. Each task is a directory containing requirements, context, research, state, and relationship information. - -## Task Directory Structure - -```text -.trellis/tasks/ -├── 04-28-example-task/ -│ ├── task.json -│ ├── prd.md -│ ├── design.md -│ ├── implement.md -│ ├── implement.jsonl -│ ├── check.jsonl -│ └── research/ -└── archive/ - └── 2026-04/ -``` - -| File | Purpose | -| --- | --- | -| `task.json` | Task metadata: status, assignee, priority, branch, parent/child tasks, and similar fields. | -| `prd.md` | Requirements, constraints, and acceptance criteria. Lightweight tasks may be PRD-only. | -| `design.md` | Technical design for complex tasks: boundaries, contracts, data flow, compatibility, tradeoffs. | -| `implement.md` | Execution plan for complex tasks: ordered checklist, validation commands, review gates, rollback points. | -| `implement.jsonl` | List of spec/research files the implement agent must read first. | -| `check.jsonl` | List of spec/research files the check agent must read first. | -| `research/` | Research artifacts. Complex findings should not live only in chat. | - -## `task.json` - -`task.json` records task status and metadata. Common fields: - -| Field | Meaning | -| --- | --- | -| `id` / `name` / `title` | Task identity and title. | -| `status` | Status such as `planning`, `in_progress`, `review`, or `completed`. | -| `priority` | `P0`, `P1`, `P2`, `P3`. | -| `creator` / `assignee` | Creator and assignee. | -| `package` | Target package in a monorepo; may be empty. | -| `branch` / `base_branch` | Working branch and PR target branch. | -| `children` / `parent` | Parent/child task relationships. | -| `commit` / `pr_url` | Commit and PR information after completion. | -| `meta` | Extension fields. | - -## Parent / Child Task Trees - -Parent/child task relationships are for work structure. A parent task groups related deliverables under one source requirement set; it is not a dependency scheduler and does not replace the child task's own planning artifacts. - -Use a parent task when a request has multiple independently verifiable deliverables. The parent owns: - -- Source requirements and user-facing scope. -- The map of child tasks and their responsibility boundaries. -- Cross-child acceptance criteria and final integration review. - -Use child tasks for deliverables that can move through planning, implementation, check, and archive independently. If one child depends on another, write that dependency in the child `prd.md` / `implement.md`; do not rely on tree position to imply ordering. - -Create new children with: - -```bash -python3 ./.trellis/scripts/task.py create "<child title>" --slug <child-slug> --parent <parent-dir> -``` - -Link or unlink existing tasks with: - -```bash -python3 ./.trellis/scripts/task.py add-subtask <parent-dir> <child-dir> -python3 ./.trellis/scripts/task.py remove-subtask <parent-dir> <child-dir> -``` - -`children` on the parent is a historical list. When a child is archived, Trellis keeps that child name in the parent so progress like `[2/3 done]` remains meaningful after completed children move to `archive/`. - -The AI should not treat phase numbers as task status. Task progress is mainly determined by `status`, artifact presence (`prd.md`, optional `design.md` / `implement.md`), whether JSONL context is configured for sub-agent mode, and the phase descriptions in `workflow.md`. - -## Active Task - -The user sees a "current task," but Trellis stores active task state per session. - -```text -.trellis/.runtime/sessions/<context-key>.json -``` - -`task.py start` writes the task path into the runtime session file for the current session. `task.py current --source` shows the current task and where it came from. Different AI windows can point to different tasks without overwriting each other. - -If the platform or shell environment has no stable session identity, `task.py start` may be unable to set the active task. The AI should read the error, inspect the platform hook/session environment, and not fall back to a shared global pointer. - -## JSONL Context - -`implement.jsonl` and `check.jsonl` are context manifests for sub-agents to read first. They do not replace `implement.md`; `implement.md` is the human-readable execution plan. - -Format: - -```jsonl -{"file": ".trellis/spec/cli/backend/index.md", "reason": "Backend conventions"} -{"file": ".trellis/tasks/04-28-example/research/api.md", "reason": "API research"} -``` - -Rules: - -- Include spec and research files. -- Do not include code files that are about to be modified. -- Do not treat temporary conclusions in chat as the only context. -- Seed rows have no `file` field; they only prompt the AI to fill in real entries. - -## Common Commands - -```bash -python3 ./.trellis/scripts/task.py create "<title>" --slug <slug> -python3 ./.trellis/scripts/task.py start <task> -python3 ./.trellis/scripts/task.py current --source -python3 ./.trellis/scripts/task.py add-context <task> implement <file> <reason> -python3 ./.trellis/scripts/task.py validate <task> -python3 ./.trellis/scripts/task.py finish -python3 ./.trellis/scripts/task.py archive <task> -``` - -When modifying the task system, the AI should prefer script commands to maintain structure. Edit JSON/Markdown directly only when scripts do not cover the need. - -## Local Customization Points - -| Need | Edit location | -| --- | --- | -| Change the default task template | `.trellis/scripts/common/task_store.py` and task creation instructions. | -| Change status semantics | `.trellis/workflow.md`, workflow-state hook logic, and task usage conventions. | -| Add task lifecycle actions | `hooks.after_*` in `.trellis/config.yaml`. | -| Change context rules | Planning artifact guidance in `.trellis/workflow.md` and related platform agent/hook instructions. | -| Change archive policy | `.trellis/scripts/common/task_store.py` / `task_utils.py`. | - -These are local files in the user project. Do not default to editing Trellis CLI source code unless the user wants to contribute upstream. diff --git a/.agents/skills/trellis-meta/references/local-architecture/workflow.md b/.agents/skills/trellis-meta/references/local-architecture/workflow.md deleted file mode 100644 index f0659ff..0000000 --- a/.agents/skills/trellis-meta/references/local-architecture/workflow.md +++ /dev/null @@ -1,75 +0,0 @@ -# Local Workflow System - -`.trellis/workflow.md` is the Trellis workflow source of truth inside the user project. An AI does not need Trellis source code to understand how the current project should move tasks forward; this file is enough. - -## File Responsibilities - -`.trellis/workflow.md` has three responsibilities: - -1. **Explain workflow phases**: Plan, Execute, Finish. -2. **Define skill routing**: which skill or agent the AI should use when the user expresses a certain intent. -3. **Provide workflow-state prompt blocks**: hooks can inject the prompt block for the current state into the conversation. - -## Current Phase Model - -```text -Phase 1: Plan -> clarify what to build, produce prd.md and required research -Phase 2: Execute -> implement against the PRD and specs, then check -Phase 3: Finish -> final verification, preserve lessons, and wrap up -``` - -Each phase contains numbered steps, such as `1.3 Configure context`. These numbers are not runtime fields in `task.json`; they are workflow structure for AI and humans to read. - -## Skill Routing - -`workflow.md` separates routing by platform capability: - -- Platforms with sub-agent support: dispatch `trellis-implement` by default for implementation and `trellis-check` for checking. -- Platforms without sub-agent support: the main session reads skills such as `trellis-before-dev`, then executes directly. - -When changing local AI behavior, update the routing descriptions in `workflow.md` first, then check whether the corresponding platform skill, command, or agent files need to stay in sync. - -## Workflow-State Prompt Blocks - -The bottom of `workflow.md` can contain state blocks like this: - -```text -[workflow-state:no_task] -... -[/workflow-state:no_task] -``` - -Hooks choose the right block based on current task status and inject it into the conversation. Common states include: - -| State | Meaning | -| --- | --- | -| `no_task` | The current session has no active task. | -| `planning` | The task is still in requirements, research, or context configuration. | -| `in_progress` | The task has entered implementation and checking. | -| `completed` | The task is complete and waiting for wrap-up or archive. | - -If the user wants to change policies such as "whether to create a task when there is no task," "when task creation may be skipped," or "whether sub-agents are required," edit these state blocks and the routing table above them. - -## Local Modification Patterns - -Common changes: - -| Goal | Edit point | -| --- | --- | -| Add a phase | Update the Phase Index, phase body, routing, and state blocks. | -| Change task creation policy | Update the `no_task` state block and Phase 1 description. | -| Change the default implementation/check path | Update Phase 2 and skill routing. | -| Change the wrap-up flow | Update Phase 3 and `finish-work` related descriptions. Note the current split: Phase 3.4 = AI-driven code commits (batched, user-confirmed), Phase 3.5 = `/finish-work` (archive + record session). `/finish-work` refuses to run if the working tree is dirty. | -| Change platform differences | Update routing descriptions grouped by platform. | - -After editing, make the AI reread `.trellis/workflow.md`; do not assume the flow from the old conversation is still valid. - -## Relationship To Platform Files - -`workflow.md` is the semantic center of the local workflow, but each platform can also have its own entry files: - -- skills, such as `trellis-brainstorm` and `trellis-check`. -- commands/prompts/workflows, such as continue and finish-work. -- hooks, such as session-start or workflow-state injection. - -If only `workflow.md` changes, platform entry files may still contain old language. When the user wants to change "what the AI actually does," also inspect the relevant platform directory. diff --git a/.agents/skills/trellis-meta/references/local-architecture/workspace-memory.md b/.agents/skills/trellis-meta/references/local-architecture/workspace-memory.md deleted file mode 100644 index c2958f2..0000000 --- a/.agents/skills/trellis-meta/references/local-architecture/workspace-memory.md +++ /dev/null @@ -1,71 +0,0 @@ -# Local Workspace Memory System - -`.trellis/workspace/` stores cross-session memory. Its purpose is to let AI and humans understand what happened before across different windows and different days. - -## Directory Structure - -```text -.trellis/workspace/ -├── index.md -└── <developer>/ - ├── index.md - ├── journal-1.md - └── journal-2.md -``` - -| File | Purpose | -| --- | --- | -| `.trellis/.developer` | Current developer identity. | -| `.trellis/workspace/index.md` | Global workspace overview. | -| `.trellis/workspace/<developer>/index.md` | Session index for a developer. | -| `.trellis/workspace/<developer>/journal-N.md` | Session journal. | - -## Developer Identity - -Run this the first time: - -```bash -python3 ./.trellis/scripts/init_developer.py <name> -``` - -This creates `.trellis/.developer` and the corresponding workspace directory. The AI should not change developer identity casually; if the identity is wrong, first confirm who is using the current project. - -## Journal - -`journal-N.md` records completed or partially completed work from each session. By default, each journal holds about 2000 lines; after that it rotates to the next file. - -Common command for recording a session: - -```bash -python3 ./.trellis/scripts/add_session.py \ - --title "Session title" \ - --summary "What changed" \ - --commit "abc1234" -``` - -Planning or review work without a commit can also be recorded by using `--no-commit` or an empty commit value. - -## Relationship Between Workspace Memory And Tasks - -| System | What it stores | -| --- | --- | -| `.trellis/tasks/` | Requirements, design, research, and state for a specific task. | -| `.trellis/workspace/` | Work records across tasks and sessions. | -| `.trellis/spec/` | Engineering knowledge preserved as long-term conventions. | - -If information is only useful for the current task, put it in the task directory. -If information describes what happened in the current session, put it in the workspace journal. -If information should be followed every time code is written in the future, put it in spec. - -## Local Customization Points - -| Need | Edit location | -| --- | --- | -| Change maximum journal lines | `max_journal_lines` in `.trellis/config.yaml`. | -| Change session auto-commit message | `session_commit_message` in `.trellis/config.yaml`. | -| Change session content format | `.trellis/scripts/add_session.py`. | -| Change how workspace is displayed in context | `.trellis/scripts/common/session_context.py`. | - -## AI Usage Rules - -The AI should not treat workspace as the only source of truth. When resuming a task, read the current task first, then use workspace for background. After a task is complete, record important process notes in workspace; if long-term rules emerged, update spec. diff --git a/.agents/skills/trellis-meta/references/platform-files/agents.md b/.agents/skills/trellis-meta/references/platform-files/agents.md deleted file mode 100644 index 7e5e47f..0000000 --- a/.agents/skills/trellis-meta/references/platform-files/agents.md +++ /dev/null @@ -1,83 +0,0 @@ -# Agents - -Trellis agent files define specialized roles. Common Trellis agents in a user project are: - -- `trellis-research` -- `trellis-implement` -- `trellis-check` - -File locations and formats differ by platform, but responsibility boundaries should stay consistent. - -## Agent Responsibilities - -| Agent | Responsibility | -| --- | --- | -| `trellis-research` | Investigate the question and write findings into the current task's `research/`. | -| `trellis-implement` | Implement against `prd.md`, optional `design.md` / `implement.md`, `implement.jsonl`, and related spec/research. | -| `trellis-check` | Review changes, fix discovered issues, and run necessary checks. | - -Agent files should not become generic chat prompts. They should define input sources, write boundaries, whether code may be changed, and how results are reported. - -## Common Paths - -| Platform | Agent path | -| --- | --- | -| Claude Code | `.claude/agents/trellis-*.md` | -| Cursor | `.cursor/agents/trellis-*.md` | -| OpenCode | `.opencode/agents/trellis-*.md` | -| Codex | `.codex/agents/trellis-*.toml` | -| Kiro | `.kiro/agents/trellis-*.json` | -| Gemini CLI | `.gemini/agents/trellis-*.md` | -| Qoder | `.qoder/agents/trellis-*.md` | -| CodeBuddy | `.codebuddy/agents/trellis-*.md` | -| Factory Droid | `.factory/droids/trellis-*.md` | -| Pi Agent | `.pi/agents/trellis-*.md` | -| Reasonix | `.reasonix/skills/trellis-*/SKILL.md` (subagent frontmatter) | -| ZCode | `.zcode/agents/trellis-*.md` | -| Kimi Code | `.kimi-code/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. - -Main-session workflow platforms such as Kilo, Antigravity, and Devin may not have Trellis sub-agent files. They usually rely on workflows/skills to guide the main session. - -## Two Context Loading Modes - -### hook push - -The platform hook injects task context before the agent starts. The agent file itself can focus more on responsibilities and boundaries. - -Common on platforms that support agent hooks. - -### agent pull - -The agent file instructs the agent to read after startup: - -- `python3 ./.trellis/scripts/task.py current --source` -- `implement.jsonl` or `check.jsonl` -- spec/research files referenced by JSONL -- current task `prd.md` -- `design.md` if present -- `implement.md` if present - -This mode fits platforms whose hooks cannot reliably rewrite sub-agent prompts. - -## Local Change Scenarios - -| User need | Edit location | -| --- | --- | -| Implement agent must follow extra restrictions | The platform's `trellis-implement` agent file. | -| Check agent must run project-specific commands | `trellis-check` agent file, and `.trellis/spec/` if needed. | -| Research agent must output a fixed format | `trellis-research` agent file. | -| Agent cannot read task context | Agent prelude or `inject-subagent-context` hook. | -| Add a project-specific agent | Platform agent directory + related workflow/command/skill entry point. | - -## Modification Principles - -1. **Keep responsibilities single-purpose**. Do not mix research, implement, and check responsibilities into one agent. -2. **Specify the read order**. Agents must know to start from the active task, read jsonl/spec context, then read `prd.md`, `design.md` if present, and `implement.md` if present. -3. **Specify write boundaries**. Research usually only writes `research/`; implement can write code; check can fix issues. -4. **Keep semantics synchronized in multi-platform projects**. If the user configured Claude, Codex, and Cursor together, decide whether changes to one platform's agent also need to be applied to others. - -## Do Not Default To Editing Upstream Templates - -Local AI should default to modifying platform agent files inside the user project. Discuss upstream template source only when the user explicitly wants to contribute the change back to Trellis. diff --git a/.agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md b/.agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md deleted file mode 100644 index 85f7f11..0000000 --- a/.agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md +++ /dev/null @@ -1,72 +0,0 @@ -# Hooks And Settings - -Hooks/settings are the entry layer that connects a platform to Trellis. They decide which scripts, plugins, or extensions a platform runs for which events. - -## Settings Responsibilities - -settings/config files usually register: - -- session-start hook: injects a Trellis overview when a new session starts or context resets. -- workflow-state hook: parses `[workflow-state:STATUS]` blocks from `.trellis/workflow.md` and emits the body matching the current task `status` on each user input. Parser-only; the script does not embed fallback content. -- sub-agent context hook: injects task context when implementation/check/research agents start. -- shell/session bridge: lets shell commands see the same Trellis session identity. -- platform plugin or extension entry points. - -Common files: - -| Platform | settings/config | -| --- | --- | -| Claude Code | `.claude/settings.json` | -| Cursor | `.cursor/hooks.json` | -| Codex | `.codex/hooks.json`, `.codex/config.toml` | -| OpenCode | `.opencode/package.json`, `.opencode/plugins/*` | -| Kiro | `.kiro/hooks/` + platform config | -| Gemini CLI | `.gemini/settings.json` | -| Qoder | `.qoder/settings.json` | -| CodeBuddy | `.codebuddy/settings.json` | -| GitHub Copilot | `.github/copilot/hooks.json` | -| Factory Droid | `.factory/settings.json` | -| Pi Agent | `.pi/settings.json`, `.pi/extensions/trellis/` | -| Trae IDE | `.trae/hooks.json` | - -Reasonix is a pull-based platform whose agent files contain prelude instructions to read context after startup. ZCode uses `.zcode/config.json` with shared hooks, including PreToolUse for sub-agent prompt injection. Kimi Code is likewise pull-based and has no project-level settings/hooks file Trellis writes (hooks live only in the user-level `~/.kimi-code/config.toml`), so its agent prompts ship as skills 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. - -## Hook Script Types - -| Script | Purpose | -| --- | --- | -| `session-start.py` | Generates session-start context. | -| `inject-workflow-state.py` | Parses `[workflow-state:STATUS]` blocks in `.trellis/workflow.md` and emits the body matching the current task status. Falls back to `Refer to workflow.md for current step.` when no matching block exists. | -| `inject-subagent-context.py` | Injects PRD, JSONL context, and related spec/research into sub-agents. | -| `inject-shell-session-context.py` | Lets shell commands inherit Trellis session identity. | - -Not every platform has every hook. Do not copy files from another platform just because a platform lacks a hook; first confirm whether that platform supports the corresponding event. - -## Local Change Scenarios - -| User need | Edit location | -| --- | --- | -| AI should see more/less context in a new session | Platform `session-start` hook. | -| Per-turn hint policy should change | `[workflow-state:STATUS]` block in `.trellis/workflow.md`. The hook parses workflow.md verbatim — no script edit required. | -| Sub-agent cannot read PRD/spec | `inject-subagent-context` hook or agent prelude. | -| `task.py current` in shell has no active task | Shell/session bridge hook or platform environment variable configuration. | -| Disable an automatic injection | The corresponding hook registration in settings/config. | - -## Modification Principles - -1. **Settings wire things up; hooks define behavior**. If only the hook changes, the platform may never call it. If only settings change, behavior may not change. -2. **Confirm platform event names first**. Different platforms use different names for SessionStart, UserPromptSubmit, AgentSpawn, shell execution, and similar events. -3. **Hooks read local `.trellis/`, not upstream source**. `.trellis/scripts/` and `.trellis/workflow.md` in the user project are the default targets. -4. **Errors must be visible**. Hook failures should tell the user what was not injected instead of silently leaving the AI without context. - -## Troubleshooting Path - -If the user says "AI did not read Trellis state": - -1. Check whether the platform settings register the hook. -2. Check whether the hook file exists. -3. Manually run the `.trellis/scripts/get_context.py` or `task.py current --source` command that the hook depends on. -4. Check whether active task state exists in `.trellis/.runtime/sessions/`. -5. Check whether the platform shell passes session identity. diff --git a/.agents/skills/trellis-meta/references/platform-files/overview.md b/.agents/skills/trellis-meta/references/platform-files/overview.md deleted file mode 100644 index f9b72b0..0000000 --- a/.agents/skills/trellis-meta/references/platform-files/overview.md +++ /dev/null @@ -1,59 +0,0 @@ -# Platform Files Overview - -Trellis connects the same local architecture to different AI tools. `.trellis/` stores the shared runtime; platform directories store adapter files that define how each AI tool enters Trellis. - -When a local AI modifies Trellis, it should distinguish two file categories first: - -- **Shared files**: `.trellis/workflow.md`, `.trellis/tasks/`, `.trellis/spec/`, `.trellis/scripts/`. -- **Platform files**: `.claude/`, `.snow/`, `.codex/`, `.cursor/`, `.opencode/`, `.kiro/`, `.gemini/`, `.qoder/`, `.codebuddy/`, `.github/`, `.factory/`, `.pi/`, `.trae/`, `.kilocode/`, `.agent/`, `.devin/`, `.reasonix/`, `.zcode/`, `.kimi-code/`, and similar directories. - -Platform files do not store business state. They let the corresponding AI tool read Trellis state, call Trellis scripts, and load Trellis skills/agents/hooks. - -## Platform File Categories - -| Category | Common paths | Purpose | -| --- | --- | --- | -| settings/config | `.claude/settings.json`, `.codex/hooks.json`, `.qoder/settings.json`, `.trae/hooks.json` | Register hooks, plugins, extensions, or platform behavior. | -| hooks/plugins/extensions | `.claude/hooks/`, `.opencode/plugins/`, `.pi/extensions/` | Inject context at session start, user input, agent startup, shell execution, and similar events. | -| agents | `.claude/agents/`, `.codex/agents/`, `.kiro/agents/`, `.zcode/agents/` | Define `trellis-research`, `trellis-implement`, and `trellis-check`. | -| skills | `.claude/skills/`, `.agents/skills/`, `.qoder/skills/`, `.zcode/skills/` | Capability descriptions that auto-trigger or can be read on demand. | -| commands/prompts/workflows | `.cursor/commands/`, `.github/prompts/`, `.devin/workflows/`, `.zcode/commands/` | Entry points explicitly invoked by the user. | - -## Three Platform Integration Modes - -### 1. Hook / Extension Driven - -These platforms can trigger scripts or plugins on specific events and actively inject Trellis context into AI. - -Common capabilities: - -- session-start injection of a `.trellis/` overview. -- workflow-state hints for each user turn. -- PRD/spec/research injection when sub-agents start. -- Shell commands inheriting session identity. - -To change "when the AI knows what," inspect hooks/plugins/extensions and settings first. - -### 2. Agent Prelude / Pull-Based - -Some platforms cannot reliably let hooks rewrite sub-agent prompts, so the agent file itself instructs the agent to read the active task, PRD, and JSONL context after startup. - -To change how sub-agents load context, inspect the agent files themselves. - -### 3. Main-Session Workflow - -Some platforms do not have Trellis sub-agent or hook capabilities. They rely on workflows/skills/commands to guide the main-session AI to read files, run scripts, and move tasks forward. - -To change behavior, inspect platform workflows/skills/commands and `.trellis/workflow.md`. - -## Local Modification Order - -When the user asks to customize behavior for a platform, the AI should inspect files in this order: - -1. Read `.trellis/workflow.md` to confirm the shared flow. -2. Read the target platform's settings/config to see which hooks/agents/skills/commands are registered. -3. Read the target platform's agents/skills/commands/hooks. -4. Modify the local file closest to the user's need. -5. If the change affects the shared flow, synchronize `.trellis/workflow.md` or `.trellis/spec/`. - -Do not modify only platform files and forget the shared workflow. Do not modify only `.trellis/workflow.md` and forget that platform entry points may still contain old descriptions. diff --git a/.agents/skills/trellis-meta/references/platform-files/platform-map.md b/.agents/skills/trellis-meta/references/platform-files/platform-map.md deleted file mode 100644 index f66e131..0000000 --- a/.agents/skills/trellis-meta/references/platform-files/platform-map.md +++ /dev/null @@ -1,112 +0,0 @@ -# Platform File Map - -This page lists common Trellis file locations in a user project by platform. Whether a platform directory exists in an actual project depends on which `trellis init --<platform>` commands the user ran. - -## Matrix - -| Platform | CLI flag | Main directory | Skill directory | Agent directory | Hooks/extensions | -| --- | --- | --- | --- | --- | --- | -| Claude Code | `--claude` | `.claude/` | `.claude/skills/` | `.claude/agents/` | `.claude/hooks/` + `.claude/settings.json` | -| Cursor | `--cursor` | `.cursor/` | `.cursor/skills/` | `.cursor/agents/` | `.cursor/hooks.json` + `.cursor/hooks/` | -| OpenCode | `--opencode` | `.opencode/` | `.opencode/skills/` | `.opencode/agents/` | `.opencode/plugins/` | -| Codex | `--codex` | `.codex/` | `.agents/skills/` | `.codex/agents/` | `.codex/hooks/` + `.codex/hooks.json` | -| Kilo | `--kilo` | `.kilocode/` | `.kilocode/skills/` | Usually none | `.kilocode/workflows/` | -| Kiro | `--kiro` | `.kiro/` | `.kiro/skills/` | `.kiro/agents/` | `.kiro/hooks/` | -| Gemini CLI | `--gemini` | `.gemini/` | `.agents/skills/` | `.gemini/agents/` | `.gemini/settings.json` + `.gemini/hooks/` | -| Antigravity | `--antigravity` | `.agent/` | `.agent/skills/` | Usually none | `.agent/workflows/` | -| Devin | `--devin` | `.devin/` | `.devin/skills/` | Usually none | `.devin/workflows/` | -| Qoder | `--qoder` | `.qoder/` | `.qoder/skills/` | `.qoder/agents/` | `.qoder/hooks/` + `.qoder/settings.json` | -| CodeBuddy | `--codebuddy` | `.codebuddy/` | `.codebuddy/skills/` | `.codebuddy/agents/` | `.codebuddy/hooks/` + `.codebuddy/settings.json` | -| GitHub Copilot | `--copilot` | `.github/` | `.github/skills/` | `.github/agents/` | `.github/copilot/hooks/` + prompts | -| Factory Droid | `--droid` | `.factory/` | `.factory/skills/` | `.factory/droids/` | `.factory/hooks/` + settings | -| 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/` | `.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 - -### Trellis Sub-Agent Support - -These platforms usually have `trellis-research`, `trellis-implement`, and `trellis-check` files: - -- Claude Code -- Cursor -- OpenCode -- Codex -- Kiro -- Gemini CLI -- Qoder -- CodeBuddy -- GitHub Copilot -- Factory Droid -- Pi Agent -- Trae IDE -- Reasonix (delivered as skills with `runAs: subagent` under `.reasonix/skills/`, not as a separate `agents/` directory) -- ZCode -- Grok Build (`.grok/agents/`; dispatch via `spawn_subagent` with `subagent_type`) -- Kimi Code (`.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. - -### Native Trellis Sub-Agent Tool - -Some platforms expose a first-class tool that the host runtime understands. The model calls it like any other tool and the host renders progress cards, validates the agent name against `.<platform>/agents/`, and enforces dispatch modes. - -- Pi Agent — `trellis_subagent` tool, defined in `.pi/extensions/trellis/index.ts`. Supports `single` / `parallel` / `chain` dispatch modes and emits live `trellis-subagent-progress` events. - -When changing sub-agent dispatch behavior on these platforms, edit the extension file, **not** the agent markdown — the agent markdown defines responsibilities, but the host extension owns dispatch, validation, and progress rendering. - -### Main-Session Workflow Platforms - -These platforms rely more on workflows/skills to guide the main session: - -- Kilo -- Antigravity -- Devin - -When changing behavior, inspect workflows and skills first. Do not assume Trellis sub-agents exist. - -### Shared `.agents/skills/` - -Codex, Gemini CLI, Pi Agent, 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 - -1. User specified a platform: modify only that platform directory unless shared workflow/spec files must also change. -2. User says "all platforms should do this": synchronize equivalent entry points platform by platform; do not modify only one directory. -3. User only says "my AI": inspect the configuration directories that actually exist in the project and infer the current AI platform. -4. User wants project rules: prefer `.trellis/spec/` or a project-local skill. -5. User wants Trellis behavior: edit `.trellis/workflow.md` plus platform hooks/agents/skills/commands. - -## When Paths Differ - -Platform ecosystems change, and user projects may already be customized. If this table disagrees with local files, use the actual settings/config in the user project as authoritative: - -- Check the hook that settings registers. -- Check the script that a command/prompt/workflow points to. -- Judge behavior by the read rules currently written in the agent file. - -Do not delete a custom file just because it is not listed in this path table. - -### `.omp/` — Oh My Pi (OMP) - -Extension-backed platform. OMP native provider auto-discovers all subdirectories. - -```text -.omp/ -├── commands/ # Slash commands (flat .md) -├── skills/ # Auto-triggered skills (SKILL.md per dir) -├── agents/ # Agent definitions (.md) -└── extensions/ - └── trellis/ - └── index.ts # Trellis extension (context injection) -``` - -No `settings.json` — OMP scans `.omp/` subdirectories automatically. -No Python hooks — hook-equivalent behavior lives in the TypeScript extension. diff --git a/.agents/skills/trellis-meta/references/platform-files/skills-and-commands.md b/.agents/skills/trellis-meta/references/platform-files/skills-and-commands.md deleted file mode 100644 index 89f15ce..0000000 --- a/.agents/skills/trellis-meta/references/platform-files/skills-and-commands.md +++ /dev/null @@ -1,86 +0,0 @@ -# Skills, Commands, Prompts, And Workflows - -Skills and commands are textual entry points for user interaction with Trellis. Different platforms use different names, but their core purpose is the same: tell the AI how to enter the Trellis flow when the user expresses a certain intent. - -## Conceptual Differences - -| Type | Trigger mode | Best for | -| --- | --- | --- | -| skill | AI auto-match or explicit user mention | Long-term capabilities, workflow rules, modification guides. | -| command | Explicit user invocation | Clear operation entry points such as continue and finish-work. | -| prompt | Explicit user invocation or platform selection | Similar to command, but in a platform prompt format. | -| workflow | Explicit user selection or platform auto-match | Guides the main session when no sub-agent/hook exists. | - -Trellis workflow skills usually share one semantic set: brainstorm, before-dev, check, update-spec, break-loop. Multi-file built-in skills such as `trellis-meta` use layered references. - -## Common Paths - -| Platform | Common entries | -| --- | --- | -| Claude Code | `.claude/skills/`, `.claude/commands/` | -| Cursor | `.cursor/skills/`, `.cursor/commands/` | -| OpenCode | `.opencode/skills/`, `.opencode/commands/` | -| Codex | `.agents/skills/`, `.codex/skills/` | -| Kilo | `.kilocode/skills/`, `.kilocode/workflows/` | -| Kiro | `.kiro/skills/` | -| Gemini CLI | `.agents/skills/`, `.gemini/commands/` | -| Antigravity | `.agent/skills/`, `.agent/workflows/` | -| Devin | `.devin/skills/`, `.devin/workflows/` | -| Qoder | `.qoder/skills/`, `.qoder/commands/` | -| CodeBuddy | `.codebuddy/skills/`, `.codebuddy/commands/` | -| GitHub Copilot | `.github/skills/`, `.github/prompts/` | -| Factory Droid | `.factory/skills/`, `.factory/commands/` | -| Pi Agent | `.agents/skills/` | -| Reasonix | `.reasonix/skills/` | -| ZCode | `.zcode/skills/`, `.zcode/commands/` | -| Kimi Code | `.agents/skills/`, `.kimi-code/skills/` (commands delivered as `/skill:trellis-*` skills) | - -In a user project, use the files actually generated by init as authoritative. - -## Skill Structure - -A common skill is a directory: - -```text -trellis-meta/ -├── SKILL.md -└── references/ -``` - -`SKILL.md` should tell the AI: - -- When to use this skill. -- Which reference to read first for the current task. -- What not to do. - -References hold longer explanations so the entry file does not contain everything. - -## Command/Prompt/Workflow Structure - -Commands, prompts, and workflows are usually single files. Their content should include: - -- When to use it. -- Which `.trellis/` files to read. -- Which scripts to run. -- How to report after completion. - -They should not store task state; task state belongs in `.trellis/tasks/` and `.trellis/.runtime/`. - -## Local Change Scenarios - -| User need | Edit location | -| --- | --- | -| Change AI auto-trigger rules | The corresponding skill's frontmatter description. | -| Change user command behavior | The corresponding command/prompt/workflow file. | -| Add a project-local skill | Platform skill directory, or shared `.agents/skills/`. | -| Let multiple platforms share one capability | Write equivalent skills in each platform skill directory, or use the `.agents/skills/` shared layer on platforms that support it. | -| Change finish/continue entry points | Platform commands/prompts/workflows. | - -## Modification Principles - -1. **Keep entry files short; references carry long content**. This matters especially for multi-file skills like `trellis-meta`. -2. **Make trigger descriptions specific**. A description that is too broad can mis-trigger; one that is too narrow may not trigger. -3. **Keep the same semantics consistent across platforms**. File formats can differ, but behavior descriptions should match. -4. **Put project-specific capabilities in local skills**. Do not put team-private flows into public `trellis-meta`. - -If the user only wants local AI to know one more project rule, usually create a project-local skill or update `.trellis/spec/` instead of changing a Trellis built-in workflow skill. diff --git a/.agents/skills/trellis-session-insight/SKILL.md b/.agents/skills/trellis-session-insight/SKILL.md deleted file mode 100644 index 1d9f4ed..0000000 --- a/.agents/skills/trellis-session-insight/SKILL.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -name: trellis-session-insight -description: "Reach into past AI conversation history through the `trellis mem` CLI. Use whenever the user asks 'how did we solve X last time', 'have we discussed this before', 'what was the decision on X', 'remind me what we did in this task', '上次怎么解的', '之前讨论过吗', '想起一段对话', or when starting a brainstorm that overlaps prior work, debugging a familiar bug, continuing a task across sessions, or doing a finish-work review. Returns raw past dialogue; decide for the moment whether to update spec, append to task notes, quote inline in the answer, or just internalize." ---- - -# Trellis Session Insight - -This skill teaches an AI **how to call `trellis mem`** — the project's cross-session memory feedstock — and **when reaching for it is the right move**. - -It is intentionally a **capability skill, not a workflow**. There is no fixed output file, no required write-back step, no "always run after finish-work" rule. What to do with what `mem` returns is a judgement call made in the moment of the conversation. The skill exists so the AI knows the capability is there and can decide. - -## What `trellis mem` is - -A local CLI that indexes the user's past Claude Code, Codex, Pi Agent, and ZCode conversation logs and lets you list, search, slice by Trellis task boundaries, and dump cleaned dialogue from them. Claude and Codex use `~/.claude/projects/` and `~/.codex/sessions/`. Pi uses its default or environment-configured session root, global `~/.pi/agent/settings.json`, and the scoped project's `.pi/settings.json`; relative `sessionDir` values resolve from the settings file directory. Project-local Pi settings require project-scoped lookup through the current cwd or `--cwd`. ZCode uses `~/.zcode/cli/db/db.sqlite`. OpenCode logs are not yet indexable (provider adapter pending) — when an OpenCode session is the obvious target, surface that limitation rather than guessing. - -Nothing in `mem` is uploaded. All reads are local. - -## When to reach for it - -The bar is "would a senior teammate ask 'didn't we already talk about this?'" — those are the moments. Some concrete patterns: - -- **Brainstorm rerun risk.** Starting a new task that touches an area the user has been in before, and you want to check whether a decision was already made — before re-asking the user. -- **Familiar-bug debugging.** The current bug pattern feels like one the user reported / fixed before. Pulling the relevant past session can save a full debugging loop. -- **Cross-session continuation.** The user resumes work after a gap and says "where were we" / "继续上次的" without being specific. -- **Decision retrieval.** The user references "the decision we made about X" but the decision lives in an old brainstorm, not in any `prd.md` / `spec/`. -- **Finish-work retrospective.** When the user explicitly asks for a wrap-up of what was decided / what hurt / what surprised them in this task — not as a forced step on every finish-work. -- **Pattern-spotting across past work.** The user asks "do I keep making the same mistake on X" / "我每次都踩这个坑吗" — search across sessions answers that. - -If none of these apply, don't call `mem`. It is a tool, not a ceremony. - -## When NOT to reach for it - -- The relevant context is already in the current turn, `prd.md`, `design.md`, recent `git log`, or the open files. `mem` is for stuff that has fallen out of immediate reach. -- The user is asking about a fact in the code, not a fact from a past conversation. `git log -p` / `grep` / reading the file directly is faster and more authoritative. -- You are in a sub-agent (`trellis-implement` / `trellis-check`) whose dispatch prompt already includes the curated `implement.jsonl` / `check.jsonl` context. Adding `mem` on top usually just clutters. -- The user has explicitly said "don't dig through history, just answer what I asked". - -## What to do with what `mem` returns - -Treat the output as **raw material**, not a deliverable. Once you have it, decide based on the live conversation: - -- **Quote inline in your reply** if a specific past exchange answers the user's current question — and cite the session-id / phase so the user can verify. -- **Update `<task>/prd.md` or `<task>/design.md`** if `mem` surfaced a load-bearing decision that should have been written down but wasn't. Surface the proposed edit to the user first. -- **Append to a task-local notes file** (e.g. `<task>/notes.md` or extending an existing one) if the finding belongs to the current task's record but doesn't fit the PRD. -- **Update `.trellis/spec/`** if the finding is a project-wide convention or gotcha that would help future tasks. Run the `trellis-update-spec` skill for that — `session-insight` ends at the discovery. -- **Just absorb it** for the next few turns and answer better, without writing anything. This is often the right move for one-off recall. - -Trellis does not prescribe a single destination. Forcing every recall into a fixed file makes the file grow into noise. Let the situation decide. - -## How to call it - -Full CLI reference is in `references/cli-quick-reference.md`. The 80% case is one of: - -```bash -# Find sessions whose contents mention a keyword (project-scope is default; -# add --global to search every project on this machine). -trellis mem search "<keyword>" - -# Dump dialogue from one session, optionally filtered by phase or keyword. -trellis mem extract <session-id> --phase brainstorm -trellis mem extract <session-id> --grep "<keyword>" - -# Drill into a session: top-N hit turns + surrounding context. -trellis mem context <session-id> --turns 3 --around 2 - -# When you do not know the session id yet, start with list + filter. -trellis mem list --cwd <project-path> -trellis mem projects # → list active project cwds, then narrow -``` - -Phase slicing (`--phase brainstorm|implement|all`) cuts the session at `task.py create` and `task.py start` boundaries. For a finish-work review of the current task, `--phase brainstorm` recovers the planning discussion and `--phase implement` recovers the execution loop. Default is `all`. - -## Triggering patterns - -`references/triggering-patterns.md` lists more verbatim user phrasings (English + Chinese) that should make you think "reach for `mem`" — keep that handy when training instinct. - -## Out of scope - -- `mem` does not edit code or update files. Any write-back is your decision in the moment. -- `mem` is read-only on the platform JSONL stores. It does not push or sync to remote. -- This skill does not replace `trellis-update-spec` (which is the right tool for promoting a finding into project-wide guidance) or the platform-native task / spec workflow. diff --git a/.agents/skills/trellis-session-insight/references/cli-quick-reference.md b/.agents/skills/trellis-session-insight/references/cli-quick-reference.md deleted file mode 100644 index 78540f2..0000000 --- a/.agents/skills/trellis-session-insight/references/cli-quick-reference.md +++ /dev/null @@ -1,65 +0,0 @@ -# `trellis mem` CLI Reference - -Full flag reference for the five subcommands. Pin this as the authoritative source — `trellis mem help` prints the same content at runtime, so anything here that drifts is a bug. - -## Subcommands - -| Command | Purpose | -| ---------------------- | ---------------------------------------------------------------------------------------------------------------------- | -| `list` | List sessions. Default subcommand when none is given. | -| `search <keyword>` | Find sessions whose contents match a keyword. | -| `context <session-id>` | Drill into one session: top-N hit turns + surrounding context. Pair with `--grep` for keyword anchoring. | -| `extract <session-id>` | Dump cleaned dialogue. Combine with `--phase` / `--grep` to slice. | -| `projects` | List active project `cwd` values with session counts. Use this to discover which `--cwd` to pass to other subcommands. | - -## Flags (apply where meaningful) - -| Flag | Subcommands | Meaning | -| --------------------------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--platform claude\|codex\|opencode\|pi\|all` | all | Default `all`. OpenCode adapter is currently a stub on `0.6.0-beta.*` — see "Caveats" below. | -| `--since YYYY-MM-DD` | list / search | Inclusive lower date bound. | -| `--until YYYY-MM-DD` | list / search | Inclusive upper date bound. | -| `--global` | list / search | Include sessions from every project on this machine. Default is the current project `cwd`. | -| `--cwd <path>` | list / search | Force a specific project cwd instead of inferring from where you are. | -| `--limit N` | list / search | Cap output rows. Default `50`. | -| `--grep KW` | extract / context | Filter turns by keyword. Multi-token AND when whitespace-separated. | -| `--phase brainstorm\|implement\|all` | extract | Slice session by Trellis task boundaries. `brainstorm` = `[task.py create, task.py start)`. `implement` = turns outside brainstorm windows. Default `all`. | -| `--turns N` | context | Number of hit turns to return. Default `3`. | -| `--around N` | context | Surrounding turns to include per hit. Default `1`. | -| `--max-chars N` | context | Total character budget. Default `6000` (~1500 tokens). | -| `--include-children` | search / context | Merge OpenCode sub-agent sessions into their parent session. | -| `--json` | all | Emit machine-parseable JSON instead of human-readable output. | - -## Common one-liners - -```bash -# What past sessions discussed "deadlock" anywhere on this machine? -trellis mem search "deadlock" --global --limit 20 - -# Inside a specific session, surface the top 5 turns that mention "lock contention" -# plus 2 turns of surrounding context. -trellis mem context 5842592d --grep "lock contention" --turns 5 --around 2 - -# Recover the brainstorm window for a session — useful when continuing a task -# the user started a week ago. -trellis mem extract 5842592d --phase brainstorm - -# List every project this machine has Trellis sessions for, with counts. -trellis mem projects -``` - -## Output shapes - -- **Default human output** (no `--json`): wrapped to a terminal, with session ids highlighted and turn markers visible. Suitable to read inline but messy to paste into a markdown file. -- **`--json`**: stable schema, safe to parse and process. When piping `mem` output into a follow-up step (e.g. summarizing for a Lessons section), prefer `--json`. - -## Caveats - -- **OpenCode adapter is a stub on `0.6.0-beta.*`.** When `--platform` resolves to OpenCode (or `all` and OpenCode would be included), `mem` prints a one-line "reader unavailable" notice and continues with the other platforms. Don't promise OpenCode coverage in your reply until the adapter ships. -- **`--phase` slicing depends on `task.py create` / `task.py start` invocations appearing in the recorded bash calls of the session.** Sessions where the user ran `task.py` from a different terminal — outside the recorded AI loop — will not have phase boundaries. `--phase all` is the safe fallback. -- **`mem` indexes platform JSONL files directly.** If the user has cleared their Claude / Codex / Pi session storage, `mem` cannot recover what is no longer on disk. -- **`mem` is read-only.** No remote sync, no edits to platform JSONL. Any write you do based on `mem` findings is your own follow-up call into the editing tools available to you. - -## When you need more than this reference - -Run `trellis mem help` in the user's shell. The runtime help is authoritative and will be ahead of this reference during fast-moving beta releases. diff --git a/.agents/skills/trellis-session-insight/references/triggering-patterns.md b/.agents/skills/trellis-session-insight/references/triggering-patterns.md deleted file mode 100644 index 66021ca..0000000 --- a/.agents/skills/trellis-session-insight/references/triggering-patterns.md +++ /dev/null @@ -1,93 +0,0 @@ -# Triggering Patterns - -Verbatim user phrasings that should make an AI reach for `trellis mem`. Calibrate instinct against these — if a user message hits one of these patterns and you do not reach for `mem`, you probably missed an obvious recall. - -Patterns are grouped by the *intent* behind the phrasing, not the surface words. The same intent shows up in different languages and registers. - -## Past-solution recall - -The user is asking "how did we (or I) solve this before". Past dialogue holds the answer; the codebase shows the result but not the reasoning. - -- "How did we solve this last time?" -- "What did we end up doing about X?" -- "We dealt with this once already, didn't we?" -- "上次怎么解的?" -- "之前是怎么搞定 X 的?" -- "我记得以前修过类似的" - -Reach: `trellis mem search "<symptom keyword>" --global --limit 10`, then `context` into the hit that looks closest. - -## Decision retrieval - -The user is referencing a decision that lives in old dialogue, not in any committed file. Look in brainstorm windows. - -- "What was the decision on X?" -- "Did we decide to use Postgres or SQLite?" -- "The rationale for choosing X over Y was…?" -- "我们当时为啥选了 X 而不是 Y?" -- "关于 X 我们之前是怎么定的?" -- "之前讨论过 X 的方案吗?" - -Reach: `trellis mem search "<decision keyword>"` to find the session, then `extract <id> --phase brainstorm` to recover the discussion. - -## Cross-session continuation - -The user resumed work after a gap and the context is implicit. - -- "Where were we?" -- "Continue from last time." -- "Pick up where we left off." -- "继续上次的" -- "我们上次做到哪了" -- "接着昨天那个任务" - -Reach: `trellis mem list --task <current-task-dir>` to find the most recent sessions tied to the active task, then `extract` the last one. - -## Familiar-bug debugging - -The current bug feels like one already seen. Past sessions probably hold the resolution path. - -- "I feel like I've hit this before." -- "Doesn't this look like that bug from last month?" -- "Same kind of timeout I had in X." -- "这个错好像之前见过" -- "这个 bug 是不是上次那个?" -- "怎么又是这个 error?" - -Reach: `trellis mem search "<error message fragment>" --global`. Anchor on a short, distinctive token from the actual error string. - -## Self-pattern spotting - -The user is asking whether they keep repeating the same kind of mistake or decision. - -- "Do I always make this mistake?" -- "How often have I run into X?" -- "Is this a recurring thing for me?" -- "我每次都踩这个坑吗?" -- "我老犯这个错?" -- "这类问题之前出现过几次?" - -Reach: `trellis mem search "<topic>" --global --limit 50` and scan the dates / projects in the listing. Optionally `extract` two or three for comparison. - -## Finish-work retrospective (on demand) - -The user explicitly wants to look back at this task — not as a forced step, only when they ask. - -- "Summarize what we did in this task." -- "What were the key decisions / surprises?" -- "Write up the lessons from this round." -- "总结一下这次的经验" -- "记一下这次踩的坑" -- "复盘下这个任务" - -Reach: identify the current task's session id (from `.trellis/.runtime/sessions/*.json` or `mem list --task <task-dir>`), then `extract <id> --phase brainstorm` and `--phase implement`. Present a summary — surface concrete file:line citations where possible. Whether to also write the summary somewhere (PRD, spec, notes file) is the user's call; offer, don't auto-write. - -## Anti-patterns: do NOT reach for `mem` here - -- "What does this function do?" → read the file. -- "Why is this test failing?" → read the test output and the file. -- "What's the right pattern for X in our codebase?" → grep / read spec files. -- "What's the latest npm version of Y?" → call `npm view`. -- "Fix this bug." → debug. Reach for `mem` only if you suspect prior context exists; otherwise it is noise. - -The bar stays: would a senior teammate ask "didn't we already talk about this?" before answering? If yes, reach for `mem`. If no, don't. diff --git a/.agents/skills/trellis-spec-bootstrap/SKILL.md b/.agents/skills/trellis-spec-bootstrap/SKILL.md deleted file mode 100644 index e1650df..0000000 --- a/.agents/skills/trellis-spec-bootstrap/SKILL.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -name: trellis-spec-bootstrap -description: "Bootstrap project-specific Trellis coding specs with a platform-neutral single-agent workflow. Use when creating or refreshing .trellis/spec guidelines, analyzing a codebase with GitNexus, ABCoder, or source inspection, decomposing package/layer spec work, and writing real codebase-backed spec docs without placeholder text." ---- - -# Trellis Spec Bootstrap - -Use this skill to create or refresh `.trellis/spec/` guidelines from the real codebase. One capable agent owns the full loop: analyze the repository, choose the spec boundaries, write the docs, and verify the result. The workflow does not depend on a specific host, CLI, or agent brand. - -## Workflow - -1. Confirm Trellis is initialized and inspect the current `.trellis/spec/` tree. -2. Analyze the repository architecture with the best available tools: GitNexus, ABCoder, language tooling, and direct source reads. -3. Decompose the spec work by package and layer only when that reflects the actual codebase. -4. Fill or reshape the spec files with concrete patterns, file paths, examples, and anti-patterns from the project. -5. Verify that the final specs are internally consistent and contain no template placeholders. - -## Reference Routing - -| Need | Read | -|------|------| -| Repository architecture analysis | [references/repository-analysis.md](references/repository-analysis.md) | -| Spec work decomposition and task planning | [references/spec-task-planning.md](references/spec-task-planning.md) | -| Writing high-signal Trellis spec files | [references/spec-writing.md](references/spec-writing.md) | -| GitNexus and ABCoder MCP setup | [references/mcp-setup.md](references/mcp-setup.md) | - -## Operating Rules - -- Treat templates as starting points, not contracts. Delete, rename, split, or add spec files when the repository calls for it. -- Prefer source-backed rules over generic advice. Every important recommendation should point at a real file or repeated local pattern. -- Keep execution single-owner by default. Optional helper agents are an implementation detail, not a requirement or user-visible dependency. -- Do not write platform-specific instructions unless the target project already standardizes on that platform. -- Do not leave placeholder text, empty headings, or copied boilerplate in `.trellis/spec/`. - -## Done Criteria - -- `.trellis/spec/` describes the project as it exists now. -- Each relevant package or layer has practical coding guidance with real examples. -- Non-applicable template sections are removed. -- `index.md` files match the final spec file set. -- Any required setup or analysis assumptions are documented in the relevant spec or task notes. diff --git a/.agents/skills/trellis-spec-bootstrap/references/mcp-setup.md b/.agents/skills/trellis-spec-bootstrap/references/mcp-setup.md deleted file mode 100644 index 629fcbd..0000000 --- a/.agents/skills/trellis-spec-bootstrap/references/mcp-setup.md +++ /dev/null @@ -1,90 +0,0 @@ -# MCP Setup - -GitNexus and ABCoder are recommended when bootstrapping Trellis specs because they expose architecture and AST context to the agent. They are tool choices, not platform requirements. Configure them through whatever MCP mechanism your agent host provides. - -## GitNexus - -GitNexus builds a code knowledge graph from the repository. Use it for module boundaries, execution flows, dependency relationships, blast radius, and graph queries. - -### Install and Index - -```bash -# Run from the repository root. -npx gitnexus analyze - -# Check index status. -npx gitnexus status - -# Re-index after code changes when the analysis is stale. -npx gitnexus analyze -``` - -The index is written to `.gitnexus/`. Keep embeddings only if the project already uses them; otherwise a normal index is enough for spec bootstrapping. - -### MCP Server Command - -Use this server command in the host's MCP configuration: - -```bash -npx -y gitnexus mcp -``` - -### Useful Tools - -| Tool | Purpose | -|------|---------| -| `gitnexus_query` | Find execution flows and functional areas by concept | -| `gitnexus_context` | Inspect callers, callees, references, and process participation for a symbol | -| `gitnexus_impact` | Understand blast radius before changing a symbol | -| `gitnexus_detect_changes` | Check changed symbols and affected flows before finishing | -| `gitnexus_cypher` | Run direct graph queries | -| `gitnexus_list_repos` | List indexed repositories | - -## ABCoder - -ABCoder parses code into UniAST and gives precise package, file, and node-level structure. Use it for signatures, type shapes, implementations, dependencies, and reverse references. - -### Install - -```bash -go install github.com/cloudwego/abcoder@latest -abcoder --help -``` - -### Parse Repositories - -```bash -abcoder parse /absolute/path/to/package \ - --lang typescript \ - --name package-name \ - --output ~/abcoder-asts -``` - -For monorepos, parse each package with a stable `--name` so task notes can reference the same repository names. - -### MCP Server Command - -Use this server command in the host's MCP configuration: - -```bash -abcoder mcp ~/abcoder-asts -``` - -### Useful Tools - -| Tool | Layer | Purpose | -|------|-------|---------| -| `list_repos` | 1 | List parsed repositories | -| `get_repo_structure` | 2 | Inspect packages and files | -| `get_package_structure` | 3 | Inspect nodes within a package | -| `get_file_structure` | 3 | Inspect functions, classes, types, and signatures in a file | -| `get_ast_node` | 4 | Retrieve code, dependencies, references, and implementations | - -## Verification - -After configuration, verify from the agent host that both MCP servers are visible. Then run one simple query against each server before starting the spec writing pass. - -```bash -ls .gitnexus/meta.json -ls ~/abcoder-asts/*.json -``` diff --git a/.agents/skills/trellis-spec-bootstrap/references/repository-analysis.md b/.agents/skills/trellis-spec-bootstrap/references/repository-analysis.md deleted file mode 100644 index 1309d29..0000000 --- a/.agents/skills/trellis-spec-bootstrap/references/repository-analysis.md +++ /dev/null @@ -1,59 +0,0 @@ -# Repository Analysis - -The goal is to discover the project's real architecture before writing rules. Do not start from generic spec templates and fill blanks. Start from the code, then let the spec structure follow. - -## Analysis Order - -1. Read the existing `.trellis/spec/` tree and note which files are templates, outdated, or already project-specific. -2. Inspect package manifests, build scripts, workspace config, and top-level documentation to identify packages and runtime layers. -3. Use GitNexus for execution flows, module clusters, dependency hubs, and impact-sensitive areas. -4. Use ABCoder or language-native tooling for exact signatures, types, class boundaries, and implementation examples. -5. Read representative source and test files directly before turning any finding into a spec rule. - -## What To Capture - -| Area | Questions | -|------|-----------| -| Package boundaries | What does each package own? What imports cross boundaries? | -| Runtime layers | Which code is CLI, backend, frontend, worker, shared library, test-only, or tooling? | -| Core abstractions | Which types, services, stores, commands, routes, or adapters define the system shape? | -| Data flow | Where does user input enter, how is it validated, and where does state persist? | -| Error handling | How are failures represented, logged, surfaced, and tested? | -| Configuration | Where do defaults, environment config, generated files, and templates live? | -| Tests | Which test styles are trusted examples for new work? | - -## GitNexus Usage - -Start broad, then inspect specific symbols: - -```text -gitnexus_query({query: "CLI command execution flow"}) -gitnexus_query({query: "template generation and migration"}) -gitnexus_context({name: "SymbolName"}) -gitnexus_cypher({query: "MATCH (n)-[r]->(m) RETURN n.name, type(r), m.name LIMIT 30"}) -``` - -Use GitNexus results to find important files and flows. Do not quote graph output as the final authority until you have checked the relevant source files. - -## ABCoder Usage - -Use ABCoder when the spec needs exact code shapes: - -```text -list_repos() -get_repo_structure({repo_name: "package-name"}) -get_file_structure({repo_name: "package-name", file_path: "src/example.ts"}) -get_ast_node({repo_name: "package-name", node_ids: [{mod_path: "...", pkg_path: "...", name: "SymbolName"}]}) -``` - -ABCoder is most valuable for documenting constructor patterns, function signatures, type contracts, and reference chains. - -## Analysis Notes - -Keep short notes while analyzing. The notes should include: - -- Package or layer name. -- Files that define the local pattern. -- Rules the spec should teach. -- Anti-patterns found in old code, comments, tests, or migration paths. -- Spec files that should be created, deleted, renamed, or merged. diff --git a/.agents/skills/trellis-spec-bootstrap/references/spec-task-planning.md b/.agents/skills/trellis-spec-bootstrap/references/spec-task-planning.md deleted file mode 100644 index dca2687..0000000 --- a/.agents/skills/trellis-spec-bootstrap/references/spec-task-planning.md +++ /dev/null @@ -1,61 +0,0 @@ -# Spec Task Planning - -Use a single agent as the default execution model. The agent may create Trellis tasks for traceability, but the skill should not require a specific platform, CLI, or parallel worker model. - -## Decomposition - -Create spec work units around real ownership boundaries: - -- One package when a package has its own conventions. -- One layer when the same package has distinct frontend, backend, CLI, worker, or shared-library rules. -- One cross-cutting guide when a pattern spans packages and is not owned by one layer. - -Avoid artificial decomposition. A small library usually needs one focused spec pass, not several tasks. - -## Task Shape - -When a Trellis task is useful, write a concise PRD with these sections: - -```markdown -# Fill <package-or-layer> Trellis Specs - -## Goal -Write project-specific `.trellis/spec/` guidance for <scope>. - -## Scope -- Spec directory: -- Source directories to inspect: -- Tests to inspect: -- Out of scope: - -## Architecture Context -Summarize the concrete findings from repository analysis. - -## Files To Create Or Update -- `.trellis/spec/.../index.md` -- `.trellis/spec/.../<topic>.md` - -## Rules -- Adapt the spec file set to the real codebase. -- Use real source examples with file paths. -- Remove template-only sections that do not apply. -- Do not modify product source code unless the task explicitly asks for it. - -## Acceptance Criteria -- [ ] Specs contain concrete examples and anti-patterns from the repository. -- [ ] No placeholder text remains. -- [ ] Index files match the final spec files. -- [ ] Claims are backed by source files, tests, or project docs. -``` - -## Optional Helper Agents - -If the host supports subagents, helpers can inspect independent packages or run verification. They are optional. The main agent still owns integration and final quality. - -Helper tasks must have clear ownership: - -- Read-only research tasks may inspect any source needed for the assigned scope. -- Write tasks should own disjoint spec directories. -- Verification tasks should check placeholder removal, broken links, and consistency. - -Do not encode helper-agent names, vendor-specific commands, or platform-specific routing in the skill. Put only the required work and acceptance criteria in the task. diff --git a/.agents/skills/trellis-spec-bootstrap/references/spec-writing.md b/.agents/skills/trellis-spec-bootstrap/references/spec-writing.md deleted file mode 100644 index 6bc7dec..0000000 --- a/.agents/skills/trellis-spec-bootstrap/references/spec-writing.md +++ /dev/null @@ -1,70 +0,0 @@ -# Spec Writing - -Trellis specs are coding guidance for future agents. They should explain how to work in this repository, not how a generic project might be organized. - -## Write From Evidence - -Each important rule should be backed by one of these: - -- A source file that demonstrates the preferred pattern. -- A test file that shows expected behavior. -- A project document that defines the convention. -- A repeated pattern across multiple files. - -Use short snippets only when they make the rule clearer. Prefer linking to the file path and naming the symbol or behavior. - -## File Structure - -Keep the spec tree aligned with the project: - -- Keep `index.md` as the navigation file for the spec directory. -- Split topics when developers would look for them independently. -- Merge topics when separate files would repeat the same rule. -- Delete template files that do not apply. -- Add new files for important local patterns the template missed. - -## Content Standards - -Good spec sections include: - -- When the rule applies. -- The local pattern to follow. -- The source or test files that prove the pattern. -- Common mistakes or anti-patterns. -- Verification commands or checks when they are specific and reliable. - -Avoid: - -- Placeholder prose. -- Generic framework advice. -- Tool instructions that only work in one agent host. -- Long copied code blocks. -- Rules based on a single accidental implementation detail. - -## Example Shape - -```markdown -## Command Handlers - -Command handlers should keep argument parsing, validation, and side effects separate. The local pattern is: - -- Parse CLI flags at the command boundary. -- Convert raw inputs into typed task options before invoking core logic. -- Keep filesystem writes in the command or service layer, not in template helpers. - -Reference files: -- `packages/cli/src/commands/example.ts` -- `packages/cli/test/commands/example.test.ts` - -Avoid passing raw `process.argv` or unvalidated config objects into shared helpers. -``` - -## Final Pass - -Before finishing: - -```bash -grep -R "To be filled\\|TODO: fill\\|placeholder" .trellis/spec -``` - -Also check links, index files, and whether any spec still describes a template rather than this repository. diff --git a/.agents/skills/trellis-start/SKILL.md b/.agents/skills/trellis-start/SKILL.md deleted file mode 100644 index 3c7980d..0000000 --- a/.agents/skills/trellis-start/SKILL.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -name: trellis-start -description: "Initializes an AI development session by reading workflow guides, developer identity, git status, active tasks, and project guidelines from .trellis/. Classifies incoming tasks and routes to brainstorm, direct edit, or task workflow. Use when beginning a new coding session, resuming work, starting a new task, or re-establishing project context." ---- - -# Start Session - -Initialize a Trellis-managed development session. This platform has no session-start hook, so manually load the equivalent compact context by following these steps. - ---- - -## Step 1: Current state -Identity, git status, current task, active tasks, journal location. - -```bash -python3 ./.trellis/scripts/get_context.py -``` - -If this output includes a line beginning `Trellis update available:`, copy the full line verbatim when summarizing session context. Do not shorten operational command hints. - -## Step 2: Workflow overview -Compact Phase Index, request triage rules, planning artifact contract, and the step-detail command. - -```bash -python3 ./.trellis/scripts/get_context.py --mode phase -``` - -Full guide in `.trellis/workflow.md` (read on demand). - -## Step 3: Guideline indexes -Discover packages + spec layers, then read each relevant index file. - -```bash -python3 ./.trellis/scripts/get_context.py --mode packages -cat .trellis/spec/guides/index.md -cat .trellis/spec/<package>/<layer>/index.md # for each relevant layer -``` - -Index files list the specific guideline docs to read when you actually start coding. - -## Step 4: Decide next action -From Step 1 you know the current task and status. Check the task directory: - -- **Active task status `planning` + no `prd.md`** → Phase 1.1. Load the `trellis-brainstorm` skill. -- **Active task status `planning` + `prd.md` exists** → stay in Phase 1. Lightweight tasks can be PRD-only; complex tasks need `design.md` + `implement.md`. Load the relevant Phase 1 step detail before `task.py start`. -- **Active task status `in_progress`** → Phase 2 step 2.1. Load the step detail: - ```bash - python3 ./.trellis/scripts/get_context.py --mode phase --step 2.1 --platform codex - ``` -- **No active task** → classify first. For simple conversation / small task, ask only whether this turn should create a Trellis task. For complex work, ask whether you may create a Trellis task and enter planning. If the user says no, skip Trellis for this session. - ---- - -## Skill routing (quick reference) - -| User intent | Skill | -|---|---| -| New feature / unclear requirements | `trellis-brainstorm` | -| About to write code | `trellis-before-dev` | -| Done coding / quality check | `trellis-check` | -| Stuck / fixed same bug multiple times | `trellis-break-loop` | -| Learned something worth capturing | `trellis-update-spec` | - -Full rules + anti-rationalization table in `.trellis/workflow.md`. diff --git a/.agents/skills/trellis-update-spec/SKILL.md b/.agents/skills/trellis-update-spec/SKILL.md deleted file mode 100644 index 81bad08..0000000 --- a/.agents/skills/trellis-update-spec/SKILL.md +++ /dev/null @@ -1,356 +0,0 @@ ---- -name: trellis-update-spec -description: "Captures executable contracts and coding conventions into .trellis/spec/ documents. Use when learning something valuable from debugging, implementing, or discussion that should be preserved for future sessions." ---- - -# Update Code-Spec - Capture Executable Contracts - -When you learn something valuable (from debugging, implementing, or discussion), use this to update the relevant code-spec documents. - -**Timing**: After completing a task, fixing a bug, or discovering a new pattern - ---- - -## Code-Spec First Rule (CRITICAL) - -In this project, "spec" for implementation work means **code-spec**: -- Executable contracts (not principle-only text) -- Concrete signatures, payload fields, env keys, and boundary behavior -- Testable validation/error behavior - -If the change touches infra or cross-layer contracts, code-spec depth is mandatory. - -### Mandatory Triggers - -Apply code-spec depth when the change includes any of: -- New/changed command or API signature -- Cross-layer request/response contract change -- Database schema/migration change -- Infra integration (storage, queue, cache, secrets, env wiring) - -### Mandatory Output (7 Sections) - -For triggered tasks, include all sections below: -1. Scope / Trigger -2. Signatures (command/API/DB) -3. Contracts (request/response/env) -4. Validation & Error Matrix -5. Good/Base/Bad Cases -6. Tests Required (with assertion points) -7. Wrong vs Correct (at least one pair) - ---- - -## When to Update Code-Specs - -| Trigger | Example | Target Spec | -|---------|---------|-------------| -| **Implemented a feature** | Added a new integration or module | Relevant spec file | -| **Made a design decision** | Chose extensibility pattern over simplicity | Relevant spec + "Design Decisions" section | -| **Fixed a bug** | Found a subtle issue with error handling | Relevant spec (e.g., error-handling docs) | -| **Discovered a pattern** | Found a better way to structure code | Relevant spec file | -| **Hit a gotcha** | Learned that X must be done before Y | Relevant spec + "Common Mistakes" section | -| **Established a convention** | Team agreed on naming pattern | Quality guidelines | -| **New thinking trigger** | "Don't forget to check X before doing Y" | `guides/*.md` (as a checklist item) | - -**Key Insight**: Code-spec updates are NOT just for problems. Every feature implementation contains design decisions and contracts that future AI/developers need to execute safely. - ---- - -## Spec Structure Overview - -``` -.trellis/spec/ -├── <layer>/ # Per-layer coding standards (e.g., backend/, frontend/, api/) -│ ├── index.md # Overview and links -│ └── *.md # Topic-specific guidelines -└── guides/ # Thinking checklists (NOT coding specs!) - ├── index.md # Guide index - └── *.md # Topic-specific guides -``` - -### CRITICAL: Code-Spec vs Guide - Know the Difference - -| Type | Location | Purpose | Content Style | -|------|----------|---------|---------------| -| **Code-Spec** | `<layer>/*.md` | Tell AI "how to implement safely" | Signatures, contracts, matrices, cases, test points | -| **Guide** | `guides/*.md` | Help AI "what to think about" | Checklists, questions, pointers to specs | - -**Decision Rule**: Ask yourself: - -- "This is **how to write** the code" → Put in a spec layer directory -- "This is **what to consider** before writing" → Put in `guides/` - -**Example**: - -| Learning | Wrong Location | Correct Location | -|----------|----------------|------------------| -| "Use API X not API Y for this task" | ❌ `guides/` (too specific for a thinking guide) | ✅ Relevant spec file (concrete convention) | -| "Remember to check X when doing Y" | ❌ Spec file (too abstract for a spec) | ✅ `guides/` (thinking checklist) | - -**Guides should be short checklists that point to specs**, not duplicate the detailed rules. - ---- - -## Update Process - -### Step 1: Identify What You Learned - -Answer these questions: - -1. **What did you learn?** (Be specific) -2. **Why is it important?** (What problem does it prevent?) -3. **Where does it belong?** (Which spec file?) - -### Step 2: Classify the Update Type - -| Type | Description | Action | -|------|-------------|--------| -| **Design Decision** | Why we chose approach X over Y | Add to "Design Decisions" section | -| **Project Convention** | How we do X in this project | Add to relevant section with examples | -| **New Pattern** | A reusable approach discovered | Add to "Patterns" section | -| **Forbidden Pattern** | Something that causes problems | Add to "Anti-patterns" or "Don't" section | -| **Common Mistake** | Easy-to-make error | Add to "Common Mistakes" section | -| **Convention** | Agreed-upon standard | Add to relevant section | -| **Gotcha** | Non-obvious behavior | Add warning callout | - -### Step 3: Read the Target Code-Spec - -Before editing, read the current code-spec to: -- Understand existing structure -- Avoid duplicating content -- Find the right section for your update - -```bash -cat .trellis/spec/<category>/<file>.md -``` - -### Step 4: Make the Update - -Follow these principles: - -1. **Be Specific**: Include concrete examples, not just abstract rules -2. **Explain Why**: State the problem this prevents -3. **Show Contracts**: Add signatures, payload fields, and error behavior -4. **Show Code**: Add code snippets for key patterns -5. **Keep it Short**: One concept per section - -### Step 5: Update the Index (if needed) - -If you added a new section or the code-spec status changed, update the category's `index.md`. - ---- - -## Update Templates - -### Mandatory Template for Infra/Cross-Layer Work - -```markdown -## Scenario: <name> - -### 1. Scope / Trigger -- Trigger: <why this requires code-spec depth> - -### 2. Signatures -- Backend command/API/DB signature(s) - -### 3. Contracts -- Request fields (name, type, constraints) -- Response fields (name, type, constraints) -- Environment keys (required/optional) - -### 4. Validation & Error Matrix -- <condition> -> <error> - -### 5. Good/Base/Bad Cases -- Good: ... -- Base: ... -- Bad: ... - -### 6. Tests Required -- Unit/Integration/E2E with assertion points - -### 7. Wrong vs Correct -#### Wrong -... -#### Correct -... -``` - -### Adding a Design Decision - -```markdown -### Design Decision: [Decision Name] - -**Context**: What problem were we solving? - -**Options Considered**: -1. Option A - brief description -2. Option B - brief description - -**Decision**: We chose Option X because... - -**Example**: -\`\`\`typescript -// How it's implemented -code example -\`\`\` - -**Extensibility**: How to extend this in the future... -``` - -### Adding a Project Convention - -```markdown -### Convention: [Convention Name] - -**What**: Brief description of the convention. - -**Why**: Why we do it this way in this project. - -**Example**: -\`\`\`typescript -// How to follow this convention -code example -\`\`\` - -**Related**: Links to related conventions or specs. -``` - -### Adding a New Pattern - -```markdown -### Pattern Name - -**Problem**: What problem does this solve? - -**Solution**: Brief description of the approach. - -**Example**: -\`\`\` -// Good -code example - -// Bad -code example -\`\`\` - -**Why**: Explanation of why this works better. -``` - -### Adding a Forbidden Pattern - -```markdown -### Don't: Pattern Name - -**Problem**: -\`\`\` -// Don't do this -bad code example -\`\`\` - -**Why it's bad**: Explanation of the issue. - -**Instead**: -\`\`\` -// Do this instead -good code example -\`\`\` -``` - -### Adding a Common Mistake - -```markdown -### Common Mistake: Description - -**Symptom**: What goes wrong - -**Cause**: Why this happens - -**Fix**: How to correct it - -**Prevention**: How to avoid it in the future -``` - -### Adding a Gotcha - -```markdown -> **Warning**: Brief description of the non-obvious behavior. -> -> Details about when this happens and how to handle it. -``` - ---- - -## Interactive Mode - -If you're unsure what to update, answer these prompts: - -1. **What did you just finish?** - - [ ] Fixed a bug - - [ ] Implemented a feature - - [ ] Refactored code - - [ ] Had a discussion about approach - -2. **What did you learn or decide?** - - Design decision (why X over Y) - - Project convention (how we do X) - - Non-obvious behavior (gotcha) - - Better approach (pattern) - -3. **Would future AI/developers need to know this?** - - To understand how the code works → Yes, update spec - - To maintain or extend the feature → Yes, update spec - - To avoid repeating mistakes → Yes, update spec - - Purely one-off implementation detail → Maybe skip - -4. **Which area does it relate to?** - - [ ] Backend code - - [ ] Frontend code - - [ ] Cross-layer data flow - - [ ] Code organization/reuse - - [ ] Quality/testing - ---- - -## Quality Checklist - -Before finishing your code-spec update: - -- [ ] Is the content specific and actionable? -- [ ] Did you include a code example? -- [ ] Did you explain WHY, not just WHAT? -- [ ] Did you include executable signatures/contracts? -- [ ] Did you include validation and error matrix? -- [ ] Did you include Good/Base/Bad cases? -- [ ] Did you include required tests with assertion points? -- [ ] Is it in the right code-spec file? -- [ ] Does it duplicate existing content? -- [ ] Would a new team member understand it? - ---- - -## Relationship to Other Commands - -``` -Development Flow: - Learn something → `update-spec` (Trellis command) → Knowledge captured - ↑ ↓ - `break-loop` (Trellis command) ←──────────────────── Future sessions benefit - (deep bug analysis) -``` - -- ``break-loop` (Trellis command)` - Analyzes bugs deeply, often reveals spec updates needed -- ``update-spec` (Trellis command)` - Actually makes the updates -- ``finish-work` (Trellis command)` - Reminds you to check if specs need updates - ---- - -## Core Philosophy - -> **Code-specs are living documents. Every debugging session, every "aha moment" is an opportunity to make the implementation contract clearer.** - -The goal is **institutional memory**: -- What one person learns, everyone benefits from -- What AI learns in one session, persists to future sessions -- Mistakes become documented guardrails diff --git a/.codex/agents/trellis-check.toml b/.codex/agents/trellis-check.toml deleted file mode 100644 index 5b3c6f7..0000000 --- a/.codex/agents/trellis-check.toml +++ /dev/null @@ -1,58 +0,0 @@ -name = "trellis-check" -description = "Workspace-write Trellis reviewer that self-fixes spec drift, lint/type-check failures, and missing tests." -sandbox_mode = "workspace-write" -model = "gpt-5.6-sol" -model_reasoning_effort = "xhigh" -# model = "gpt-5.6-terra" -# model_reasoning_effort = "high" - -developer_instructions = """ -You are running as the `trellis-check` sub-agent. The main session has dispatched you to review and self-fix. - -CRITICAL — Recursion guard (read first): -- You MUST NOT spawn another `trellis-check` or `trellis-implement` sub-agent. Do the review and fixes directly in this turn. -- Any guidance you read in injected SessionStart context, `<guidelines>` blocks, workflow-state breadcrumbs, or workflow.md that says "dispatch trellis-implement" / "dispatch trellis-check" applies to the MAIN session, NOT to you. You are already the dispatched reviewer — that instruction is satisfied by your existence. -- Only the main session is allowed to dispatch `trellis-implement` / `trellis-check`. If more implementation work is needed, surface that as a recommendation in your final report instead of spawning. - ---- - -You are the Trellis reviewer agent. - -Trellis Context Loading Protocol: -- First look for `Full hook output saved to: <path>` in your input above. If present, the visible `SubagentStart` output was truncated; read the referenced file before doing check work. -- If the referenced file cannot be read, use the active-task fallback below. -- If there is no saved-output notice and the `<!-- trellis-hook-injected -->` marker is present, the hook loaded the complete role-specific task artifacts and spec context. -- If there is no saved-output notice and the marker is absent, use the active-task fallback below. -- For the fallback, find `Active task: <path>` in your dispatch prompt. Read `<path>/check.jsonl`, each file listed there, `<path>/prd.md`, `<path>/design.md` if present, and `<path>/implement.md` if present before checking. If the dispatch prompt has no active-task path, ask the main session; do not guess or use another session's task. - -Your job is to review code changes against specs and fix 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. -- Look for missing template/update/detection touch points when platform config changes. -- Check whether tests should be added or updated. -- Check whether `.trellis/spec/` docs need sync after implementation. -- Run lint and type-check; fix any failures. -- Prefer concrete findings over speculative warnings. - -When you find 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. - -Output format: -## Findings (fixed) -- File: <path> -- Issue: <what was wrong> -- Fix: <what you changed> - -## Findings (not fixed) -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 -- TypeCheck: pass/fail -- Tests: pass/fail (if applicable) - -If no issues are found, say so explicitly after verifying lint/type-check pass. -""" diff --git a/.codex/agents/trellis-implement.toml b/.codex/agents/trellis-implement.toml deleted file mode 100644 index 2b2a524..0000000 --- a/.codex/agents/trellis-implement.toml +++ /dev/null @@ -1,39 +0,0 @@ -name = "trellis-implement" -description = "Workspace-write Trellis implementer that follows specs and keeps generated templates in sync." -sandbox_mode = "workspace-write" -model = "gpt-5.6-sol" -model_reasoning_effort = "high" -# model = "gpt-5.6-terra" -# model_reasoning_effort = "high" - -developer_instructions = """ -You are running as the `trellis-implement` sub-agent. The main session has dispatched you to do the work. - -CRITICAL — Recursion guard (read first): -- You MUST NOT spawn another `trellis-implement` or `trellis-check` sub-agent. Do the implementation work directly in this turn. -- Any guidance you read in injected SessionStart context, `<guidelines>` blocks, workflow-state breadcrumbs, or workflow.md that says "dispatch trellis-implement" / "dispatch trellis-check" applies to the MAIN session, NOT to you. You are already the dispatched implementer — that instruction is satisfied by your existence. -- Only the main session is allowed to dispatch `trellis-implement` / `trellis-check`. If more parallel work is needed, surface that as a recommendation in your final report instead of spawning. - ---- - -You are the Trellis implementer agent. - -Trellis Context Loading Protocol: -- First look for `Full hook output saved to: <path>` in your input above. If present, the visible `SubagentStart` output was truncated; read the referenced file before doing implementation work. -- If the referenced file cannot be read, use the active-task fallback below. -- If there is no saved-output notice and the `<!-- trellis-hook-injected -->` marker is present, the hook loaded the complete role-specific task artifacts and spec context. -- If there is no saved-output notice and the marker is absent, use the active-task fallback below. -- For the fallback, find `Active task: <path>` in your dispatch prompt. Read `<path>/implement.jsonl`, each file listed there, `<path>/prd.md`, `<path>/design.md` if present, and `<path>/implement.md` if present before doing the work. If the dispatch prompt has no active-task path, ask the main session; do not guess or use another session's task. - -Rules: -- Read before write. Follow `.trellis/spec/` guidance relevant to the task. -- Keep changes focused on the requested scope. -- When touching platform registries or template lists, search first so you do not miss mirrored update paths. -- If you modify `.trellis/scripts/`, keep `packages/cli/src/templates/trellis/scripts/` in sync. -- Do not make destructive git changes unless explicitly asked. - -Before finishing, summarize: -- Files changed -- Tests/checks run -- Remaining risks or follow-ups -""" diff --git a/.codex/agents/trellis-research.toml b/.codex/agents/trellis-research.toml deleted file mode 100644 index 3efd253..0000000 --- a/.codex/agents/trellis-research.toml +++ /dev/null @@ -1,80 +0,0 @@ -name = "trellis-research" -description = "Trellis researcher for specs, code patterns, and affected files. Writes findings into {TASK_DIR}/research/ — read-only elsewhere." -sandbox_mode = "workspace-write" -model = "gpt-5.6-terra" -model_reasoning_effort = "high" -# model = "gpt-5.6-terra" -# model_reasoning_effort = "high" - -developer_instructions = """ -You are the Trellis researcher agent. - -## Core principle - -Conversations get compacted; files don't. Every research topic MUST be -persisted to `{TASK_DIR}/research/<topic>.md`. Returning findings only -through the chat reply is a failure. - -## Trellis Context Loading Protocol - -- First look for `Full hook output saved to: <path>` in your input above. If - present, the visible `SubagentStart` output was truncated; read the referenced - file before doing research work. -- If the referenced file cannot be read, use the active-task fallback below. -- If there is no saved-output notice and the - `<!-- trellis-hook-injected -->` marker is present, the hook supplied the - complete `Active task: <path>` header and research-only context. -- If there is no saved-output notice and the marker is absent, use the - active-task fallback below. -- For the fallback, find `Active task: <path>` in your dispatch prompt. If it - is absent too, ask the main session for the path; do not run - `task.py current`, guess, or use another session's task. -- Do not load `implement.jsonl` or `check.jsonl`; research is role-isolated. - -## Workflow - -1. Use the active task path supplied by the context-loading protocol. If no - path is supplied, ask the user where to write output; do not guess. -2. Run `mkdir -p <TASK_DIR>/research` to ensure the directory exists. -3. Read `.trellis/workflow.md`, relevant `.trellis/spec/` files, and - target code before forming an opinion. -4. For each research topic, write `<TASK_DIR>/research/<slug>.md` with: - - Query, scope, date - - Files found (path + one-line description) - - Code patterns (cite file:line) - - External references (docs, versions) - - Related specs - - Caveats / not-found notes -5. Reply with only: list of files written, one-line summary per file, - any critical caveats. Do not paste full research into the reply. - -## Scope limits - -Write allowed ONLY in `{TASK_DIR}/research/`. - -Write forbidden everywhere else: -- Code files (`src/`, `lib/`, …) -- Spec files (`.trellis/spec/`) — use `update-spec` skill instead -- `.trellis/scripts/`, `.trellis/workflow.md`, platform config -- Other task directories -- Any git operation - -If the user asks you to edit code, decline and tell them to spawn the -`implement` agent. - -## Output format for each research file - -``` -# Research: <topic> - -- Query: ... -- Scope: internal / external / mixed -- Date: YYYY-MM-DD - -## Findings -... - -## Caveats / Not Found -... -``` -""" diff --git a/.codex/config.toml b/.codex/config.toml deleted file mode 100644 index e1feee3..0000000 --- a/.codex/config.toml +++ /dev/null @@ -1,39 +0,0 @@ -# Project-scoped Codex defaults for Trellis workflows. -# Codex merges this layer after the user-level config when the project -# is marked as a trusted project. To trust this project, add it under -# `[projects]` in ~/.codex/config.toml, e.g.: -# -# [projects."/abs/path/to/this/repo"] -# trust_level = "trusted" - -# Keep AGENTS.md as the primary project instruction file. -project_doc_fallback_filenames = ["AGENTS.md"] - -# Codex hooks (`hooks.json` in this directory) only fire when the user -# has enabled them in their USER-level config: `[features].hooks = true` -# in ~/.codex/config.toml (Codex 0.129+; legacy name: `codex_hooks = true`, -# still works but emits a deprecation warning on 0.129+). Project-level -# config.toml cannot set feature flags; they must be user-level. -# Codex 0.129+ additionally gates each installed hook behind a one-time -# `/hooks` TUI review; until the user approves it, the hook stays inactive. - -# NOTE: Trellis intentionally does NOT write a [features.multi_agent_v2] -# block here. Codex CLI changed `features` deserialization between 0.130 -# and 0.131: the structured table form (with max_concurrent_threads_per_session -# / *_wait_timeout_ms) is only accepted by 0.131+. On 0.130 and earlier — -# including the codex CLI bundled inside the Codex desktop app — it fails -# with `data did not match any variant of untagged enum FeatureToml`, which -# aborts the entire config load and blocks Codex from starting. Codex's own -# default for multi_agent_v2 is used instead; tune it in your user-level -# config if needed. - -# Pin the subagent recursion depth explicitly instead of relying on Codex's -# default. #445 removed the per-agent `[features] multi_agent = false` guard -# (the #240/#241 wait_agent-deadlock structural fix) because native subagent -# dispatch already caps recursion via `agents.max_depth` — but that key is -# global/user-level, not settable inside an individual agent's .toml. Pinning -# it here means an upstream default change, or a user's own global override, -# can't silently reopen the recursion the #240/#241 fix closed. Project config -# (this file) takes precedence over user-level `~/.codex/config.toml`. -[agents] -max_depth = 1 diff --git a/.codex/hooks.json b/.codex/hooks.json deleted file mode 100644 index dc04480..0000000 --- a/.codex/hooks.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "hooks": { - "UserPromptSubmit": [ - { - "hooks": [ - { - "type": "command", - "command": "python3 -X utf8 .codex/hooks/inject-workflow-state.py", - "timeout": 15 - } - ] - } - ], - "SubagentStart": [ - { - "matcher": "^(?:trellis-implement|trellis-check|trellis-research)$", - "hooks": [ - { - "type": "command", - "command": "python3 -X utf8 .codex/hooks/inject-subagent-context.py", - "timeout": 15 - } - ] - } - ] - } -} diff --git a/.codex/hooks/inject-subagent-context.py b/.codex/hooks/inject-subagent-context.py deleted file mode 100755 index 1d2dae6..0000000 --- a/.codex/hooks/inject-subagent-context.py +++ /dev/null @@ -1,1174 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Multi-Platform Sub-Agent Context Injection Hook - -Injects task-specific context when sub-agents (implement, check, research) are spawned. - -Core Design Philosophy: -- Hook is responsible for injecting all context, subagent works autonomously with complete info -- Each agent has a dedicated jsonl file defining its context -- No resume needed, no segmentation, behavior controlled by code not prompt - -Trigger: PreToolUse (before Task tool call) - -Context Source: Trellis active task resolver points to task directory -- implement.jsonl - Implement agent dedicated context -- check.jsonl - Check agent dedicated context -- prd.md - Requirements document -- design.md - Technical design for complex tasks -- implement.md - Execution plan for complex tasks -- codex-review-output.txt - Code Review results -""" -from __future__ import annotations - -# IMPORTANT: Suppress all warnings FIRST -import warnings -warnings.filterwarnings("ignore") - -import json -import os -import sys -from pathlib import Path -from typing import Any - -# Hook hosts send UTF-8 JSON regardless of the process locale. -_stdin_reconfigure = getattr(sys.stdin, "reconfigure", None) -if callable(_stdin_reconfigure): - try: - _stdin_reconfigure(encoding="utf-8", errors="replace") - except (OSError, ValueError): - pass - -# IMPORTANT: Force stdout to use UTF-8 on Windows -# This fixes UnicodeEncodeError when outputting non-ASCII characters -if sys.platform.startswith("win"): - import io as _io - if hasattr(sys.stdout, "reconfigure"): - sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] - elif hasattr(sys.stdout, "detach"): - sys.stdout = _io.TextIOWrapper(sys.stdout.detach(), encoding="utf-8", errors="replace") # type: ignore[union-attr] - - -# ============================================================================= -# Path Constants (change here to rename directories) -# ============================================================================= - -DIR_WORKFLOW = ".trellis" -DIR_SPEC = "spec" -FILE_TASK_JSON = "task.json" - -# ============================================================================= -# Subagent Constants (change here to rename subagent types) -# ============================================================================= - -AGENT_IMPLEMENT = "trellis-implement" -AGENT_CHECK = "trellis-check" -AGENT_RESEARCH = "trellis-research" - -# Agents that require a task directory -AGENTS_REQUIRE_TASK = (AGENT_IMPLEMENT, AGENT_CHECK) -# All supported agents -AGENTS_ALL = (AGENT_IMPLEMENT, AGENT_CHECK, AGENT_RESEARCH) - - -def find_repo_root(start_path: str) -> str | None: - """ - Find git repo root from start_path upwards - - Returns: - Repo root path, or None if not found - """ - current = Path(start_path).resolve() - while current != current.parent: - if (current / ".git").exists(): - return str(current) - current = current.parent - return None - - -def _detect_platform(input_data: dict) -> str | None: - if _hook_event_name(input_data) == "SubagentStart": - return "codex" - if isinstance(input_data.get("cursor_version"), str): - return "cursor" - # 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", - "CURSOR_PROJECT_DIR": "cursor", - "CODEBUDDY_PROJECT_DIR": "codebuddy", - "FACTORY_PROJECT_DIR": "droid", - "GEMINI_PROJECT_DIR": "gemini", - "QODER_PROJECT_DIR": "qoder", - "KIRO_PROJECT_DIR": "kiro", - "COPILOT_PROJECT_DIR": "copilot", - "TRAE_PROJECT_DIR": "trae", - # 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): - return platform - script_parts = set(Path(sys.argv[0]).parts) - if ".claude" in script_parts: - return "claude" - if ".cursor" in script_parts: - return "cursor" - if ".gemini" in script_parts: - return "gemini" - if ".qoder" in script_parts: - return "qoder" - if ".codebuddy" in script_parts: - return "codebuddy" - if ".factory" in script_parts: - return "droid" - if ".kiro" in script_parts: - return "kiro" - if ".zcode" in script_parts: - return "zcode" - return None - - -def get_current_task( - repo_root: str, - input_data: dict, - *, - platform: str | None = None, - allow_single_session_fallback: bool = True, - allow_environment_context: bool = True, - require_existing: bool = False, -) -> str | None: - """Resolve current task directory through the unified active task resolver.""" - scripts_dir = Path(repo_root) / DIR_WORKFLOW / "scripts" - if str(scripts_dir) not in sys.path: - sys.path.insert(0, str(scripts_dir)) - try: - from common.active_task import resolve_active_task # type: ignore[import-not-found] - except Exception: - return None - - active = resolve_active_task( - Path(repo_root), - input_data, - platform=platform or _detect_platform(input_data), - allow_single_session_fallback=allow_single_session_fallback, - allow_environment_context=allow_environment_context, - ) - if require_existing and active.stale: - return None - return active.task_path - - -# ============================================================================= -# Context Injection Limits (issue #441) -# -# Notice text and behavior mirrored byte-for-byte in the Pi TS extension -# (templates/pi/extensions/trellis/index.ts.txt). Changing wording here -# requires changing it there too. -# ============================================================================= - -DEFAULT_MAX_FILE_BYTES = 32768 -DEFAULT_MAX_ARTIFACT_BYTES = 65536 -DEFAULT_MAX_TOTAL_BYTES = 131072 - -DEFAULT_LIMITS: dict[str, int] = { - "max_file_bytes": DEFAULT_MAX_FILE_BYTES, - "max_artifact_bytes": DEFAULT_MAX_ARTIFACT_BYTES, - "max_total_bytes": DEFAULT_MAX_TOTAL_BYTES, -} - - -def _get_limits(repo_root: str) -> dict[str, int]: - """Load context-injection byte limits from config.yaml, with safe fallback.""" - scripts_dir = Path(repo_root) / DIR_WORKFLOW / "scripts" - if str(scripts_dir) not in sys.path: - sys.path.insert(0, str(scripts_dir)) - try: - from common.config import get_context_injection_limits # type: ignore[import-not-found] - - return get_context_injection_limits(Path(repo_root)) - except Exception: - return dict(DEFAULT_LIMITS) - - -def truncate_utf8(data: bytes, cap: int) -> bytes: - """Truncate ``data`` to at most ``cap`` bytes without splitting a UTF-8 - multi-byte sequence. - - ``cap <= 0`` means "no limit" — returns ``data`` unchanged. - """ - if cap <= 0 or len(data) <= cap: - return data - - truncated = data[:cap] - i = len(truncated) - # Back off over continuation bytes (10xxxxxx) to find the lead byte. - while i > 0 and (truncated[i - 1] & 0xC0) == 0x80: - i -= 1 - if i == 0: - return b"" - - lead = truncated[i - 1] - if lead & 0x80: - if (lead & 0xE0) == 0xC0: - seq_len = 2 - elif (lead & 0xF0) == 0xE0: - seq_len = 3 - elif (lead & 0xF8) == 0xF0: - seq_len = 4 - else: - seq_len = 1 - # Drop the lead byte too if its full sequence didn't fit. - if (i - 1) + seq_len > len(truncated): - i -= 1 - - return truncated[:i] - - -class _Budget: - """Tracks the running total of bytes emitted into the sub-agent context.""" - - def __init__(self, max_total_bytes: int) -> None: - self.max_total_bytes = max_total_bytes - self.used = 0 - - def has_room(self, size: int) -> bool: - if self.max_total_bytes <= 0: - return True - return self.used + size <= self.max_total_bytes - - def add(self, size: int) -> None: - self.used += size - - -def _read_file_bytes(base_path: str, file_path: str) -> bytes | None: - """Read raw file bytes, return None if file doesn't exist.""" - full_path = os.path.join(base_path, file_path) - if os.path.exists(full_path) and os.path.isfile(full_path): - try: - with open(full_path, "rb") as f: - return f.read() - except Exception: - return None - return None - - -def _truncate_notice(path: str, cap: int) -> str: - return f"\n[Trellis: truncated at {cap} bytes — read {path} for the full content]" - - -def _is_binary_content(data: bytes) -> bool: - """Return True when raw bytes should not be decoded into model context.""" - if b"\x00" in data: - return True - try: - data.decode("utf-8", errors="strict") - except UnicodeDecodeError: - return True - return False - - -def _binary_notice(path: str, size: int, reason: str) -> str: - return ( - f"[Trellis: not inlined (binary file) — " - f"{path} ({size} bytes): {reason}]" - ) - - -def _index_notice(path: str, size: int, reason: str) -> str: - return ( - f"[Trellis: not inlined (total context limit reached) — " - f"{path} ({size} bytes): {reason}]" - ) - - -def _budgeted_block( - budget: _Budget, - header: str, - plain_path: str, - content: str, - reason: str, - size_for_index: int, -) -> str: - """Return an inlined ``=== header ===`` block, or degrade to an index - notice once the total context budget is exhausted.""" - block = f"=== {header} ===\n{content}" - block_bytes = len(block.encode("utf-8")) - if not budget.has_room(block_bytes): - notice = _index_notice(plain_path, size_for_index, reason) - budget.add(len(notice.encode("utf-8"))) - return notice - budget.add(block_bytes) - return block - - -def _materialize_file( - base_path: str, - file_path: str, - reason: str, - limits: dict[str, int], - budget: _Budget, -) -> str | None: - """Read a JSONL-referenced file, apply the per-file cap, then budget it.""" - data = _read_file_bytes(base_path, file_path) - if data is None: - return None - - size = len(data) - if _is_binary_content(data): - notice = _binary_notice(file_path, size, reason) - budget.add(len(notice.encode("utf-8"))) - return notice - - cap = limits["max_file_bytes"] - truncated_bytes = truncate_utf8(data, cap) - content = truncated_bytes.decode("utf-8", errors="replace") - if len(truncated_bytes) < size: - content += _truncate_notice(file_path, cap) - - return _budgeted_block(budget, file_path, file_path, content, reason, size) - - -def _materialize_directory( - base_path: str, - dir_path: str, - reason: str, - limits: dict[str, int], - budget: _Budget, - max_files: int = 20, -) -> list[str]: - """Read all .md files in a directory, applying the same per-file and - total caps as a single-file JSONL entry.""" - full_path = os.path.join(base_path, dir_path) - if not os.path.exists(full_path) or not os.path.isdir(full_path): - return [] - - blocks: list[str] = [] - try: - md_files = sorted( - f - for f in os.listdir(full_path) - if f.endswith(".md") and os.path.isfile(os.path.join(full_path, f)) - ) - for filename in md_files[:max_files]: - relative_path = os.path.join(dir_path, filename) - block = _materialize_file(base_path, relative_path, reason, limits, budget) - if block: - blocks.append(block) - except Exception: - pass - - return blocks - - -def read_jsonl_entries(base_path: str, jsonl_path: str) -> list[dict]: - """ - Parse all file/directory entries referenced in a jsonl context file. - - Schema: - {"file": "path/to/file.md", "reason": "..."} - {"file": "path/to/dir/", "type": "directory", "reason": "..."} - {"_example": "..."} # seed row — skipped (no `file` field) - - Rows without a ``file`` field (e.g. the self-describing seed line written - by ``task.py create`` before the agent has curated entries) are skipped - silently. If the resulting entry list is empty, a stderr warning is - emitted so the operator can debug missing context. - - Returns: - [{"file": path, "type": "file" | "directory", "reason": reason}, ...] - """ - full_path = os.path.join(base_path, jsonl_path) - if not os.path.exists(full_path): - print( - f"[inject-subagent-context] WARN: {jsonl_path} not found — " - f"sub-agent will receive only task artifacts", - file=sys.stderr, - ) - return [] - - entries: list[dict] = [] - saw_real_entry = False - try: - with open(full_path, "r", encoding="utf-8") as f: - for line in f: - line = line.strip() - if not line: - continue - try: - item = json.loads(line) - file_path = item.get("file") or item.get("path") - - if not file_path: - # Seed / comment row — skip silently - continue - - saw_real_entry = True - entries.append( - { - "file": file_path, - "type": item.get("type", "file"), - "reason": item.get("reason") or "-", - } - ) - except json.JSONDecodeError: - continue - except Exception: - pass - - if not saw_real_entry: - print( - f"[inject-subagent-context] WARN: {jsonl_path} has no curated " - f"entries (only seed / empty) — sub-agent will receive only " - f"task artifacts. See workflow.md planning artifact guidance.", - file=sys.stderr, - ) - - return entries - - -def _materialize_jsonl_entries( - base_path: str, jsonl_path: str, limits: dict[str, int], budget: _Budget -) -> list[str]: - """Materialize every entry in a jsonl context file into context blocks, - applying per-file and total budget caps.""" - blocks: list[str] = [] - for entry in read_jsonl_entries(base_path, jsonl_path): - if entry["type"] == "directory": - blocks.extend( - _materialize_directory( - base_path, entry["file"], entry["reason"], limits, budget - ) - ) - else: - block = _materialize_file( - base_path, entry["file"], entry["reason"], limits, budget - ) - if block: - blocks.append(block) - return blocks - - -def get_agent_context( - repo_root: str, - task_dir: str, - agent_type: str, - limits: dict[str, int], - budget: _Budget, -) -> str: - """ - Get context from {agent_type}.jsonl for the specified agent. - Only reads implement.jsonl or check.jsonl (the two JSONL files the task system creates). - """ - agent_jsonl = f"{task_dir}/{agent_type}.jsonl" - blocks = _materialize_jsonl_entries(repo_root, agent_jsonl, limits, budget) - return "\n\n".join(blocks) - - -def _materialize_artifact( - base_path: str, - file_path: str, - header_label: str, - reason: str, - limits: dict[str, int], - budget: _Budget, -) -> str | None: - """Read a task artifact (prd/design/implement.md), apply the per-artifact - cap, then budget it.""" - data = _read_file_bytes(base_path, file_path) - if data is None: - return None - - size = len(data) - cap = limits["max_artifact_bytes"] - truncated_bytes = truncate_utf8(data, cap) - content = truncated_bytes.decode("utf-8", errors="replace") - if len(truncated_bytes) < size: - content += _truncate_notice(file_path, cap) - - return _budgeted_block(budget, header_label, file_path, content, reason, size) - - -def get_implement_context(repo_root: str, task_dir: str) -> str: - """ - Complete context for Implement Agent - - Read order: - 1. All files in implement.jsonl (spec/research manifests) - 2. prd.md (requirements) - 3. design.md if present (technical design) - 4. implement.md if present (execution plan) - """ - limits = _get_limits(repo_root) - budget = _Budget(limits["max_total_bytes"]) - context_parts = [] - - # 1. Read implement.jsonl - base_context = get_agent_context(repo_root, task_dir, "implement", limits, budget) - if base_context: - context_parts.append(base_context) - - # 2. Requirements document - prd_block = _materialize_artifact( - repo_root, - f"{task_dir}/prd.md", - f"{task_dir}/prd.md (Requirements)", - "Requirements document", - limits, - budget, - ) - if prd_block: - context_parts.append(prd_block) - - # 3. Technical design for complex tasks - design_block = _materialize_artifact( - repo_root, - f"{task_dir}/design.md", - f"{task_dir}/design.md (Technical Design)", - "Technical design document", - limits, - budget, - ) - if design_block: - context_parts.append(design_block) - - # 4. Execution plan for complex tasks - implement_plan_block = _materialize_artifact( - repo_root, - f"{task_dir}/implement.md", - f"{task_dir}/implement.md (Execution Plan)", - "Execution plan document", - limits, - budget, - ) - if implement_plan_block: - context_parts.append(implement_plan_block) - - return "\n\n".join(context_parts) - - -def get_check_context(repo_root: str, task_dir: str) -> str: - """ - Context for Check Agent: check.jsonl + task artifacts. - """ - limits = _get_limits(repo_root) - budget = _Budget(limits["max_total_bytes"]) - context_parts = [] - - base_context = get_agent_context(repo_root, task_dir, "check", limits, budget) - if base_context: - context_parts.append(base_context) - - prd_block = _materialize_artifact( - repo_root, - f"{task_dir}/prd.md", - f"{task_dir}/prd.md (Requirements)", - "Requirements document", - limits, - budget, - ) - if prd_block: - context_parts.append(prd_block) - - design_block = _materialize_artifact( - repo_root, - f"{task_dir}/design.md", - f"{task_dir}/design.md (Technical Design)", - "Technical design document", - limits, - budget, - ) - if design_block: - context_parts.append(design_block) - - implement_plan_block = _materialize_artifact( - repo_root, - f"{task_dir}/implement.md", - f"{task_dir}/implement.md (Execution Plan)", - "Execution plan document", - limits, - budget, - ) - if implement_plan_block: - context_parts.append(implement_plan_block) - - return "\n\n".join(context_parts) - - -def get_finish_context(repo_root: str, task_dir: str) -> str: - """ - Context for Finish phase: reuses check.jsonl + prd.md - (Finish is a final check, same context source.) - """ - return get_check_context(repo_root, task_dir) - - - -def build_implement_prompt(original_prompt: str, context: str) -> str: - """Build complete prompt for Implement""" - return f"""<!-- trellis-hook-injected --> -# Implement Agent Task - -You are the Implement Agent in the Multi-Agent Pipeline. - -## Your Context - -All the information you need has been prepared for you: - -{context} - ---- - -## Your Task - -{original_prompt} - ---- - -## Workflow - -1. **Understand specs** - All dev specs are injected above, understand them - 2. **Understand task artifacts** - Read requirements, technical design if present, and execution plan if present - 3. **Implement feature** - Implement following specs and task artifacts -4. **Self-check** - Ensure code quality against check specs - -## Important Constraints - -- Do NOT execute git commit, only code modifications -- Follow all dev specs injected above -- Report list of modified/created files when done""" - - -def build_check_prompt(original_prompt: str, context: str) -> str: - """Build complete prompt for Check""" - return f"""<!-- trellis-hook-injected --> -# Check Agent Task - -You are the Check Agent in the Multi-Agent Pipeline (code and cross-layer checker). - -## Your Context - -All check specs and dev specs you need: - -{context} - ---- - -## Your Task - -{original_prompt} - ---- - -## Workflow - -1. **Get changes** - Run `git diff --name-only` and `git diff` to get code changes -2. **Check against specs** - Check item by item against specs above -3. **Self-fix** - Fix issues directly, don't just report -4. **Run verification** - Run project's lint and typecheck commands - -## Important Constraints - -- Fix issues yourself, don't just report -- Must execute complete checklist in check specs -- Pay special attention to impact radius analysis (L1-L5)""" - - -def build_finish_prompt(original_prompt: str, context: str) -> str: - """Build complete prompt for Finish (final check before PR)""" - return f"""<!-- trellis-hook-injected --> -# Finish Agent Task - -You are performing the final check before creating a PR. - -## Your Context - -Finish checklist and requirements: - -{context} - ---- - -## Your Task - -{original_prompt} - ---- - -## Workflow - -1. **Review changes** - Run `git diff --name-only` to see all changed files - 2. **Verify task artifacts** - Check requirements in prd.md and, when present, design.md / implement.md -3. **Spec sync** - Analyze whether changes introduce new patterns, contracts, or conventions - - If new pattern/convention found: read target spec file → update it → update index.md if needed - - If infra/cross-layer change: follow the 7-section mandatory template from update-spec.md - - If pure code fix with no new patterns: skip this step -4. **Run final checks** - Execute lint and typecheck -5. **Confirm ready** - Ensure code is ready for PR - -## Important Constraints - -- You MAY update spec files when gaps are detected (use update-spec.md as guide) -- MUST read the target spec file BEFORE editing (avoid duplicating existing content) -- Do NOT update specs for trivial changes (typos, formatting, obvious fixes) -- If critical CODE issues found, report them clearly (fix specs, not code) -- Verify all acceptance criteria in prd.md are met -- Verify design.md and implement.md constraints when those files are present""" - - - -def get_research_context(repo_root: str, task_dir: str | None) -> str: - """ - Context for Research Agent — project structure overview for spec directories. - - `task_dir` kept for signature parity with get_implement_context / get_check_context - so the dispatcher can call them uniformly. - """ - _ = task_dir - context_parts = [] - - # 1. Project structure overview (dynamically discover spec directories) - spec_path = f"{DIR_WORKFLOW}/{DIR_SPEC}" - spec_root = Path(repo_root) / DIR_WORKFLOW / DIR_SPEC - - # Build spec tree dynamically - tree_lines = [f"{spec_path}/"] - if spec_root.is_dir(): - pkg_dirs = sorted(d for d in spec_root.iterdir() if d.is_dir()) - for i, pkg_dir in enumerate(pkg_dirs): - is_last = i == len(pkg_dirs) - 1 - prefix = "└── " if is_last else "├── " - layers = sorted(d.name for d in pkg_dir.iterdir() if d.is_dir()) - layer_info = f" ({', '.join(layers)})" if layers else "" - tree_lines.append(f"{prefix}{pkg_dir.name}/{layer_info}") - - spec_tree = "\n".join(tree_lines) - - project_structure = f"""## Project Spec Directory Structure - -``` -{spec_tree} -``` - -To get structured package info, run: `python3 ./{DIR_WORKFLOW}/scripts/get_context.py --mode packages` - -## Search Tips - -- Spec files: `{spec_path}/**/*.md` -- Code search: Use Glob and Grep tools -- Tech solutions: Use mcp__exa__web_search_exa or mcp__exa__get_code_context_exa""" - - context_parts.append(project_structure) - - return "\n\n".join(context_parts) - - -def build_research_prompt(original_prompt: str, context: str) -> str: - """Build complete prompt for Research""" - return f"""# Research Agent Task - -You are the Research Agent in the Multi-Agent Pipeline (search researcher). - -## Core Principle - -**You do one thing: find and explain information.** - -You are a documenter, not a reviewer. - -## Project Info - -{context} - ---- - -## Your Task - -{original_prompt} - ---- - -## Workflow - -1. **Understand query** - Determine search type (internal/external) and scope -2. **Plan search** - List search steps for complex queries -3. **Execute search** - Execute multiple independent searches in parallel -4. **Organize results** - Output structured report - -## Search Tools - -| Tool | Purpose | -|------|---------| -| Glob | Search by filename pattern | -| Grep | Search by content | -| Read | Read file content | -| mcp__exa__web_search_exa | External web search | -| mcp__exa__get_code_context_exa | External code/doc search | - -## Strict Boundaries - -**Only allowed**: Describe what exists, where it is, how it works - -**Forbidden** (unless explicitly asked): -- Suggest improvements -- Criticize implementation -- Recommend refactoring -- Modify any files - -## Report Format - -Provide structured search results including: -- List of files found (with paths) -- Code pattern analysis (if applicable) -- Related spec documents -- External references (if any)""" - - -def _string_value(value: Any) -> str: - if isinstance(value, str): - stripped = value.strip() - return stripped - return "" - - -def _hook_event_name(input_data: dict) -> str: - """Return a hook event name from the documented snake/camel-case fields.""" - return _string_value( - input_data.get("hook_event_name") or input_data.get("hookEventName") - ) - - -def _codex_subagent_type(input_data: dict) -> str: - """Return a Trellis Codex agent type only for a native start event.""" - if _hook_event_name(input_data) != "SubagentStart": - return "" - agent_type = _string_value( - input_data.get("agent_type") or input_data.get("agentType") - ) - return agent_type if agent_type in AGENTS_ALL else "" - - -def build_codex_subagent_context( - subagent_type: str, - task_dir: str, - context: str, -) -> str: - """Build developer context for a native, already-dispatched Codex role.""" - role = subagent_type.removeprefix("trellis-") - return f"""<!-- trellis-hook-injected --> -# Trellis Native {role.title()} Subagent - -You are the dispatched `{subagent_type}` role for this task. Perform that role -directly; do not follow main-session dispatch or wait instructions, and do not -spawn another Trellis subagent. - -Active task: {task_dir} - -## Curated Context - -{context}""" - - -def _handle_codex_subagent_start(input_data: dict) -> None: - """Emit Codex developer context for a recognised native Trellis subagent. - - The event supplies the parent session id. Disabling the generic - single-session fallback is essential here: native starts must never borrow - a task from another Codex window when that parent id is absent or stale. - """ - subagent_type = _codex_subagent_type(input_data) - parent_session_id = _string_value(input_data.get("session_id")) - if not subagent_type or not parent_session_id: - return - - # 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 - - task_dir = get_current_task( - repo_root, - {"session_id": parent_session_id}, - platform="codex", - allow_single_session_fallback=False, - allow_environment_context=False, - require_existing=True, - ) - if not task_dir: - return - - if subagent_type in AGENTS_REQUIRE_TASK: - task_dir_full = Path(repo_root) / task_dir - if not task_dir_full.is_dir(): - return - - if subagent_type == AGENT_IMPLEMENT: - context = get_implement_context(repo_root, task_dir) - elif subagent_type == AGENT_CHECK: - context = get_check_context(repo_root, task_dir) - else: - context = get_research_context(repo_root, task_dir) - - if not context: - return - - output = { - "hookSpecificOutput": { - "hookEventName": "SubagentStart", - "additionalContext": build_codex_subagent_context( - subagent_type, task_dir, context - ), - } - } - print(json.dumps(output, ensure_ascii=False)) - - -def _extract_subagent_name(value: Any) -> str: - """Extract a sub-agent name from common platform encodings. - - Cursor's native Task args encode custom sub-agents as a protobuf oneof, - which can appear in hook JSON as either ``{"custom": {"name": "..."}}`` - or ``{"type": {"case": "custom", "value": {"name": "..."}}}``. - """ - direct = _string_value(value) - if direct: - return direct - - if not isinstance(value, dict): - return "" - - for key in ("name", "subagent_type_name", "subagentTypeName"): - direct = _string_value(value.get(key)) - if direct: - return direct - - custom = value.get("custom") - if isinstance(custom, dict): - custom_name = _string_value(custom.get("name")) - if custom_name: - return custom_name - - oneof = value.get("type") - if isinstance(oneof, dict): - case_name = _string_value(oneof.get("case")) - if case_name == "custom": - nested_value = oneof.get("value") - if isinstance(nested_value, dict): - custom_name = _string_value(nested_value.get("name")) - if custom_name: - return custom_name - if case_name: - return case_name - - case_name = _string_value(value.get("case")) - if case_name == "custom": - nested_value = value.get("value") - if isinstance(nested_value, dict): - custom_name = _string_value(nested_value.get("name")) - if custom_name: - return custom_name - if case_name: - return case_name - - for agent_name in AGENTS_ALL: - if agent_name in value: - return agent_name - - return "" - - -def _extract_subagent_type(tool_input: dict) -> str: - for key in ( - "subagent_type", - "subagentType", - "subagent_type_name", - "subagentTypeName", - "subagent_name", - "subagentName", - "agent_type", - "agentType", - "name", - ): - agent_name = _extract_subagent_name(tool_input.get(key)) - if agent_name: - return agent_name - return "" - - -def _parse_hook_input(input_data: dict) -> tuple[str, str, dict]: - """Parse hook input across different platform formats. - - Returns (subagent_type, original_prompt, tool_input). - Handles: - - Claude Code / Qoder / 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 - - Gemini CLI: tool_name IS the agent name (BeforeTool matcher already filtered) - - Kiro: agentSpawn hook, agent_name field at top level - """ - tool_input = input_data.get("tool_input", {}) - if not isinstance(tool_input, dict): - tool_input = input_data.get("toolInput", {}) - if not isinstance(tool_input, dict): - tool_input = {} - - # Standard format: Task/Agent tool with subagent_type - tool_name = input_data.get("tool_name", "") or input_data.get("toolName", "") - if tool_name.lower() in ("task", "agent", "subagent"): - return ( - _extract_subagent_type(tool_input), - tool_input.get("prompt", ""), - tool_input, - ) - - # Kiro: agentSpawn hook passes agent_name at top level - agent_name = input_data.get("agent_name", "") - if agent_name: - return agent_name, tool_input.get("prompt", input_data.get("prompt", "")), tool_input - - # Gemini CLI: BeforeTool where tool_name IS the agent name - # (matcher already ensured it's one of our agents) - if tool_name in AGENTS_ALL: - return tool_name, tool_input.get("prompt", ""), tool_input - - # Copilot CLI: toolName field (camelCase), value might be the agent name - tool_name_camel = input_data.get("toolName", "") - if tool_name_camel in AGENTS_ALL: - return tool_name_camel, input_data.get("toolArgs", ""), tool_input - - return "", "", tool_input - - -def main(): - if os.environ.get("TRELLIS_HOOKS") == "0" or os.environ.get("TRELLIS_DISABLE_HOOKS") == "1": - sys.exit(0) - - try: - input_data = json.load(sys.stdin) - except json.JSONDecodeError: - sys.exit(0) - if not isinstance(input_data, dict): - sys.exit(0) - - if _hook_event_name(input_data) == "SubagentStart": - try: - _handle_codex_subagent_start(input_data) - except Exception: - # A native context hook must never prevent Codex from spawning the - # requested child when its runtime state is unavailable or stale. - pass - sys.exit(0) - - subagent_type, original_prompt, tool_input = _parse_hook_input(input_data) - cwd = input_data.get("cwd", os.getcwd()) - - # Only handle subagent types we care about - if subagent_type not in AGENTS_ALL: - sys.exit(0) - - # Find repo root - repo_root = find_repo_root(cwd) - if not repo_root: - sys.exit(0) - - # Get current task directory (research doesn't require it) - task_dir = get_current_task(repo_root, input_data) - - # implement/check need task directory - if subagent_type in AGENTS_REQUIRE_TASK: - if not task_dir: - sys.exit(0) - # 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) - task_dir_full = os.path.realpath(os.path.join(repo_root, task_dir)) - # ValueError on Windows when the two sit on different drives; that - # is outside the repo by definition, so it fails closed below. - if os.path.commonpath([root_real, task_dir_full]) != root_real: - sys.exit(0) - except (OSError, ValueError): - sys.exit(0) - if not os.path.exists(task_dir_full): - sys.exit(0) - - # Check for [finish] marker in prompt (check agent with finish context) - is_finish_phase = "[finish]" in original_prompt.lower() - - # Get context and build prompt based on subagent type - if subagent_type == AGENT_IMPLEMENT: - assert task_dir is not None # validated above - context = get_implement_context(repo_root, task_dir) - new_prompt = build_implement_prompt(original_prompt, context) - elif subagent_type == AGENT_CHECK: - assert task_dir is not None # validated above - if is_finish_phase: - # Finish phase: use finish context (lighter, focused on final verification) - context = get_finish_context(repo_root, task_dir) - new_prompt = build_finish_prompt(original_prompt, context) - else: - # Regular check phase: use check context (full specs for self-fix loop) - context = get_check_context(repo_root, task_dir) - new_prompt = build_check_prompt(original_prompt, context) - elif subagent_type == AGENT_RESEARCH: - # Research can work without task directory - context = get_research_context(repo_root, task_dir) - new_prompt = build_research_prompt(original_prompt, context) - else: - sys.exit(0) - - if not context: - sys.exit(0) - - # Return updated input. Most platforms ignore unrecognized fields, so we - # include multiple formats. ZCode is stricter; live probing confirmed the - # nested Claude-compatible shape below reaches the sub-agent prompt. - updated = {**tool_input, "prompt": new_prompt} - if _detect_platform(input_data) == "zcode": - output = { - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "allow", - "updatedInput": updated, - } - } - else: - output = { - # Claude Code / Qoder / CodeBuddy / Droid format - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "allow", - "updatedInput": updated, - }, - # Cursor format - "permission": "allow", - "updated_input": updated, - # Gemini format - "updatedInput": updated, - } - - print(json.dumps(output, ensure_ascii=False)) - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/.codex/hooks/inject-workflow-state.py b/.codex/hooks/inject-workflow-state.py deleted file mode 100644 index 05bef95..0000000 --- a/.codex/hooks/inject-workflow-state.py +++ /dev/null @@ -1,475 +0,0 @@ -#!/usr/bin/env python3 -"""Trellis per-turn breadcrumb hook (UserPromptSubmit / BeforeAgent equivalent). - -Runs on every user prompt. Resolves the active task through Trellis' -session-aware active task resolver and emits a short <workflow-state> -block reminding the main AI what task is active and its expected flow. - -The emitted ``hookEventName`` field is platform-aware: most hosts expect -``UserPromptSubmit`` (Claude Code naming, also accepted by Cursor / Qoder / -CodeBuddy / Droid / Codex / Copilot wiring), but Gemini CLI 0.40.x renamed -its per-turn event to ``BeforeAgent`` and its schema validator rejects the -legacy name. ``_detect_platform`` picks the right value at runtime. -Breadcrumb text is pulled exclusively from workflow.md -[workflow-state:STATUS] tag blocks — workflow.md is the single source of -truth. There are no fallback dicts in this script: when workflow.md is -missing or a tag is absent, the breadcrumb degrades to a generic -"Refer to workflow.md for current step." line so users see (and fix) -the broken state instead of the hook silently masking it. - -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). - -Silent exit 0 cases (no output): - - No .trellis/ directory found (not a Trellis project) - - task.json malformed or missing status -""" -from __future__ import annotations - -import json -import os -import re -import sys -import queue -import threading -from pathlib import Path - -# Force UTF-8 on stdin/stdout/stderr on Windows. Default codepage there is -# cp936 / cp1252 / etc. — non-ASCII content (Chinese task names, prd snippets) -# both in stdin (hook payload from host CLI) and stdout (our emitted blocks) -# raises UnicodeDecodeError / UnicodeEncodeError. Equivalent to `python -X utf8` -# but applied per-stream so we don't depend on host CLI's command wiring. -if sys.platform.startswith("win"): - import io as _io - for _stream_name in ("stdin", "stdout", "stderr"): - _stream = getattr(sys, _stream_name, None) - if _stream is None: - continue - if hasattr(_stream, "reconfigure"): - try: - _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] - except Exception: - pass # Optional Windows stream setup; keep hook startup non-fatal. - elif hasattr(_stream, "detach"): - try: - setattr(sys, _stream_name, _io.TextIOWrapper(_stream.detach(), encoding="utf-8", errors="replace")) - except Exception: - pass # Optional Windows stream setup; keep hook startup non-fatal. -from typing import Optional - - -# Bootstrap notice for Codex while the session has no active task. Codex does not -# get the full SessionStart overview; this short reminder points the main session -# at the start skill once and leaves the per-turn state block compact. -CODEX_NO_TASK_BOOTSTRAP_NOTICE = """<trellis-bootstrap> -If you have not already loaded Trellis context this session, read the `trellis-start` skill once. -</trellis-bootstrap>""" - - -# --------------------------------------------------------------------------- -# CWD-robust Trellis root discovery (fixes hook-path-robustness for this hook) -# --------------------------------------------------------------------------- - -def find_trellis_root(start: Path) -> Optional[Path]: - """Walk up from start to find directory containing .trellis/. - - Handles CWD drift: subdirectory launches, monorepo packages, etc. - Returns None if no .trellis/ found (silent no-op). - """ - cur = start.resolve() - while cur != cur.parent: - if (cur / ".trellis").is_dir(): - return cur - cur = cur.parent - return None - - -# --------------------------------------------------------------------------- -# Active task discovery -# --------------------------------------------------------------------------- - -def _detect_platform(input_data: dict) -> str | None: - if isinstance(input_data.get("cursor_version"), str): - return "cursor" - # 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_PROJECT_DIR": "zcode", - "CURSOR_PROJECT_DIR": "cursor", - "CODEBUDDY_PROJECT_DIR": "codebuddy", - "FACTORY_PROJECT_DIR": "droid", - "GEMINI_PROJECT_DIR": "gemini", - "QODER_PROJECT_DIR": "qoder", - "KIRO_PROJECT_DIR": "kiro", - "COPILOT_PROJECT_DIR": "copilot", - "TRAE_PROJECT_DIR": "trae", - # 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): - return platform - script_parts = set(Path(sys.argv[0]).parts) - if ".claude" in script_parts: - return "claude" - if ".cursor" in script_parts: - return "cursor" - if ".codex" in script_parts: - return "codex" - if ".gemini" in script_parts: - return "gemini" - if ".qoder" in script_parts: - return "qoder" - if ".codebuddy" in script_parts: - return "codebuddy" - if ".factory" in script_parts: - return "droid" - if ".kiro" in script_parts: - return "kiro" - if ".trae" in script_parts: - return "trae" - if ".zcode" in script_parts: - return "zcode" - return None - - -def _resolve_active_task(root: Path, input_data: dict): - scripts_dir = root / ".trellis" / "scripts" - if str(scripts_dir) not in sys.path: - sys.path.insert(0, str(scripts_dir)) - from common.active_task import resolve_active_task # type: ignore[import-not-found] - - return resolve_active_task(root, input_data, platform=_detect_platform(input_data)) - - -def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, str]]: - """Return (task_id, status, source) from the current active task.""" - active = _resolve_active_task(root, input_data) - if not active.task_path: - return None - - task_dir = Path(active.task_path) - if not task_dir.is_absolute(): - task_dir = root / task_dir - if active.stale: - return task_dir.name, f"stale_{active.source_type}", active.source - - task_json = task_dir / "task.json" - if not task_json.is_file(): - return None - try: - data = json.loads(task_json.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError): - return None - - task_id = data.get("id") or task_dir.name - status = data.get("status", "") - if not isinstance(status, str) or not status: - return None - return task_id, status, active.source - - -# --------------------------------------------------------------------------- -# Breadcrumb loading: parse workflow.md, fall back to hardcoded defaults -# --------------------------------------------------------------------------- - -# Supports STATUS values with letters, digits, underscores, hyphens -# (so "in-review" / "blocked-by-team" work alongside "in_progress"). -_TAG_RE = re.compile( - r"\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n(.*?)\n\s*\[/workflow-state:\1\]", - re.DOTALL, -) - -def load_breadcrumbs(root: Path) -> dict[str, str]: - """Parse workflow.md for [workflow-state:STATUS] blocks. - - Returns {status: body_text}. workflow.md is the single source of - truth — there are no fallback dicts in this script. Missing tags - (or a missing/unreadable workflow.md) fall back to a generic line - in build_breadcrumb so users see the broken state and fix - workflow.md, rather than the hook silently masking the issue. - """ - workflow = root / ".trellis" / "workflow.md" - if not workflow.is_file(): - return {} - try: - content = workflow.read_text(encoding="utf-8") - except OSError: - return {} - - result: dict[str, str] = {} - for match in _TAG_RE.finditer(content): - status = match.group(1) - body = match.group(2).strip() - if body: - result[status] = body - return result - - -def _read_trellis_config(root: Path) -> dict: - """Load .trellis/config.yaml via the bundled trellis_config helper. - - The helper lives in .trellis/scripts/common; the hook lives outside the - scripts tree, so we extend sys.path before importing. - """ - scripts_dir = root / ".trellis" / "scripts" - if str(scripts_dir) not in sys.path: - sys.path.insert(0, str(scripts_dir)) - try: - from common.trellis_config import read_trellis_config # type: ignore[import-not-found] - except Exception: - return {} - try: - return read_trellis_config(root) - except Exception: - return {} - - -DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD = "no-trellis" - - -def _resolve_skip_keyword(config: dict) -> str: - """Read `prompt_injection.skip_keyword` from parsed .trellis/config.yaml. - - Mirrors `common.config.get_prompt_injection_config()`. Defaults to - "no-trellis"; "" disables the escape hatch entirely. A non-string value - falls back to the default. - """ - if isinstance(config, dict): - section = config.get("prompt_injection") - if isinstance(section, dict): - raw = section.get("skip_keyword", DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD) - if isinstance(raw, str): - return raw - return DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD - - -def prompt_has_skip_keyword(prompt: str, keyword: str) -> bool: - """Case-insensitive, word-boundary match of `keyword` in `prompt`. - - Hyphen counts as a word char so "no-trellisx" / "xno-trellis" / - "foo-no-trellis" don't match, but punctuation/whitespace boundaries do. - Empty keyword never matches (disables the escape hatch). - """ - if not keyword or not isinstance(prompt, str): - return False - pattern = r"(?<![\w-])" + re.escape(keyword) + r"(?![\w-])" - return re.search(pattern, prompt, re.IGNORECASE) is not None - - -def _resolve_codex_dispatch_mode(config: dict) -> str: - """Normalize `codex.dispatch_mode` from .trellis/config.yaml to "auto" or "inline". - - Defaults to `auto`. The legacy `sub-agent` value is an alias for `auto`. - Any other explicit value (including invalid ones) falls back to `inline` - without per-turn warnings. Shared by `_codex_mode_banner` (the per-turn - banner) and `resolve_breadcrumb_key` (the breadcrumb tag key) so the two - stay in lockstep. - """ - mode = "auto" - if isinstance(config, dict): - codex_cfg = config.get("codex") - if isinstance(codex_cfg, dict): - cfg_mode = str(codex_cfg.get("dispatch_mode", mode)).strip().lower() - if cfg_mode == "inline": - mode = "inline" - elif cfg_mode in ("auto", "sub-agent"): - mode = "auto" - else: - mode = "inline" - return mode - - -def _codex_mode_banner(config: dict) -> str: - """Emit a `<codex-mode>` banner for the additionalContext payload. - - Reads `codex.dispatch_mode` from .trellis/config.yaml; defaults to - `auto`, which dispatches Trellis sub-agents using native Codex context - injection with a child-side fallback. This does not rely on inherited - parent transcripts: `fork_turns` remains caller-controlled, and - fresh-history sub-agents still receive their explicit delegated task and - inherited session configuration. `inline` is an explicit opt-out; the - legacy `sub-agent` value is an alias for `auto`. Invalid explicit values - fall back to `inline` without per-turn warnings. The banner makes the - active mode explicit to Codex AI per turn, complementing the workflow-state - body which is per-status. Mode tells AI which dispatch protocol to follow; - workflow-state tells AI what step it's at. - """ - mode = _resolve_codex_dispatch_mode(config) - if mode == "auto": - meaning = ( - "auto: implement/check work defaults to Trellis sub-agents; native Codex " - "context injection is preferred and child-side loading is the fallback. " - "The main session still coordinates, clarifies, updates specs, commits, and finishes." - ) - else: - meaning = ( - "inline: the main session implements/checks directly; " - "do not dispatch implement/check sub-agents." - ) - return f"<codex-mode>{meaning}</codex-mode>" - - -def resolve_breadcrumb_key( - status: str, platform: str | None, config: dict -) -> str: - """Pick the breadcrumb tag key based on Codex dispatch_mode. - - Codex defaults to ``auto`` and therefore uses the ordinary ``<status>`` - breadcrumb for native SubagentStart dispatch with child-side fallback; - it does not depend on an inherited parent transcript. ``inline`` selects - the parallel ``<status>-inline`` tag; ``sub-agent`` remains an alias for - ``auto``. Invalid explicit values fall back to inline without per-turn - warnings. - - Non-codex platforms return the plain status unchanged. - """ - if platform == "codex": - mode = _resolve_codex_dispatch_mode(config) - return f"{status}-inline" if mode == "inline" else status - return status - - -def build_breadcrumb( - task_id: Optional[str], - status: str, - templates: dict[str, str], - source: str | None = None, - breadcrumb_key: str | None = None, -) -> str: - """Build the <workflow-state>...</workflow-state> block. - - - Known status (tag present in workflow.md) → detailed template body - - Unknown status (no tag, or workflow.md missing) → generic - "Refer to workflow.md for current step." line - - `no_task` pseudo-status (task_id is None) → header omits task info - """ - lookup_key = breadcrumb_key or status - body = templates.get(lookup_key) - if body is None and lookup_key != status: - body = templates.get(status) - if body is None: - body = "Refer to workflow.md for current step." - header = f"Status: {status}" if task_id is None else f"Task: {task_id} ({status})" - return f"<workflow-state>\n{header}\n{body}\n</workflow-state>" - - -# --------------------------------------------------------------------------- -# Entry -# --------------------------------------------------------------------------- - -def _load_hook_input() -> dict: - """Read hook JSON without trusting host runners to close stdin. - - Kiro IDE `runCommand` and similar hook runners can leave stdin open while - sending no payload. A plain `json.load(sys.stdin)` then blocks forever. - Normal hook runners write the complete JSON payload and close stdin, so the - short daemon read preserves that path while failing closed to `{}` for - non-piping hosts. - """ - result_queue: "queue.Queue[str | Exception]" = queue.Queue(maxsize=1) - - def _read() -> None: - try: - result_queue.put(sys.stdin.read()) - except Exception as exc: - result_queue.put(exc) - - reader = threading.Thread(target=_read, daemon=True) - reader.start() - try: - raw = result_queue.get(timeout=0.2) - except queue.Empty: - return {} - - if isinstance(raw, Exception): - return {} - try: - data = json.loads(raw) if raw.strip() else {} - except (json.JSONDecodeError, ValueError): - return {} - return data if isinstance(data, dict) else {} - - -def main() -> int: - if os.environ.get("TRELLIS_HOOKS") == "0" or os.environ.get("TRELLIS_DISABLE_HOOKS") == "1": - return 0 - - data = _load_hook_input() - - cwd_str = data.get("cwd") or os.getcwd() - cwd = Path(cwd_str) - - root = find_trellis_root(cwd) - if root is None: - return 0 # not a Trellis project - - config = _read_trellis_config(root) - if prompt_has_skip_keyword(data.get("prompt", ""), _resolve_skip_keyword(config)): - return 0 # user opted out of the per-turn breadcrumb for this turn - - templates = load_breadcrumbs(root) - platform = _detect_platform(data) - task = get_active_task(root, data) - if task is None: - # No active task — still emit a breadcrumb nudging AI toward - # trellis-brainstorm + task.py create when user describes real work. - no_task_key = resolve_breadcrumb_key("no_task", platform, config) - breadcrumb = build_breadcrumb( - None, "no_task", templates, breadcrumb_key=no_task_key - ) - else: - task_id, status, source = task - status_key = resolve_breadcrumb_key(status, platform, config) - source_for_breadcrumb = None if platform == "codex" else source - breadcrumb = build_breadcrumb( - task_id, status, templates, source_for_breadcrumb, breadcrumb_key=status_key - ) - if platform == "codex": - parts: list[str] = [] - if task is None: - parts.append(CODEX_NO_TASK_BOOTSTRAP_NOTICE) - parts.append(_codex_mode_banner(config)) - parts.append(breadcrumb) - breadcrumb = "\n\n".join(parts) - - # Kiro (CLI userPromptSubmit / IDE promptSubmit) adds a hook's stdout - # directly to the conversation context — no JSON envelope. Emit the bare - # breadcrumb text. Conditionally isolated: all other platforms keep the - # hookSpecificOutput JSON path below unchanged. - if platform == "kiro": - print(breadcrumb) - return 0 - - # Gemini CLI 0.40.x rejects "UserPromptSubmit" — its per-turn event is - # named "BeforeAgent". Other platforms (Claude/Cursor/Qoder/CodeBuddy/ - # Droid/Codex/Copilot) accept the original Claude-style name. - hook_event_name = ( - "BeforeAgent" if platform == "gemini" else "UserPromptSubmit" - ) - - output = { - "hookSpecificOutput": { - "hookEventName": hook_event_name, - "additionalContext": breadcrumb, - } - } - print(json.dumps(output)) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.codex/hooks/session-start.py b/.codex/hooks/session-start.py deleted file mode 100644 index ca5608f..0000000 --- a/.codex/hooks/session-start.py +++ /dev/null @@ -1,551 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Codex Session Start Hook - Inject Trellis context into Codex sessions. - -Output format follows Codex hook protocol: - stdout JSON → { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: "..." } } -""" - -from __future__ import annotations - -import json -import os -import re -import subprocess -import sys -import warnings -from io import StringIO -from pathlib import Path - -# Force UTF-8 on stdin/stdout/stderr on Windows. Default codepage there is -# cp936 / cp1252 / etc. — non-ASCII content (Chinese task names, prd snippets) -# both in stdin (hook payload from host CLI) and stdout (our emitted blocks) -# raises UnicodeDecodeError / UnicodeEncodeError. Equivalent to `python -X utf8` -# but applied per-stream so we don't depend on host CLI's command wiring. -if sys.platform.startswith("win"): - import io as _io - for _stream_name in ("stdin", "stdout", "stderr"): - _stream = getattr(sys, _stream_name, None) - if _stream is None: - continue - if hasattr(_stream, "reconfigure"): - try: - _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] - except Exception: - pass # Optional Windows stream setup; keep hook startup non-fatal. - elif hasattr(_stream, "detach"): - try: - setattr(sys, _stream_name, _io.TextIOWrapper(_stream.detach(), encoding="utf-8", errors="replace")) - except Exception: - pass # Optional Windows stream setup; keep hook startup non-fatal. - - -def _normalize_windows_shell_path(path_str: str) -> str: - """Normalize Unix-style shell paths to real Windows paths. - - On Windows, shells like Git Bash / MSYS2 / Cygwin may report paths like - `/d/Users/...` or `/cygdrive/d/Users/...`. `Path.resolve()` will misinterpret - these as `D:/d/Users...` on drive D: (or similar), breaking repo root - detection. - - This function is intentionally conservative: it only rewrites patterns that - unambiguously represent a drive letter mount. - """ - if not isinstance(path_str, str) or not path_str: - return path_str - - # Only relevant on Windows; keep other platforms untouched. - if not sys.platform.startswith("win"): - return path_str - - p = path_str.strip() - - # Already a Windows drive path (C:\... or C:/...) - if re.match(r"^[A-Za-z]:[\/]", p): - return p - - # MSYS/Git-Bash style: /c/Users/... or /d/Work/... - m = re.match(r"^/([A-Za-z])/(.*)", p) - if m: - drive, rest = m.group(1).upper(), m.group(2) - rest = rest.replace('/', '\\') - return f"{drive}:\\{rest}" - - # Cygwin style: /cygdrive/c/Users/... - m = re.match(r"^/cygdrive/([A-Za-z])/(.*)", p) - if m: - drive, rest = m.group(1).upper(), m.group(2) - rest = rest.replace('/', '\\') - return f"{drive}:\\{rest}" - - # WSL mounted drive (sometimes leaked into env): /mnt/c/Users/... - m = re.match(r"^/mnt/([A-Za-z])/(.*)", p) - if m: - drive, rest = m.group(1).upper(), m.group(2) - rest = rest.replace('/', '\\') - return f"{drive}:\\{rest}" - - return path_str - - -warnings.filterwarnings("ignore") - -FIRST_REPLY_NOTICE = """<first-reply-notice> -On the first visible assistant reply in this session, briefly acknowledge that Trellis SessionStart context loaded. -Choose the acknowledgment language in this order: -1. Use the language of the user's current request (the user message that triggered this reply). -2. If that request has no clear natural language, use an explicitly established project communication language. -3. If neither provides a language, output the language-neutral fallback exactly: `Trellis SessionStart ✓`. -Continue directly with the user's request after the acknowledgment. -The acknowledgment must not alter the language used for the remainder of the response. -This notice is one-shot: do not repeat it after the first visible assistant reply in this session. -</first-reply-notice>""" - - -def should_skip_injection() -> bool: - if os.environ.get("TRELLIS_HOOKS") == "0": - return True - if os.environ.get("TRELLIS_DISABLE_HOOKS") == "1": - return True - return os.environ.get("CODEX_NON_INTERACTIVE") == "1" - - -def configure_project_encoding(project_dir: Path) -> None: - """Reuse Trellis' shared Windows stdio encoding helper before JSON output.""" - scripts_dir = project_dir / ".trellis" / "scripts" - if str(scripts_dir) not in sys.path: - sys.path.insert(0, str(scripts_dir)) - - try: - from common import configure_encoding # type: ignore[import-not-found] - - configure_encoding() - except Exception: - pass # Optional encoding helper; host defaults are still usable. - - -def _has_curated_jsonl_entry(jsonl_path: Path) -> bool: - """Return True iff jsonl has at least one row with a ``file`` field. - - A freshly seeded jsonl only contains a ``{"_example": ...}`` row (no - ``file`` key) — that is NOT "ready". Readiness requires at least one - curated entry. Matches the contract used by ``inject-subagent-context.py``. - """ - try: - for line in jsonl_path.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if not line: - continue - try: - row = json.loads(line) - except json.JSONDecodeError: - continue - if isinstance(row, dict) and row.get("file"): - return True - except (OSError, UnicodeDecodeError): - return False - return False - - -def read_file(path: Path, fallback: str = "") -> str: - try: - return path.read_text(encoding="utf-8") - except (FileNotFoundError, PermissionError): - return fallback - - -def _resolve_context_key(project_dir: Path, hook_input: dict) -> str | None: - scripts_dir = project_dir / ".trellis" / "scripts" - if str(scripts_dir) not in sys.path: - sys.path.insert(0, str(scripts_dir)) - try: - from common.active_task import resolve_context_key # type: ignore[import-not-found] - except Exception: - return None - return resolve_context_key(hook_input, platform="codex") - - -def _resolve_active_task(trellis_dir: Path, hook_input: dict): - scripts_dir = trellis_dir / "scripts" - if str(scripts_dir) not in sys.path: - sys.path.insert(0, str(scripts_dir)) - from common.active_task import resolve_active_task # type: ignore[import-not-found] - - return resolve_active_task(trellis_dir.parent, hook_input, platform="codex") - - -def run_script(script_path: Path, context_key: str | None = None) -> str: - try: - env = os.environ.copy() - env["PYTHONIOENCODING"] = "utf-8" - if context_key: - env["TRELLIS_CONTEXT_ID"] = context_key - cmd = [sys.executable, "-W", "ignore", str(script_path)] - result = subprocess.run( - cmd, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=5, - cwd=str(script_path.parent.parent.parent), - env=env, - ) - return result.stdout if result.returncode == 0 else "No context available" - except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError): - return "No context available" - - -def _normalize_task_ref(task_ref: str) -> str: - normalized = task_ref.strip() - if not normalized: - return "" - - path_obj = Path(normalized) - if path_obj.is_absolute(): - return str(path_obj) - - normalized = normalized.replace("\\", "/") - while normalized.startswith("./"): - normalized = normalized[2:] - - if normalized.startswith("tasks/"): - return f".trellis/{normalized}" - - return normalized - - -def _resolve_task_dir(trellis_dir: Path, task_ref: str) -> Path: - normalized = _normalize_task_ref(task_ref) - path_obj = Path(normalized) - if path_obj.is_absolute(): - return path_obj - if normalized.startswith(".trellis/"): - return trellis_dir.parent / path_obj - return trellis_dir / "tasks" / path_obj - - -def _get_task_status(trellis_dir: Path, hook_input: dict) -> str: - active = _resolve_active_task(trellis_dir, hook_input) - if not active.task_path: - return ( - "Status: NO ACTIVE TASK\n" - "Next: Classify the current turn and ask for task-creation consent " - "before creating any Trellis task." - ) - - task_ref = active.task_path - task_dir = _resolve_task_dir(trellis_dir, task_ref) - if active.stale or not task_dir.is_dir(): - return ( - f"Status: STALE POINTER\nTask: {task_ref}\n" - "Next: Task directory not found. Run: python3 ./.trellis/scripts/task.py finish" - ) - - task_json_path = task_dir / "task.json" - task_data: dict = {} - if task_json_path.is_file(): - try: - task_data = json.loads(task_json_path.read_text(encoding="utf-8")) - except (json.JSONDecodeError, PermissionError): - pass # Optional task metadata; fall back to generic status. - - task_title = task_data.get("title", task_ref) - task_status = task_data.get("status", "unknown") - - if task_status == "completed": - return ( - f"Status: COMPLETED\nTask: {task_title}\n" - f"Next: Archive with `python3 ./.trellis/scripts/task.py archive {task_dir.name}` " - "or start a new task." - ) - - has_prd = (task_dir / "prd.md").is_file() - has_design = (task_dir / "design.md").is_file() - has_implement = (task_dir / "implement.md").is_file() - present = [ - name - for name in ("prd.md", "design.md", "implement.md", "implement.jsonl", "check.jsonl") - if (task_dir / name).is_file() - ] - present_line = ", ".join(present) if present else "none" - - if not has_prd: - return ( - f"Status: PLANNING\nTask: {task_title}\nPresent: {present_line}\n" - "Next: Load trellis-brainstorm and write prd.md. Stay in planning." - ) - - if task_status == "planning": - if has_design and has_implement: - next_action = "Review planning artifacts with the user before `task.py start`." - else: - next_action = ( - "Lightweight task can ask for start review with PRD-only; " - "complex task must add design.md and implement.md before `task.py start`." - ) - return ( - f"Status: PLANNING\nTask: {task_title}\nPresent: {present_line}\n" - f"Next: {next_action}" - ) - - return ( - f"Status: {task_status.upper()}\nTask: {task_title}\nPresent: {present_line}\n" - "Next: Follow the matching per-turn workflow-state. Context order is jsonl entries, " - "prd.md, design.md if present, implement.md if present." - ) - - -def _run_git(repo_root: Path, args: list[str]) -> str: - try: - result = subprocess.run( - ["git", *args], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=3, - cwd=str(repo_root), - ) - except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError): - return "" - if result.returncode != 0: - return "" - return result.stdout.strip() - - -def _format_git_state(repo_root: Path) -> str: - branch = _run_git(repo_root, ["branch", "--show-current"]) or "(detached)" - dirty_lines = [ - line for line in _run_git(repo_root, ["status", "--porcelain"]).splitlines() - if line.strip() - ] - dirty_text = "clean" if not dirty_lines else f"dirty {len(dirty_lines)} paths" - return f"Git: branch {branch}; {dirty_text}." - - -def _repo_relative(repo_root: Path, path: Path) -> str: - try: - return path.relative_to(repo_root).as_posix() - except ValueError: - return str(path) - - -def _collect_spec_index_paths(trellis_dir: Path) -> list[str]: - paths: list[str] = [] - guides_index = trellis_dir / "spec" / "guides" / "index.md" - if guides_index.is_file(): - paths.append(".trellis/spec/guides/index.md") - - spec_dir = trellis_dir / "spec" - if not spec_dir.is_dir(): - return paths - - for sub in sorted(spec_dir.iterdir()): - if not sub.is_dir() or sub.name.startswith(".") or sub.name == "guides": - continue - index_file = sub / "index.md" - if index_file.is_file(): - paths.append(f".trellis/spec/{sub.name}/index.md") - continue - for nested in sorted(sub.iterdir()): - if not nested.is_dir(): - continue - nested_index = nested / "index.md" - if nested_index.is_file(): - paths.append(f".trellis/spec/{sub.name}/{nested.name}/index.md") - - return paths - - -def _build_compact_current_state( - trellis_dir: Path, - hook_input: dict, - spec_index_paths: list[str], -) -> str: - repo_root = trellis_dir.parent - lines: list[str] = [] - - try: - from common.paths import get_active_journal_file, get_developer, get_tasks_dir, count_lines # type: ignore[import-not-found] - from common.tasks import iter_active_tasks # type: ignore[import-not-found] - except Exception: - get_active_journal_file = None # type: ignore[assignment] - get_developer = None # type: ignore[assignment] - get_tasks_dir = None # type: ignore[assignment] - count_lines = None # type: ignore[assignment] - iter_active_tasks = None # type: ignore[assignment] - - developer = get_developer(repo_root) if get_developer else None - lines.append(f"Developer: {developer or '(not initialized)'}") - lines.append(_format_git_state(repo_root)) - - active = _resolve_active_task(trellis_dir, hook_input) - if active.task_path: - task_dir = _resolve_task_dir(trellis_dir, active.task_path) - status = "unknown" - task_json = task_dir / "task.json" - if task_json.is_file(): - try: - data = json.loads(task_json.read_text(encoding="utf-8")) - if isinstance(data, dict): - status = str(data.get("status") or "unknown") - except (json.JSONDecodeError, OSError): - pass # Optional task metadata; fall back to generic status. - lines.append(f"Current task: {_repo_relative(repo_root, task_dir)}; status={status}.") - else: - lines.append("Current task: none.") - - if get_tasks_dir and iter_active_tasks: - try: - task_count = sum(1 for _ in iter_active_tasks(get_tasks_dir(repo_root))) - lines.append( - f"Active tasks: {task_count} total. Use `python3 ./.trellis/scripts/task.py list --mine` only if needed." - ) - except Exception: - pass # Optional task summary; keep compact state available. - - if get_active_journal_file and count_lines: - journal = get_active_journal_file(repo_root) - if journal: - lines.append( - f"Journal: {_repo_relative(repo_root, journal)}, {count_lines(journal)} / 2000 lines." - ) - - if spec_index_paths: - lines.append(f"Spec indexes: {len(spec_index_paths)} available.") - - return "\n".join(lines) - - -def _extract_range(content: str, start_header: str, end_header: str) -> str: - """Extract lines starting at `## start_header` up to (but excluding) `## end_header`.""" - lines = content.splitlines() - start: "int | None" = None - end: int = len(lines) - start_match = f"## {start_header}" - end_match = f"## {end_header}" - for i, line in enumerate(lines): - stripped = line.strip() - if start is None and stripped == start_match: - start = i - continue - if start is not None and stripped == end_match: - end = i - break - if start is None: - return "" - return "\n".join(lines[start:end]).rstrip() - - -_BREADCRUMB_TAG_RE = re.compile( - r"\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n.*?\n\s*\[/workflow-state:\1\]", - re.DOTALL, -) - - -def _strip_breadcrumb_tag_blocks(content: str) -> str: - stripped = _BREADCRUMB_TAG_RE.sub("", content) - stripped = re.sub(r"<!--.*?-->", "", stripped, flags=re.DOTALL) - stripped = re.sub(r"^\[(?!/?workflow-state:)/?[^\]\n]+\]\s*\n?", "", stripped, flags=re.MULTILINE) - return re.sub(r"\n{3,}", "\n\n", stripped).strip() - - -def _build_workflow_toc(workflow_path: Path) -> str: - """Inject only the compact Phase Index summary for SessionStart.""" - content = read_file(workflow_path) - if not content: - return "No workflow.md found" - - out_lines = [ - "# Development Workflow - Session Summary", - "Full guide: .trellis/workflow.md. Step detail: `python3 ./.trellis/scripts/get_context.py --mode phase --step <X.Y>`.", - "", - ] - - phases = _extract_range(content, "Phase Index", "Phase 1: Plan") - if phases: - out_lines.append(_strip_breadcrumb_tag_blocks(phases).rstrip()) - - return "\n".join(out_lines).rstrip() - - -def main() -> None: - if should_skip_injection(): - sys.exit(0) - - # Read hook input from stdin - try: - hook_input = json.loads(sys.stdin.read()) - if not isinstance(hook_input, dict): - hook_input = {} - project_dir = Path(_normalize_windows_shell_path(hook_input.get("cwd", "."))).resolve() - except (json.JSONDecodeError, KeyError): - hook_input = {} - project_dir = Path(".").resolve() - - configure_project_encoding(project_dir) - - trellis_dir = project_dir / ".trellis" - spec_index_paths = _collect_spec_index_paths(trellis_dir) - - output = StringIO() - - output.write("""<session-context> -Trellis compact SessionStart context. Use it to orient the session; load details on demand. -</session-context> - -""") - output.write(FIRST_REPLY_NOTICE) - output.write("\n\n") - - output.write("<current-state>\n") - output.write(_build_compact_current_state(trellis_dir, hook_input, spec_index_paths)) - output.write("\n</current-state>\n\n") - - output.write("<trellis-workflow>\n") - output.write(_build_workflow_toc(trellis_dir / "workflow.md")) - output.write("\n</trellis-workflow>\n\n") - - output.write("<guidelines>\n") - output.write( - "Task context order for implementation/check: jsonl entries -> `prd.md` -> " - "`design.md if present` -> `implement.md if present`. Missing optional artifacts " - "are skipped for lightweight tasks.\n\n" - ) - - if spec_index_paths: - output.write("## Available indexes (read on demand)\n") - for p in spec_index_paths: - output.write(f"- {p}\n") - output.write("\n") - - output.write( - "Discover more via: " - "`python3 ./.trellis/scripts/get_context.py --mode packages`\n" - ) - output.write("</guidelines>\n\n") - - task_status = _get_task_status(trellis_dir, hook_input) - output.write(f"<task-status>\n{task_status}\n</task-status>\n\n") - - output.write("""<ready> -Context loaded. Follow <task-status>. Load workflow/spec/task details only when needed. -</ready>""") - - context = output.getvalue() - result = { - "suppressOutput": True, - "systemMessage": f"Trellis context injected ({len(context)} chars)", - "hookSpecificOutput": { - "hookEventName": "SessionStart", - "additionalContext": context, - }, - } - - print(json.dumps(result, ensure_ascii=False), flush=True) - - -if __name__ == "__main__": - main() diff --git a/.gitattributes b/.gitattributes index bcebc3c..9588c17 100644 --- a/.gitattributes +++ b/.gitattributes @@ -23,13 +23,3 @@ third_party/zashboard/dist/** -text -diff -whitespace *.pdf binary *.pcbdoc binary *.wav binary - -# Trellis: append-only developer journals should merge cleanly across -# parallel sessions/worktrees — each session only appends a new block, so -# there is nothing to actually conflict on. -# -# Do NOT add a rule for workspace/*/index.md here — it is fully regenerated -# every session, so a real conflict there is expected and safe to resolve by -# picking either side (task state lives in task.json, not index.md). See -# .trellis/spec/cli/backend/directory-structure.md for details. -.trellis/workspace/*/journal-*.md merge=union diff --git a/.trellis/.gitignore b/.trellis/.gitignore deleted file mode 100644 index 5a991ea..0000000 --- a/.trellis/.gitignore +++ /dev/null @@ -1,32 +0,0 @@ -# Developer identity (local only) -.developer - -# Current task pointer (each dev works on different task) -.current-task - -# Session/window scoped runtime state -.runtime/ - -# Ralph Loop state file -.ralph-state.json - -# Agent runtime files -.agents/ -.agent-log -.session-id - -# Task directory runtime files -.plan-log - -# Atomic update temp files -*.tmp - -# Update backup directories -.backup-* - -# Conflict resolution temp files -*.new - -# Python cache -**/__pycache__/ -**/*.pyc diff --git a/.trellis/.template-hashes.json b/.trellis/.template-hashes.json deleted file mode 100644 index 583c349..0000000 --- a/.trellis/.template-hashes.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "__version": 2, - "hashes": { - ".agents/skills/trellis-continue/SKILL.md": "7723ccf49fbf19d8f086cacc7a080bd8be8db6fc70a32908b80f68efa318d7bf", - ".agents/skills/trellis-finish-work/SKILL.md": "161060fbcd44f787440d3a5c297a9f5223ea7774bb3021a50e376875a9ac5b2d", - ".agents/skills/trellis-start/SKILL.md": "79a5ba7a2aff3c72e06d7f4cd6942dc4f4f4092dd40f9c8e94f1838024a81e4d", - ".agents/skills/trellis-before-dev/SKILL.md": "00c9d1c83bc318e91b27a019bc954c2957cfad907ff20d854b596f16cad2c0f3", - ".agents/skills/trellis-brainstorm/SKILL.md": "a0f226ddcb8a3e846acd2a35d121996e9ca55165ce76202095d0b65e2b48a5e8", - ".agents/skills/trellis-break-loop/SKILL.md": "f5a93699832f29dee443b53c135a7459519b371f689af191d15c29e8ee5c7bde", - ".agents/skills/trellis-check/SKILL.md": "dfb0600e95c19c7a83200465b6a92a5514ec9778ba4083efa881bed541cc74a8", - ".agents/skills/trellis-update-spec/SKILL.md": "003ce08a3404aeb50998029392c4d4e57b626edf526d3ebd585032bb92dcbb96", - ".agents/skills/trellis-channel/references/command-reference.md": "c4df3d940d89814310bfdaded20e84dd1d6b96786f6772688de7a123338d3c8e", - ".agents/skills/trellis-channel/references/forum.md": "407db39e8ebde47cea8637e3069bdb3d76ba42b43ed1d08ba97a56f26dbcfcb4", - ".agents/skills/trellis-channel/references/progress-debugging.md": "93b021fd8d5d3d4ee54952c40424ab91ca57c2ab8032bfbfc3b5c5e22f1464cd", - ".agents/skills/trellis-channel/references/workers.md": "d4481b2858bca82050cb8d131916da8a2ccc3857f199cdb9ced8bb41d592e2a7", - ".agents/skills/trellis-channel/references/workflows.md": "0782d602efde6e94a7d7d7950fd605eedc9a1f5a523908284e76b628fd618fd4", - ".agents/skills/trellis-channel/SKILL.md": "ff84801c511a736da018b4b1149348ebc9a16b7fc18d905ec10cfa7acbf269a3", - ".agents/skills/trellis-meta/references/customize-local/add-project-local-conventions.md": "86009ccb5d0373f399582da0bc570c4e5c6053c3c764857424ff93384f0e04e5", - ".agents/skills/trellis-meta/references/customize-local/change-agents.md": "4216ac3cc570038fd8ce3319a85932bb537fea9e0a7f4d11fa315cfa645c9c85", - ".agents/skills/trellis-meta/references/customize-local/change-context-loading.md": "350d319dc1ab99609ddbf52cf8c06c71bd97ba1a29ce2eac8b97d0bb192938bf", - ".agents/skills/trellis-meta/references/customize-local/change-hooks.md": "4c18b134f05d5ba1609c517ec94c97c9442b5f0670aa2211c487c31ed2a4f358", - ".agents/skills/trellis-meta/references/customize-local/change-skills-or-commands.md": "5b942e9f512e049a75e8dd9e8cc4d49786f6da61b22afc82bb447acf808f9fe2", - ".agents/skills/trellis-meta/references/customize-local/change-spec-structure.md": "31eccaad7097d96e66a45c1b4caea1ba4f2e54b7814184c3ebf82c87dafc4841", - ".agents/skills/trellis-meta/references/customize-local/change-task-lifecycle.md": "60ff9efb93604b87a461a4af30322d76750402a51e40f31531a7ff88d309996d", - ".agents/skills/trellis-meta/references/customize-local/change-workflow.md": "43fa780a2ca580de121b10893d49b99f978873deebbf45008c466e5ac6651519", - ".agents/skills/trellis-meta/references/customize-local/overview.md": "ce8f09e9f93ce9a48500763fb3a4db2b3908a5fbf4f985ab71dacebb404cf8f4", - ".agents/skills/trellis-meta/references/local-architecture/bundled-skills.md": "aa6a0bf83060205ee4ea621c467fb900a7db06b4476a4ad472cc4e248c887389", - ".agents/skills/trellis-meta/references/local-architecture/context-injection.md": "8497289bf333b3aa456f317039d1239b7ece79254aa0eb62cfc647714c866084", - ".agents/skills/trellis-meta/references/local-architecture/generated-files.md": "7eb2d452eddb4f4226f7578c2ec6d5ee0434ed172ba4c36107cc8bdff7554dc6", - ".agents/skills/trellis-meta/references/local-architecture/multi-agent-channel.md": "56e5070474aeca872e2d70c46feea5aaafecd3d3ec052c3f7b1877358dca62e9", - ".agents/skills/trellis-meta/references/local-architecture/overview.md": "50638fd9eaaba2e0edf2f2a84d920578d5b2fb7934031b174e417d3dc510b2a6", - ".agents/skills/trellis-meta/references/local-architecture/spec-system.md": "b8d8a6a0888b44a232c8f50161b9e20e903cf621ad7be4021715ab6fab226f47", - ".agents/skills/trellis-meta/references/local-architecture/task-system.md": "2b561d49c390f7d0db5391912946133be4bf73189231e2b8cc9afa1c5ac6165a", - ".agents/skills/trellis-meta/references/local-architecture/workflow.md": "cfcdc6e4468a5d9c816e929fcca01640cd41cfdaaa4824118b40a8e460c927b6", - ".agents/skills/trellis-meta/references/local-architecture/workspace-memory.md": "e6427b46aba744563c2444b30df4043cd856561b7709ec2dece26095416421fd", - ".agents/skills/trellis-meta/references/platform-files/agents.md": "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": "3c0d4546461c06d1aaf006276e34e250e0ca7bbb5b853fb45c58114cebca4042", - ".agents/skills/trellis-meta/references/platform-files/skills-and-commands.md": "e39831d860bd27a04e7757f7bd941ab83b3c5b11ad4460cec03975b655f26cc3", - ".agents/skills/trellis-meta/SKILL.md": "ee7bcfd0023def62688339c4b63c50f2fbeea12a34eb89e8bb63a992b58cb168", - ".agents/skills/trellis-session-insight/references/cli-quick-reference.md": "c520353fe3fc00b9702ed4f780647c8fbd6d342e66527a03647f533a9fe09779", - ".agents/skills/trellis-session-insight/references/triggering-patterns.md": "121ecd23be83d1567e8ce15c366a81073d7a2b1d3ad616fce235c07ca1f1cc20", - ".agents/skills/trellis-session-insight/SKILL.md": "d20f1d20c6946e26ba6f848b9a016d9a16c803d87501462e5b11a2ced6f56643", - ".agents/skills/trellis-spec-bootstrap/references/mcp-setup.md": "df542fc8f279edd38046d26a7c8151804b708f57b24d4aa2733cea587a88c65e", - ".agents/skills/trellis-spec-bootstrap/references/repository-analysis.md": "0dae98d774f6e34559b9f3442888ac43e3a8af110c37cbefc49ce256986858b6", - ".agents/skills/trellis-spec-bootstrap/references/spec-task-planning.md": "ef493d028c3b0807a8a534bb71fb92a68129f273db763ad27ceb464a522e799d", - ".agents/skills/trellis-spec-bootstrap/references/spec-writing.md": "e9800fe9ed4a4cd87062ea1829cf2caa8d170ec15e141678a6a30e74c497f47d", - ".agents/skills/trellis-spec-bootstrap/SKILL.md": "97bfa68c06cebb558eb4464bc1b81f7d2d56040d75baa8de1ee5ad90cca0196a", - ".codex/agents/trellis-check.toml": "7ca59196a218c22fbac1765ac1e7b58b44fb45c44713694d17d9bc1f6728b116", - ".codex/agents/trellis-implement.toml": "7abe4a58bf11ff5ad731c26c65b34f5882fec127fc4d6bef4e8dba19ff6c2e1d", - ".codex/agents/trellis-research.toml": "526c8354c6e9c0fb7253a294db69ddb23ed0822c5374b106ceea7c2b494b28e3", - ".codex/hooks/session-start.py": "14de3be1cf6eb9c9feba348d8998b407f3837d6c0756b74210c9200543440677", - ".codex/hooks/inject-subagent-context.py": "7c2c5640445ea0d98ce6a13126ea105ec4b02ca661ef4a7711240b595efefbbf", - ".codex/hooks/inject-workflow-state.py": "89cef2b197dd61a731b946114207d2601b240058941ccf28622ee211d9c62eb3", - ".codex/hooks.json": "85a58ba7cdf1e19e7f75ddcc64e5680180c487ca266a74bd5005f31abeee2e02", - ".codex/config.toml": "9f2d20e28f0bc9c886312eca3ad3bba41533ef4615aaaafe25e98152302267bb", - "AGENTS.md": "6cacfe99748b435d0660c2463c697bc323d53798aecf3492283ca8eac1b29682", - ".trellis/agents/check.md": "edb4f57361407249a53bf5998ebf91c40d2b969e826a2c5e1b4e813a08bcb175", - ".trellis/agents/implement.md": "66e25ad046c94869442834bc3cdfbd5a9a7412d3ff54561d64d2886552c27e87", - ".trellis/config.yaml": "a966e6d374e9e6ff283cf761ccd99631323ee1754856cef51ad154ca0afb9dfa", - ".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c", - ".trellis/scripts/add_session.py": "876dad478edf70db59acccaae9cb4db646a155681f730bd99af48de72ddc9881", - ".trellis/scripts/common/__init__.py": "3d5e9347141f0296319a5beb29d69ae714c5a474b9078caeb3edd7c5f6562e22", - ".trellis/scripts/common/active_task.py": "bd15c5ee7810814dad915d898889323b3ea97fde92777b053b7ae734d56768bc", - ".trellis/scripts/common/cli_adapter.py": "5d6bd9d6f5c631e7e792db7dd343351317f9643bc87b73a9a98abd51cefb4307", - ".trellis/scripts/common/config.py": "8d2e5f8ccfcd5f622cd2af002aa761f3d3ffcc653182fefb2268afd102e77bca", - ".trellis/scripts/common/developer.py": "f5f833123abe68890171b4da825a324216d24913f6b5ad9245afc556424ffd7b", - ".trellis/scripts/common/git.py": "6fc5845d0104dd506ebd8b366a24cb4b1e3d8777e4e6acc12ea15c9d8e2662f2", - ".trellis/scripts/common/git_context.py": "fa30ced454f1a91ffc9f8b2abeb32225e3447cbdc90bad783797374eba07265d", - ".trellis/scripts/common/io.py": "75648caae03d5b1107d7aeccaa785d133b25762266e54a520d90ca8c76b43bdb", - ".trellis/scripts/common/log.py": "471df6895cfac80f995edebbf9974f6b7440634b7a688f28b8331c868bc0f3cf", - ".trellis/scripts/common/packages_context.py": "efe158d7c99c2268851d0216fbb08de22836e418a8dbeb73575b8cc249eed7b7", - ".trellis/scripts/common/paths.py": "c26b75bc211b290e7c21ec96fb1db36f282b3ecbb7d2c81475d258a48ea55fb8", - ".trellis/scripts/common/safe_commit.py": "baa5c82324eb62154374ec63394ecdc8609bb37d93892e3bcb88f452bb7d6446", - ".trellis/scripts/common/session_context.py": "3379ef1766e4e5ca77cbb7c040dbba3883fcca2548580299e3b38dbf22f4f7d5", - ".trellis/scripts/common/task_context.py": "6fc3abb9e483043bc8cc3477ae48e5503399402009fbfc58db80809472c5690b", - ".trellis/scripts/common/task_queue.py": "0be61f713462b1fe4574927c82fc4704e678afe72dcb9813543aedf2f9e9e0c5", - ".trellis/scripts/common/task_store.py": "9b05a40113439e2841271f2fc1e616fbef0b6929e7c064dd41350658e4ed203e", - ".trellis/scripts/common/task_utils.py": "07a599c028b2f7aa4014f56702374aade98c2d1a6ea87b314098e5e86cacf7fe", - ".trellis/scripts/common/tasks.py": "4436a8b0b53c270a35989e26d9dbd92669408c6562d88c02083a404562da85fe", - ".trellis/scripts/common/trellis_config.py": "e282e897183e3ec2f4e6e56349431946e5f98c1c31d3eca4de7fc44e1383a7bf", - ".trellis/scripts/common/types.py": "9962081cc2608fb9d1deb32c6880e336f62cdca6b338e7ae813304701e155ee9", - ".trellis/scripts/common/workflow_phase.py": "79ee522de20246acf1e2c222e8ad180ad25aaec7fec98214a93d9e81b350d9a8", - ".trellis/scripts/get_context.py": "ca5bf9e90bdb1d75d3de182b95f820f9d108ab28793d29097b24fd71315adcf5", - ".trellis/scripts/get_developer.py": "84c27076323c3e0f2c9c8ed16e8aa865e225d902a187c37e20ee1a46e7142d8f", - ".trellis/scripts/hooks/linear_sync.py": "e09cc4ce4699aada908808718698f33f705a3edf55c4dcf8f777ad892f80ca79", - ".trellis/scripts/init_developer.py": "f9e6c0d882406e81c8cd6b1c5abb204b0befc0069ff89cf650cd536a80f8c60e", - ".trellis/scripts/task.py": "152d7298db25b86756583faaba763eacd8611c0070e8f1c176b8257d687102b9", - ".trellis/workflow.md": "c694bb7901d48f224ccad940178cff81e28dda46e6e5b24bad67c83970d67808" - } -} \ No newline at end of file diff --git a/.trellis/.version b/.trellis/.version deleted file mode 100644 index 7e3c84c..0000000 --- a/.trellis/.version +++ /dev/null @@ -1 +0,0 @@ -0.6.15 \ No newline at end of file diff --git a/.trellis/agents/check.md b/.trellis/agents/check.md deleted file mode 100644 index 6c1bf13..0000000 --- a/.trellis/agents/check.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -name: check -description: | - Code quality auditor for the Trellis channel runtime. Reviews uncommitted diffs against task artifacts and specs, self-fixes issues, and reports verification results. -provider: claude -labels: [trellis, check] ---- - -# Check Agent (channel runtime) - -You are the Check Agent spawned by `trellis channel spawn --agent check` inside the Trellis channel runtime. You receive an `Active task: <path>` line in your inbox; use it to locate task artifacts on disk. - -## Context - -Before reviewing, read in this order: - -1. `<task-path>/check.jsonl` if present — spec manifest curated for this turn; read every listed file -2. `<task-path>/prd.md` — requirements -3. `<task-path>/design.md` if present — technical design -4. `<task-path>/implement.md` if present — execution plan -5. `.trellis/spec/` — project-wide guidelines (load only what is relevant to the diff under review) - -## Core Responsibilities - -1. **Get the diff** — `git diff` / `git diff --staged` for uncommitted changes -2. **Review against task artifacts** — does the diff satisfy `prd.md` (and `design.md` / `implement.md` if present)? -3. **Review against specs** — naming, structure, type safety, error handling, conventions in `.trellis/spec/` -4. **Self-fix** — when an issue is mechanical and small, fix it directly with the editing tools you have -5. **Run verification** — project lint and typecheck on the changed scope -6. **Report** — concrete findings with `file:line` citations and what was fixed vs. what is open - -## Forbidden Operations - -- `git commit` -- `git push` -- `git merge` - -The supervising main session owns commits. Report the post-fix state; do not commit on its behalf. - -## Workflow - -1. Run `git diff --name-only` and `git diff` to scope the changes -2. Read the task artifacts and relevant spec files -3. For each issue: - - If mechanical (lint nit, missing type, wrong import, dead branch) → fix in-place - - If a design/judgment issue → record and report, do not silently rewrite -4. Run the project's lint and typecheck on the changed scope after self-fixes -5. Report - -## Report Format - -``` -## Self-Check Complete - -### Files Checked -- <path> - -### Issues Found and Fixed -1. `<file>:<line>` — <what was wrong> → <what you changed> - -### Issues Not Fixed -- `<file>:<line>` — <issue> — <why deferred to the main session> - -### Verification Results -- TypeCheck: <pass|fail|skipped + reason> -- Lint: <pass|fail|skipped + reason> - -### Summary -Checked <N> files, found <X> issues, fixed <Y>, <X-Y> open. -``` diff --git a/.trellis/agents/implement.md b/.trellis/agents/implement.md deleted file mode 100644 index 3262f79..0000000 --- a/.trellis/agents/implement.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -name: implement -description: | - Code implementation expert for the Trellis channel runtime. Understands specs and task artifacts, then implements features. No git commit allowed. -provider: claude -labels: [trellis, implement] ---- - -# Implement Agent (channel runtime) - -You are the Implement Agent spawned by `trellis channel spawn --agent implement` inside the Trellis channel runtime. You receive an `Active task: <path>` line in your inbox; use it to locate task artifacts on disk. - -## Context - -Before implementing, read in this order: - -1. `<task-path>/implement.jsonl` if present — spec manifest curated for this turn; read every listed file -2. `<task-path>/prd.md` — requirements -3. `<task-path>/design.md` if present — technical design -4. `<task-path>/implement.md` if present — execution plan -5. `.trellis/spec/` — project-wide guidelines (load only what is relevant to the diff you are about to write) - -## Core Responsibilities - -1. **Understand specs** — read relevant spec files in `.trellis/spec/` -2. **Understand task artifacts** — read the artifacts listed above -3. **Implement features** — write code that follows specs and existing patterns -4. **Self-check** — run lint and typecheck on the changed scope before reporting - -## Forbidden Operations - -- `git commit` -- `git push` -- `git merge` - -The supervising main session owns commits. Report what changed; do not commit on its behalf. - -## Workflow - -1. Read relevant specs based on task type and the files in `implement.jsonl` if present -2. Read the task's `prd.md`, `design.md` if present, and `implement.md` if present -3. Implement features following specs and existing patterns -4. Run the project's lint and typecheck commands on the changed scope -5. Report files touched, key decisions, and verification results back to the channel - -## Code Standards - -- Follow existing code patterns -- Don't add unnecessary abstractions -- Only do what the PRD asks for; no speculative scope expansion -- Surface uncertainty back to the channel rather than guessing - -## Report Format - -``` -## Implementation Complete - -### Files Modified -- <path> — <one-line description> - -### Implementation Summary -1. <step> -2. <step> - -### Verification Results -- Lint: <pass|fail|skipped + reason> -- TypeCheck: <pass|fail|skipped + reason> - -### Open Questions -- <if any, otherwise omit> -``` diff --git a/.trellis/config.yaml b/.trellis/config.yaml deleted file mode 100644 index 92f7297..0000000 --- a/.trellis/config.yaml +++ /dev/null @@ -1,166 +0,0 @@ -# Trellis Configuration -# Project-level settings for the Trellis workflow system -# -# All values have sensible defaults. Only override what you need. - -#------------------------------------------------------------------------------- -# Session Recording -#------------------------------------------------------------------------------- - -# Commit message used when auto-committing journal/index changes -# after running add_session.py -session_commit_message: "chore: record journal" - -# Maximum lines per journal file before rotating to a new one -max_journal_lines: 2000 - -#------------------------------------------------------------------------------- -# Session Auto-Commit -#------------------------------------------------------------------------------- - -# Auto-commit behavior for session journal + task archive operations. -# - true (default): scripts auto-stage and auto-commit journal / task changes -# after add_session.py / task.py archive runs. -# - false: scripts do not touch git. Files (journal-*.md, task archive moves) -# are still written to disk; you decide whether to git add / commit. -# -# Use `false` if your project's .gitignore intentionally excludes `.trellis/` -# and you want session data kept local-only, or if you prefer to review -# staged changes manually before each commit. -# -# Accepts: true / false / yes / no / 1 / 0 / on / off (case-insensitive). -# -# session_auto_commit: true - -#------------------------------------------------------------------------------- -# Task Lifecycle Hooks -#------------------------------------------------------------------------------- - -# Shell commands to run after task lifecycle events. -# Each hook receives TASK_JSON_PATH environment variable pointing to task.json. -# Hook failures print a warning but do not block the main operation. -# -# hooks: -# after_create: -# - "echo 'Task created'" -# after_start: -# - "echo 'Task started'" -# after_finish: -# - "echo 'Task finished'" -# after_archive: -# - "echo 'Task archived'" - -#------------------------------------------------------------------------------- -# Monorepo / Packages -#------------------------------------------------------------------------------- - -# Declare packages for monorepo projects. -# Trellis auto-detects workspaces during `trellis init`, but you can also -# configure them manually here. -# -# packages: -# frontend: -# path: packages/frontend -# backend: -# path: packages/backend -# docs: -# path: docs-site -# type: submodule -# # For polyrepo / meta-repo layouts (independent .git in each subdir), -# # mark the package with `git: true`. The runtime treats it as an -# # independent repository for things like git-context display. -# webapp: -# path: ./webapp -# git: true - -# Default package used when --package is not specified. -# default_package: frontend - -#------------------------------------------------------------------------------- -# Channel worker OOM guard -#------------------------------------------------------------------------------- -# Default safeguards for `trellis channel spawn` workers. The guard runs -# at spawn time (cleans expired idle workers, then enforces the live-worker -# budget) and inside each supervisor (self-terminates a worker that stays -# continuously idle past `idle_timeout`). -# -# Precedence: CLI flag > env var (TRELLIS_CHANNEL_WORKER_IDLE_TIMEOUT / -# TRELLIS_CHANNEL_MAX_LIVE_WORKERS) > this config > built-in default. -# -# `idle_timeout: 0` disables idle cleanup (workers can sit idle forever -# unless explicitly killed or given `--timeout`). -# `max_live_workers: 0` disables the spawn-time budget check. -# -# `trusted_context_dirs` extends the `--file`/`--jsonl`/`--agent` containment -# check beyond the worker cwd — useful when `.trellis/tasks` or -# `.trellis/workspace` is a symlink to an external directory. Realpaths under -# any listed dir are accepted in addition to cwd. -# `auto_trust_trellis_symlinks: false` disables the narrow auto-trust of -# `.trellis/tasks` / `.trellis/workspace` when either is itself a top-level -# symlink (auto-trust is on by default). -# -channel: - worker_guard: - idle_timeout: 5m - max_live_workers: 6 - # trusted_context_dirs: - # - /work/user/trellis_workspace - # auto_trust_trellis_symlinks: false - -#------------------------------------------------------------------------------- -# Codex (dispatch behavior) -#------------------------------------------------------------------------------- -# Codex-only knob; other platforms ignore it. Default ("auto") dispatches -# trellis-implement / trellis-check / trellis-research sub-agents. This does -# not rely on inherited parent transcripts: `fork_turns` remains -# caller-controlled, while Codex's native SubagentStart hook injects task -# context when trusted and child-side loading remains the fallback when it is -# unavailable. Set to "inline" only to keep implementation and checks in the -# main session. "sub-agent" remains a backwards-compatible alias for "auto". -# Invalid explicit values safely use inline mode. -# -# In "auto" mode, dispatched sub-agents inherit the main session's model -# unless you pin one. To use a cheaper/faster model for implement/check/ -# research sub-agent work, edit `model` / `model_reasoning_effort` directly -# on the generated `.codex/agents/trellis-*.toml` files (see the commented -# hint lines in those files) — there is no config.yaml knob for this, -# `trellis update` preserves your edits across regeneration. -# -# codex: -# dispatch_mode: auto # or "inline"; legacy alias: "sub-agent" - -#------------------------------------------------------------------------------- -# Sub-agent context injection limits -#------------------------------------------------------------------------------- -# Caps how much task context (implement.jsonl / check.jsonl referenced files, -# plus prd.md / design.md / implement.md) gets inlined into a sub-agent's -# first prompt. Oversized files are truncated with a notice; once the total -# payload cap is reached, remaining files degrade to index lines (path + -# reason + size) instead of being inlined. -# -# All values are byte counts. `0` disables the corresponding limit. -# -# context_injection: -# max_file_bytes: 32768 # per implement.jsonl / check.jsonl referenced file -# max_artifact_bytes: 65536 # per task artifact (prd.md / design.md / implement.md) -# max_total_bytes: 131072 # whole injected payload; overflow degrades to index lines - -#------------------------------------------------------------------------------- -# Per-turn prompt injection -#------------------------------------------------------------------------------- -# Escape hatch for the per-turn <workflow-state> breadcrumb. When a user -# prompt contains the skip keyword as a standalone word (case-insensitive, -# word-boundary match — "no-trellisfoo" does NOT count), the breadcrumb is -# skipped for that turn only. Does not affect SessionStart or sub-agent -# context injection. -# -# prompt_injection: -# skip_keyword: "no-trellis" # "" disables the escape hatch entirely - -# Auto-detected monorepo packages -packages: - core: - path: . - web: - path: web -default_package: core diff --git a/.trellis/scripts/__init__.py b/.trellis/scripts/__init__.py deleted file mode 100755 index 815a137..0000000 --- a/.trellis/scripts/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -""" -Trellis Python Scripts - -This module provides Python implementations of Trellis workflow scripts. -""" diff --git a/.trellis/scripts/add_session.py b/.trellis/scripts/add_session.py deleted file mode 100755 index 6cd6636..0000000 --- a/.trellis/scripts/add_session.py +++ /dev/null @@ -1,681 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Add a new session to journal file and update index.md. - -Usage: - python3 add_session.py --title "Title" --commit "hash" --summary "Summary" [--package cli] - python3 add_session.py --title "Title" --branch "feat/my-branch" - - # Pipe detailed content via stdin (use --stdin to opt in): - cat << 'EOF' | python3 add_session.py --stdin --title "Title" --summary "Summary" - <session content here> - EOF - - # Structured content (repeatable; a section with no bullets is omitted): - python3 add_session.py --title "Title" --change "Did X" --test "Ran Y" --next-step "Do Z" - -Branch resolution order: - 1. --branch CLI arg (explicit) - 2. task.json branch field (from active task, if still exists) - 3. git branch --show-current (auto-detect) - 4. None (omitted gracefully) -""" - -from __future__ import annotations - -import argparse -import re -import sys -from datetime import datetime -from pathlib import Path - -from common.paths import ( - DIR_TASKS, - DIR_WORKFLOW, - FILE_JOURNAL_PREFIX, - get_repo_root, - get_current_task, - get_developer, - get_workspace_dir, -) -from common.developer import ensure_developer -from common.git import run_git -from common.log import Colors, colored -from common.safe_commit import ( - print_gitignore_warning, - safe_git_add, - safe_trellis_paths_to_add, -) -from common.tasks import load_task -from common.types import TaskInfo -from common.config import ( - get_packages, - get_session_auto_commit, - get_session_commit_message, - get_max_journal_lines, - is_monorepo, - resolve_package, - validate_package, -) - - -# ============================================================================= -# Helper Functions -# ============================================================================= - -def get_latest_journal_info(dev_dir: Path) -> tuple[Path | None, int, int]: - """Get latest journal file info. - - Returns: - Tuple of (file_path, file_number, line_count). - """ - latest_file: Path | None = None - latest_num = -1 - - for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"): - if not f.is_file(): - continue - - match = re.search(r"(\d+)$", f.stem) - if match: - num = int(match.group(1)) - if num > latest_num: - latest_num = num - latest_file = f - - if latest_file: - lines = len(latest_file.read_text(encoding="utf-8").splitlines()) - return latest_file, latest_num, lines - - return None, 0, 0 - - -def get_current_session(index_file: Path) -> int: - """Get current session number from index.md.""" - if not index_file.is_file(): - return 0 - - content = index_file.read_text(encoding="utf-8") - for line in content.splitlines(): - if "Total Sessions" in line: - match = re.search(r":\s*(\d+)", line) - if match: - return int(match.group(1)) - return 0 - - -def _extract_journal_num(filename: str) -> int: - """Extract journal number from filename for sorting.""" - match = re.search(r"(\d+)", filename) - return int(match.group(1)) if match else 0 - - -def count_journal_files(dev_dir: Path, active_num: int) -> str: - """Count journal files and return table rows.""" - active_file = f"{FILE_JOURNAL_PREFIX}{active_num}.md" - result_lines = [] - - files = sorted( - [f for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md") if f.is_file()], - key=lambda f: _extract_journal_num(f.stem), - reverse=True - ) - - for f in files: - filename = f.name - lines = len(f.read_text(encoding="utf-8").splitlines()) - status = "Active" if filename == active_file else "Archived" - result_lines.append(f"| `{filename}` | ~{lines} | {status} |") - - return "\n".join(result_lines) - - -def get_current_git_branch(repo_root: Path) -> str | None: - """Return the current checkout branch, or None for detached/non-git states.""" - rc, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) - if rc != 0: - return None - detected = branch_out.strip() - return detected or None - - -def branch_ref_exists(repo_root: Path, branch: str) -> bool: - """Return True when branch exists locally or as the local origin ref.""" - for ref in (f"refs/heads/{branch}", f"refs/remotes/origin/{branch}"): - rc, _, _ = run_git(["show-ref", "--verify", "--quiet", ref], cwd=repo_root) - if rc == 0: - return True - return False - - -def resolve_session_branch( - repo_root: Path, - cli_branch: str | None, - task_data: TaskInfo | None, -) -> str | None: - """Resolve journal branch without trusting stale task.json branch fields.""" - if cli_branch: - return cli_branch - - current_branch = get_current_git_branch(repo_root) - raw_task_branch = task_data.raw.get("branch") if task_data else None - task_branch = raw_task_branch.strip() if isinstance(raw_task_branch, str) else "" - if not task_branch: - return current_branch - - if branch_ref_exists(repo_root, task_branch): - return task_branch - - if current_branch: - print( - f"Warning: task.json branch '{task_branch}' no longer exists locally or as origin/{task_branch}; using current branch '{current_branch}'.", - file=sys.stderr, - ) - return current_branch - - print( - f"Warning: task.json branch '{task_branch}' no longer exists locally or as origin/{task_branch}; omitting branch.", - file=sys.stderr, - ) - return None - - -def is_git_worktree(repo_root: Path) -> bool: - """Return True when repo_root is a linked worktree (not the main working tree). - - Standard test: `git rev-parse --git-dir` (per-worktree) differs from - `git rev-parse --git-common-dir` (shared across all worktrees) once both - are resolved to absolute paths. In the main working tree these are the - same directory. - """ - rc_dir, git_dir, _ = run_git(["rev-parse", "--git-dir"], cwd=repo_root) - rc_common, git_common_dir, _ = run_git( - ["rev-parse", "--git-common-dir"], cwd=repo_root - ) - if rc_dir != 0 or rc_common != 0: - return False - - git_dir_path = (repo_root / git_dir.strip()).resolve() - git_common_dir_path = (repo_root / git_common_dir.strip()).resolve() - return git_dir_path != git_common_dir_path - - -def warn_if_parallel_worktree(repo_root: Path) -> None: - """Non-blocking note: index.md conflicts across parallel worktrees/branches - are expected and safe. Only fires when running in a linked git worktree - (not the main tree) with `session_auto_commit` enabled (#415 quick-fix tier). - """ - if not get_session_auto_commit(repo_root): - return - if not is_git_worktree(repo_root): - return - print( - colored( - "[NOTE] Running in a git worktree with session_auto_commit enabled: " - "journal-*.md files auto-merge via .gitattributes, but index.md " - "conflicts across parallel worktrees/branches are expected and safe " - "to resolve by picking either side (task state lives in task.json, " - "not index.md). See .trellis/spec/cli/backend/directory-structure.md " - '("Workspace Journal Merge Behavior").', - Colors.YELLOW, - ), - file=sys.stderr, - ) - - -def create_new_journal_file( - dev_dir: Path, num: int, developer: str, today: str, max_lines: int = 2000, -) -> Path: - """Create a new journal file.""" - prev_num = num - 1 - new_file = dev_dir / f"{FILE_JOURNAL_PREFIX}{num}.md" - - content = f"""# Journal - {developer} (Part {num}) - -> Continuation from `{FILE_JOURNAL_PREFIX}{prev_num}.md` (archived at ~{max_lines} lines) -> Started: {today} - ---- - -""" - new_file.write_text(content, encoding="utf-8") - return new_file - - -def _render_bullet_section(header: str, items: list[str], bullet_prefix: str = "- ") -> str: - """Render a Markdown section as bullets, or "" when there is no content. - - A section with zero provided values is omitted entirely from the - rendered entry rather than falling back to a placeholder string. - """ - if not items: - return "" - bullets = "\n".join(f"{bullet_prefix}{item}" for item in items) - return f"\n\n### {header}\n\n{bullets}" - - -def _render_main_changes(changes: list[str], extra_content: str | None) -> str: - """Render the Main Changes section from --change bullets or freeform content.""" - if changes: - return _render_bullet_section("Main Changes", changes) - if extra_content: - return f"\n\n### Main Changes\n\n{extra_content}" - return "" - - -def generate_session_content( - session_num: int, - title: str, - commit: str, - summary: str, - today: str, - package: str | None = None, - branch: str | None = None, - changes: list[str] | None = None, - extra_content: str | None = None, - tests: list[str] | None = None, - next_steps: list[str] | None = None, -) -> str: - """Generate session content.""" - if commit and commit != "-": - commit_table = """| Hash | Message | -|------|---------|""" - for c in commit.split(","): - c = c.strip() - commit_table += f"\n| `{c}` | (see git log) |" - else: - commit_table = "(No commits - planning session)" - - package_line = f"\n**Package**: {package}" if package else "" - branch_line = f"\n**Branch**: `{branch}`" if branch else "" - - main_changes_section = _render_main_changes(changes or [], extra_content) - testing_section = _render_bullet_section("Testing", tests or [], bullet_prefix="- [OK] ") - next_steps_section = _render_bullet_section("Next Steps", next_steps or []) - - return f""" - -## Session {session_num}: {title} - -**Date**: {today} -**Task**: {title}{package_line}{branch_line} - -### Summary - -{summary}{main_changes_section} - -### Git Commits - -{commit_table}{testing_section} - -### Status - -[OK] **Completed**{next_steps_section} -""" - - -def update_index( - index_file: Path, - dev_dir: Path, - title: str, - commit: str, - new_session: int, - active_file: str, - today: str, - branch: str | None = None, -) -> bool: - """Update index.md with new session info.""" - # Format commit for display - commit_display = "-" - if commit and commit != "-": - commit_display = re.sub(r"([a-f0-9]{7,})", r"`\1`", commit.replace(",", ", ")) - - # Get file number from active_file name - match = re.search(r"(\d+)", active_file) - active_num = int(match.group(1)) if match else 0 - files_table = count_journal_files(dev_dir, active_num) - - print(f"Updating index.md for session {new_session}...") - print(f" Title: {title}") - print(f" Commit: {commit_display}") - print(f" Active File: {active_file}") - print() - - content = index_file.read_text(encoding="utf-8") - - if "@@@auto:current-status" not in content: - print("Error: Markers not found in index.md. Please ensure markers exist.", file=sys.stderr) - return False - - # Process sections - lines = content.splitlines() - new_lines = [] - - in_current_status = False - in_active_documents = False - in_session_history = False - header_written = False - - for line in lines: - if "@@@auto:current-status" in line: - new_lines.append(line) - in_current_status = True - new_lines.append(f"- **Active File**: `{active_file}`") - new_lines.append(f"- **Total Sessions**: {new_session}") - new_lines.append(f"- **Last Active**: {today}") - continue - - if "@@@/auto:current-status" in line: - in_current_status = False - new_lines.append(line) - continue - - if "@@@auto:active-documents" in line: - new_lines.append(line) - in_active_documents = True - new_lines.append("| File | Lines | Status |") - new_lines.append("|------|-------|--------|") - new_lines.append(files_table) - continue - - if "@@@/auto:active-documents" in line: - in_active_documents = False - new_lines.append(line) - continue - - if "@@@auto:session-history" in line: - new_lines.append(line) - in_session_history = True - header_written = False - continue - - if "@@@/auto:session-history" in line: - in_session_history = False - new_lines.append(line) - continue - - if in_current_status: - continue - - if in_active_documents: - continue - - if in_session_history: - # Migrate old 4/6-column headers to 5-column Branch-only history. - if re.match( - r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*Base Branch\s*\|\s*$", - line, - ): - new_lines.append("| # | Date | Title | Commits | Branch |") - continue - if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*$", line): - new_lines.append("| # | Date | Title | Commits | Branch |") - continue - if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*$", line): - new_lines.append("| # | Date | Title | Commits | Branch |") - continue - if re.match(r"^\|[-| ]+\|\s*$", line) and not header_written: - new_lines.append("|---|------|-------|---------|--------|") - new_lines.append(f"| {new_session} | {today} | {title} | {commit_display} | `{branch or '-'}` |") - header_written = True - continue - new_lines.append(line) - continue - - new_lines.append(line) - - index_file.write_text("\n".join(new_lines), encoding="utf-8") - print("[OK] Updated index.md successfully!") - return True - - -# ============================================================================= -# Main Function -# ============================================================================= - -def _auto_commit_workspace(repo_root: Path) -> None: - """Stage Trellis-owned workspace + current-task paths and commit. - - Path scope is restricted to specific products: the current developer's - journal files + index.md, and ONLY the current task directory (resolved - via ``get_current_task``). We never `git add` the whole `.trellis/` tree - or iterate over all active task dirs (#303: parallel-window dirty task - dirs must not be bundled into the session auto-commit). If `.gitignore` - blocks the specific paths we warn + skip — never retry with ``-f``. - - Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when set to - ``false``, this function returns immediately without touching git - (journal/index files are still written to disk by the caller). - """ - if not get_session_auto_commit(repo_root): - print( - "[OK] session_auto_commit: false — skipping git stage/commit.", - file=sys.stderr, - ) - return - - commit_msg = get_session_commit_message(repo_root) - # Resolve the current task so staging is scoped to its dir only. The ref - # is ``.trellis/tasks/<name>`` (or under archive/) — pass the bare name. - current = get_current_task(repo_root) - if current: - task_name = Path(current).name - paths = safe_trellis_paths_to_add(repo_root, task_name=task_name) - else: - # Current task unknown (0 or >=2 parallel sessions — exactly the - # parallel-window case #303 is about). Do NOT fall back to the wide - # `tasks_dir.iterdir()` scan; that would re-leak other tasks' dirty - # dirs into the session commit. Stage only the developer's journal/ - # index and skip every task dir. - paths = [ - p - for p in safe_trellis_paths_to_add(repo_root, task_name=None) - if not p.startswith(f"{DIR_WORKFLOW}/{DIR_TASKS}/") - ] - if not paths: - print("[OK] No workspace changes to commit.", file=sys.stderr) - return - - success, _, err = safe_git_add(paths, repo_root) - if not success: - if err and "ignored by" in err.lower(): - print_gitignore_warning(paths) - else: - print( - f"[WARN] git add failed: {err.strip() if err else 'unknown error'}", - file=sys.stderr, - ) - return - - # Check if there are staged changes for the paths we just staged. - rc, _, _ = run_git( - ["diff", "--cached", "--quiet", "--", *paths], cwd=repo_root - ) - if rc == 0: - print("[OK] No workspace changes to commit.", file=sys.stderr) - return - - rc, _, commit_err = run_git(["commit", "-m", commit_msg], cwd=repo_root) - if rc == 0: - print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr) - else: - print( - f"[WARN] Auto-commit failed: {commit_err.strip()}", - file=sys.stderr, - ) - - -def add_session( - title: str, - commit: str = "-", - summary: str = "Session summary was not supplied.", - changes: list[str] | None = None, - extra_content: str | None = None, - tests: list[str] | None = None, - next_steps: list[str] | None = None, - auto_commit: bool = True, - package: str | None = None, - branch: str | None = None, -) -> int: - """Add a new session.""" - repo_root = get_repo_root() - warn_if_parallel_worktree(repo_root) - ensure_developer(repo_root) - - developer = get_developer(repo_root) - if not developer: - print("Error: Developer not initialized", file=sys.stderr) - return 1 - - dev_dir = get_workspace_dir(repo_root) - if not dev_dir: - print("Error: Workspace directory not found", file=sys.stderr) - return 1 - - max_lines = get_max_journal_lines(repo_root) - - index_file = dev_dir / "index.md" - today = datetime.now().strftime("%Y-%m-%d") - - journal_file, current_num, current_lines = get_latest_journal_info(dev_dir) - current_session = get_current_session(index_file) - new_session = current_session + 1 - - session_content = generate_session_content( - new_session, title, commit, summary, today, package, branch, - changes=changes, extra_content=extra_content, tests=tests, - next_steps=next_steps, - ) - content_lines = len(session_content.splitlines()) - - print("========================================", file=sys.stderr) - print("ADD SESSION", file=sys.stderr) - print("========================================", file=sys.stderr) - print("", file=sys.stderr) - print(f"Session: {new_session}", file=sys.stderr) - print(f"Title: {title}", file=sys.stderr) - print(f"Commit: {commit}", file=sys.stderr) - print("", file=sys.stderr) - print(f"Current journal file: {FILE_JOURNAL_PREFIX}{current_num}.md", file=sys.stderr) - print(f"Current lines: {current_lines}", file=sys.stderr) - print(f"New content lines: {content_lines}", file=sys.stderr) - print(f"Total after append: {current_lines + content_lines}", file=sys.stderr) - print("", file=sys.stderr) - - target_file = journal_file - target_num = current_num - - if current_lines + content_lines > max_lines: - target_num = current_num + 1 - print(f"[!] Exceeds {max_lines} lines, creating {FILE_JOURNAL_PREFIX}{target_num}.md", file=sys.stderr) - target_file = create_new_journal_file(dev_dir, target_num, developer, today, max_lines) - print(f"Created: {target_file}", file=sys.stderr) - - # Append session content - if target_file: - with target_file.open("a", encoding="utf-8") as f: - f.write(session_content) - print(f"[OK] Appended session to {target_file.name}", file=sys.stderr) - - print("", file=sys.stderr) - - # Update index.md - active_file = f"{FILE_JOURNAL_PREFIX}{target_num}.md" - if not update_index( - index_file, - dev_dir, - title, - commit, - new_session, - active_file, - today, - branch, - ): - return 1 - - print("", file=sys.stderr) - print("========================================", file=sys.stderr) - print(f"[OK] Session {new_session} added successfully!", file=sys.stderr) - print("========================================", file=sys.stderr) - print("", file=sys.stderr) - print("Files updated:", file=sys.stderr) - print(f" - {target_file.name if target_file else 'journal'}", file=sys.stderr) - print(" - index.md", file=sys.stderr) - - # Auto-commit workspace changes - if auto_commit: - print("", file=sys.stderr) - _auto_commit_workspace(repo_root) - - return 0 - - -# ============================================================================= -# Main Entry -# ============================================================================= - -def main() -> int: - """CLI entry point.""" - parser = argparse.ArgumentParser( - description="Add a new session to journal file and update index.md" - ) - parser.add_argument("--title", required=True, help="Session title") - parser.add_argument("--commit", default="-", help="Comma-separated commit hashes") - parser.add_argument("--summary", default="Session summary was not supplied.", help="Brief summary") - parser.add_argument("--content-file", help="Path to file with detailed content") - parser.add_argument("--package", help="Package name tag (e.g., cli, docs-site)") - parser.add_argument("--branch", help="Branch name (auto-detected if omitted)") - parser.add_argument("--change", action="append", help="Main Changes bullet (repeatable)") - parser.add_argument("--test", action="append", help="Testing bullet (repeatable)") - parser.add_argument("--next-step", action="append", help="Next Steps bullet (repeatable)") - parser.add_argument("--no-commit", action="store_true", - help="Skip auto-commit of workspace changes") - parser.add_argument("--stdin", action="store_true", - help="Read extra content from stdin (explicit opt-in)") - - args = parser.parse_args() - - extra_content: str | None = None - if args.content_file: - content_path = Path(args.content_file) - if content_path.is_file(): - extra_content = content_path.read_text(encoding="utf-8") - elif args.stdin: - extra_content = sys.stdin.read() - - # Load active task once — shared by package and branch resolution - repo_root = get_repo_root() - current = get_current_task(repo_root) - task_data = load_task(repo_root / current) if current else None - - package = args.package - if package: - # CLI source: fail-fast in monorepo, ignore in single-repo - if not is_monorepo(repo_root): - print("Warning: --package ignored in single-repo project", file=sys.stderr) - package = None - elif not validate_package(package, repo_root): - packages = get_packages(repo_root) - available = ", ".join(sorted(packages.keys())) if packages else "(none)" - print(f"Error: unknown package '{package}'. Available: {available}", file=sys.stderr) - return 1 - else: - # Inferred: active task's task.json.package → default_package → None - task_package = task_data.package if task_data else None - package = resolve_package(task_package, repo_root) - - branch = resolve_session_branch(repo_root, args.branch, task_data) - - return add_session( - args.title, args.commit, args.summary, - changes=args.change, extra_content=extra_content, tests=args.test, - next_steps=args.next_step, - auto_commit=not args.no_commit, - package=package, - branch=branch, - ) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.trellis/scripts/common/__init__.py b/.trellis/scripts/common/__init__.py deleted file mode 100755 index 6d72360..0000000 --- a/.trellis/scripts/common/__init__.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -Common utilities for Trellis workflow scripts. - -This module provides shared functionality used by other Trellis scripts. -""" - -import io -import sys - -# ============================================================================= -# Windows Encoding Fix (MUST be at top, before any other output) -# ============================================================================= -# On Windows, stdout defaults to the system code page (often GBK/CP936). -# This causes UnicodeEncodeError when printing non-ASCII characters. -# -# Any script that imports from common will automatically get this fix. -# ============================================================================= - - -def _configure_stream(stream: object) -> object: - """Configure a stream for UTF-8 encoding on Windows.""" - # Try reconfigure() first (Python 3.7+, more reliable) - if hasattr(stream, "reconfigure"): - stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] - return stream - # Fallback: detach and rewrap with TextIOWrapper - elif hasattr(stream, "detach"): - return io.TextIOWrapper( - stream.detach(), # type: ignore[union-attr] - encoding="utf-8", - errors="replace", - ) - return stream - - -if sys.platform == "win32": - sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment] - sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment] - sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment] - - -def configure_encoding() -> None: - """ - Configure stdout/stderr/stdin for UTF-8 encoding on Windows. - - This is automatically called when importing from common, - but can be called manually for scripts that don't import common. - - Safe to call multiple times. - """ - global sys - if sys.platform == "win32": - sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment] - sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment] - sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment] - - -from .paths import ( - DIR_WORKFLOW, - DIR_WORKSPACE, - DIR_TASKS, - DIR_ARCHIVE, - DIR_SPEC, - DIR_SCRIPTS, - FILE_DEVELOPER, - FILE_CURRENT_TASK, - FILE_TASK_JSON, - FILE_JOURNAL_PREFIX, - get_repo_root, - get_developer, - check_developer, - get_tasks_dir, - get_workspace_dir, - get_active_journal_file, - count_lines, - get_current_task, - get_current_task_abs, - normalize_task_ref, - resolve_task_ref, - set_current_task, - clear_current_task, - has_current_task, - generate_task_date_prefix, -) - -from .active_task import ( - ActiveTask, - clear_active_task, - resolve_active_task, - resolve_context_key, - set_active_task, -) diff --git a/.trellis/scripts/common/active_task.py b/.trellis/scripts/common/active_task.py deleted file mode 100755 index 97a7784..0000000 --- a/.trellis/scripts/common/active_task.py +++ /dev/null @@ -1,765 +0,0 @@ -#!/usr/bin/env python3 -"""Session-scoped active task resolution. - -The user-facing concept is a single "active task". Trellis stores that pointer -per AI session/window under `.trellis/.runtime/sessions/`; without a stable -session key there is no active task. -""" - -from __future__ import annotations - -import hashlib -import json -import os -import re -import sys -import time -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - -DIR_WORKFLOW = ".trellis" -DIR_TASKS = "tasks" -DIR_RUNTIME = ".runtime" -DIR_SESSIONS = "sessions" -DIR_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") -_CONVERSATION_KEYS = ("conversation_id", "conversationId", "conversationID") -_TRANSCRIPT_KEYS = ("transcript_path", "transcriptPath", "transcript") -_NESTED_KEYS = ("input", "properties", "event", "hook_input", "hookInput") -_KNOWN_PLATFORMS = { - "claude", - "codex", - "cursor", - "opencode", - "gemini", - "droid", - "qoder", - "codebuddy", - "kiro", - "copilot", - "pi", - "trae", - "grok", - "kimi", - "zcode", - "snow", - "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, ...]], ...] = ( - # 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",)), - # 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",)), - # 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")), - # 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_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, ...]], ...] = ( - # 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",)), - ("codebuddy", ("CODEBUDDY_TRANSCRIPT_PATH",)), -) -_ENV_PLATFORM_ALIASES = { - "claude-code": "claude", - "factory": "droid", - "factory-ai": "droid", - "github-copilot": "copilot", -} -# 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", -} - - -@dataclass(frozen=True) -class ActiveTask: - """Resolved active task state.""" - - task_path: str | None - source_type: str - context_key: str | None = None - stale: bool = False - - @property - def source(self) -> str: - """Human-readable source label.""" - if self.source_type == "session" and self.context_key: - return f"session:{self.context_key}" - if self.source_type == "session-fallback" and self.context_key: - return f"session-fallback:{self.context_key}" - return self.source_type - - -def normalize_task_ref(task_ref: str) -> str: - """Normalize a task ref for stable storage and comparison.""" - normalized = task_ref.strip() - if not normalized: - return "" - - path_obj = Path(normalized) - if path_obj.is_absolute(): - return str(path_obj) - - normalized = normalized.replace("\\", "/") - while normalized.startswith("./"): - normalized = normalized[2:] - - if normalized.startswith(f"{DIR_TASKS}/"): - return f"{DIR_WORKFLOW}/{normalized}" - - return normalized - - -def resolve_task_ref(task_ref: str, repo_root: Path) -> Path | None: - """Resolve a task ref to an absolute task directory 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 - - path_obj = Path(normalized) - if path_obj.is_absolute(): - candidate = path_obj - elif normalized.startswith(f"{DIR_WORKFLOW}/"): - candidate = repo_root / path_obj - else: - candidate = repo_root / DIR_WORKFLOW / DIR_TASKS / 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() - root = repo_root.resolve() - except OSError: - return None - - try: - resolved.relative_to(root) - except ValueError: - return None - - return resolved - - -def _runtime_sessions_dir(repo_root: Path) -> Path: - return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_SESSIONS - - -def _sanitize_key(raw: str) -> str: - safe = re.sub(r"[^A-Za-z0-9._-]+", "_", raw.strip()) - safe = safe.strip("._-") - return safe[:160] if safe else "" - - -def _hash_value(raw: str) -> str: - return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24] - - -def _as_dict(value: Any) -> dict[str, Any] | None: - return value if isinstance(value, dict) else None - - -def _string_value(value: Any) -> str | None: - if isinstance(value, str): - stripped = value.strip() - return stripped or None - return None - - -def _lookup_string(data: dict[str, Any], keys: tuple[str, ...]) -> str | None: - for key in keys: - value = _string_value(data.get(key)) - if value: - return value - - for nested_key in _NESTED_KEYS: - nested = _as_dict(data.get(nested_key)) - if not nested: - continue - value = _lookup_string(nested, keys) - if value: - return value - - return None - - -def _detect_platform(platform_input: dict[str, Any] | None, platform: str | None) -> str: - if platform: - return _sanitize_key(platform) or "session" - if platform_input: - for key in ("_trellis_platform", "trellis_platform", "platform", "source"): - value = _string_value(platform_input.get(key)) - if value: - return _sanitize_key(value) or "session" - if _string_value(platform_input.get("cursor_version")): - return "cursor" - return "session" - - -def _context_key(platform_name: str, kind: str, value: str) -> str: - platform_name = _CONTEXT_KEY_PLATFORM_ALIASES.get(platform_name, platform_name) - if kind == "transcript": - return f"{platform_name}_transcript_{_hash_value(value)}" - safe_value = _sanitize_key(value) - if safe_value: - return f"{platform_name}_{safe_value}" - return f"{platform_name}_{_hash_value(value)}" - - -def _iter_env_keys( - env_keys: tuple[tuple[str, tuple[str, ...]], ...], - platform_name: str | None, -) -> tuple[tuple[str, tuple[str, ...]], ...]: - """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) - return matched - - -def _env_platform_name(platform_name: str | None) -> str | None: - if not platform_name or platform_name == "session": - return None - return _ENV_PLATFORM_ALIASES.get(platform_name, platform_name) - - -def _lookup_env_context_key(platform_name: str | None) -> str | None: - """Resolve a context key from platform-provided environment variables. - - Hooks pass `TRELLIS_CONTEXT_ID` to subprocesses they launch, but an AI-run - shell command can only see session identity if the host platform exports it - in the command environment. These names are best-effort adapters; if none - are present, there is no session-scoped active task. - """ - env_platform_name = _env_platform_name(platform_name) - - for name, keys in _iter_env_keys(_ENV_SESSION_KEYS, env_platform_name): - for key in keys: - value = _string_value(os.environ.get(key)) - if value: - return _context_key(name, "session", value) - - for name, keys in _iter_env_keys(_ENV_CONVERSATION_KEYS, env_platform_name): - for key in keys: - value = _string_value(os.environ.get(key)) - if value: - return _context_key(name, "conversation", value) - - for name, keys in _iter_env_keys(_ENV_TRANSCRIPT_KEYS, env_platform_name): - for key in keys: - value = _string_value(os.environ.get(key)) - if value: - return _context_key(name, "transcript", value) - - return None - - -def _find_repo_root_from_cwd() -> Path | None: - current = Path.cwd().resolve() - while True: - if (current / DIR_WORKFLOW).is_dir(): - return current - if current == current.parent: - return None - current = current.parent - - -def _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: - try: - path.unlink() - return True - except OSError: - return False - - -def _task_refs_match(left: str | None, right: str | None, repo_root: Path) -> bool: - if not left or not right: - return False - left_path = resolve_task_ref(left, repo_root) - right_path = resolve_task_ref(right, repo_root) - if left_path is not None and right_path is not None: - return left_path == right_path - return normalize_task_ref(left) == normalize_task_ref(right) - - -def _pending_ticket_matches_args(ticket: dict[str, Any], repo_root: Path) -> bool: - if Path(sys.argv[0]).name != "task.py": - return False - args = tuple(sys.argv[1:]) - if not args: - return False - - command_name = args[0] - if command_name not in TASK_SESSION_COMMANDS: - return False - - subcommands = ticket.get("subcommands") - if not isinstance(subcommands, list): - return False - - for subcommand in subcommands: - if not isinstance(subcommand, dict): - continue - if _string_value(subcommand.get("name")) != command_name: - continue - if command_name != "start": - return True - task_ref = args[1] if len(args) > 1 else None - if _task_refs_match(_string_value(subcommand.get("task_ref")), task_ref, repo_root): - return True - - return False - - -def _ticket_is_fresh(ticket: dict[str, Any], ticket_path: Path, now: float) -> bool: - expires_at = ticket.get("expires_at_epoch") - if isinstance(expires_at, (int, float)) and expires_at < now: - _remove_file(ticket_path) - return False - - created_at = ticket.get("created_at_epoch") - if isinstance(created_at, (int, float)): - if now - created_at <= SHELL_TICKET_TTL_SECONDS: - return True - _remove_file(ticket_path) - return False - return True - - -def _ticket_cwd_matches_repo(ticket: dict[str, Any], repo_root: Path) -> bool: - cwd = _string_value(ticket.get("cwd")) - if not cwd: - return True - try: - Path(cwd).resolve().relative_to(repo_root) - except ValueError: - return False - return True - - -def _matching_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: - return None - if not _ticket_is_fresh(ticket, ticket_path, now): - return None - if not _ticket_cwd_matches_repo(ticket, repo_root): - return None - if not _pending_ticket_matches_args(ticket, repo_root): - return None - return _string_value(ticket.get("context_key")) - - -def _lookup_shell_ticket_context_key() -> str | None: - """Resolve session identity from a short-lived shell ticket. - - 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 - - now = time.time() - candidates: set[str] = set() - for ticket_dir in _shell_ticket_dirs(repo_root): - if not ticket_dir.is_dir(): - continue - for ticket_path in ticket_dir.glob("*.json"): - 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)) - return None - - -def resolve_context_key( - platform_input: dict[str, Any] | None = None, - platform: str | None = None, - *, - allow_environment_context: bool = True, -) -> str | None: - """Resolve a stable session/window context key, if one is available. - - `TRELLIS_CONTEXT_ID` is an explicit context-key override used by CLI - scripts and subprocesses. It does not store the task itself. - """ - if allow_environment_context: - override = _string_value(os.environ.get("TRELLIS_CONTEXT_ID")) - if override: - return _sanitize_key(override) or _hash_value(override) - - data = _as_dict(platform_input) - platform_name = _detect_platform(data, platform) if data or platform else None - - if data: - session_id = _lookup_string(data, _SESSION_KEYS) - if session_id: - return _context_key(platform_name or "session", "session", session_id) - - conversation_id = _lookup_string(data, _CONVERSATION_KEYS) - if conversation_id: - return _context_key(platform_name or "session", "conversation", conversation_id) - - transcript_path = _lookup_string(data, _TRANSCRIPT_KEYS) - if transcript_path: - return _context_key(platform_name or "session", "transcript", transcript_path) - - if allow_environment_context: - env_context_key = _lookup_env_context_key(platform_name) - if env_context_key: - return env_context_key - - # 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 - return data if isinstance(data, dict) else None - - -def _write_json(path: Path, data: dict[str, Any]) -> bool: - try: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - json.dumps(data, indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", - ) - return True - except OSError: - return False - - -def _canonical_task_ref(task_path: str, repo_root: Path) -> str | None: - normalized = normalize_task_ref(task_path) - if not normalized: - return None - full_path = resolve_task_ref(normalized, repo_root) - if full_path is None or not full_path.is_dir(): - return None - try: - return full_path.relative_to(repo_root.resolve()).as_posix() - except ValueError: - # 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 _active_from_ref( - task_ref: str | None, - repo_root: Path, - source_type: str, - context_key: str | None = None, -) -> ActiveTask | None: - if not task_ref: - return None - resolved = resolve_task_ref(task_ref, repo_root) - stale = resolved is None or not resolved.is_dir() - return ActiveTask(task_ref, source_type, context_key, stale) - - -def _context_path(repo_root: Path, context_key: str) -> Path: - return _runtime_sessions_dir(repo_root) / f"{context_key}.json" - - -def resolve_active_task( - repo_root: Path, - platform_input: dict[str, Any] | None = None, - platform: str | None = None, - *, - allow_single_session_fallback: bool = True, - allow_environment_context: bool = True, -) -> ActiveTask: - """Resolve the active task from session runtime state only. - - A stale session task is returned as stale. Missing context identity or a - missing/empty session context falls back to single-session inference: if - exactly one session file exists in the runtime, return its task with - source_type="session-fallback" — covers pull-based platform sub-agents - (copilot, gemini, qoder) that don't inherit the parent's session id. ≥2 - files or 0 files yield ActiveTask(None) — refuses to guess across windows. - """ - context_key = resolve_context_key( - platform_input, - platform, - allow_environment_context=allow_environment_context, - ) - if context_key: - context = _read_json(_context_path(repo_root, context_key)) or {} - task_ref = _string_value(context.get("current_task")) - active = _active_from_ref(task_ref, repo_root, "session", context_key) - if active: - return active - - if allow_single_session_fallback: - fallback = _resolve_single_session_fallback(repo_root) - if fallback is not None: - return fallback - - return ActiveTask(None, "none", context_key) - - -def _resolve_single_session_fallback(repo_root: Path) -> ActiveTask | None: - """Return the task pointed at by the sole session file, if exactly one exists. - - Used when context-key resolution fails (typical for class-2 platform - sub-agents). Returns None if 0 or ≥2 session files are present — refuses - to pick across windows so 04-21's multi-session isolation contract holds. - """ - sessions_dir = _runtime_sessions_dir(repo_root) - if not sessions_dir.is_dir(): - return None - - session_files = sorted(sessions_dir.glob("*.json")) - if len(session_files) != 1: - return None - - session_file = session_files[0] - context = _read_json(session_file) or {} - task_ref = _string_value(context.get("current_task")) - if not task_ref: - return None - - fallback_key = session_file.stem - return _active_from_ref(task_ref, repo_root, "session-fallback", fallback_key) - - -def _utc_now() -> str: - return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") - - -def _context_metadata( - platform_input: dict[str, Any] | None, - platform: str | None, - context_key: str | None = None, -) -> dict[str, Any]: - data = _as_dict(platform_input) or {} - platform_name = _detect_platform(data, platform) - if platform_name == "session" and context_key: - prefix = context_key.split("_", 1)[0] - if prefix in _KNOWN_PLATFORMS: - platform_name = prefix - metadata: dict[str, Any] = { - "platform": platform_name, - "last_seen_at": _utc_now(), - } - for key in (*_SESSION_KEYS, *_CONVERSATION_KEYS, *_TRANSCRIPT_KEYS): - value = _lookup_string(data, (key,)) - if value: - metadata[key] = value - return metadata - - -def set_active_task( - task_path: str, - repo_root: Path, - platform_input: dict[str, Any] | None = None, - platform: str | None = None, -) -> ActiveTask | None: - """Set the active task in session scope. - - Returns None when no context key is available; callers should surface a - user-facing error that explains how to provide session identity. - """ - canonical = _canonical_task_ref(task_path, repo_root) - if canonical is None: - return None - - context_key = resolve_context_key(platform_input, platform) - if not context_key: - return None - - context_path = _context_path(repo_root, context_key) - context = _read_json(context_path) or {} - context.update(_context_metadata(platform_input, platform, context_key)) - context["current_task"] = canonical - context.setdefault("current_run", None) - if not _write_json(context_path, context): - return None - return ActiveTask(canonical, "session", context_key) - - -def clear_active_task( - repo_root: Path, - platform_input: dict[str, Any] | None = None, - platform: str | None = None, -) -> ActiveTask: - """Clear the active task by deleting its resolved session context file.""" - context_key = resolve_context_key(platform_input, platform) - if not context_key: - return ActiveTask(None, "none") - - previous = resolve_active_task(repo_root, platform_input, platform) - if not previous.task_path or not previous.context_key: - return previous - - context_path = _context_path(repo_root, previous.context_key) - if context_path.is_file(): - _remove_file(context_path) - return previous - - -def clear_task_from_sessions(task_path: str, repo_root: Path) -> int: - """Delete all session runtime files that point at a task.""" - target = _canonical_task_ref(task_path, repo_root) or normalize_task_ref(task_path) - if not target: - return 0 - - cleared = 0 - sessions_dir = _runtime_sessions_dir(repo_root) - if not sessions_dir.is_dir(): - return cleared - - for session_path in sessions_dir.glob("*.json"): - context = _read_json(session_path) or {} - current = _string_value(context.get("current_task")) - if not current: - continue - current_ref = _canonical_task_ref(current, repo_root) or normalize_task_ref(current) - if current_ref != target: - continue - if session_path.is_file() and _remove_file(session_path): - cleared += 1 - - return cleared - - -def get_current_task_source( - repo_root: Path, - platform_input: dict[str, Any] | None = None, - platform: str | None = None, -) -> tuple[str, str | None, str | None]: - """Return (`source_type`, `context_key`, `task_path`) for compatibility.""" - active = resolve_active_task(repo_root, platform_input, platform) - return active.source_type, active.context_key, active.task_path diff --git a/.trellis/scripts/common/cli_adapter.py b/.trellis/scripts/common/cli_adapter.py deleted file mode 100755 index 85e1c3a..0000000 --- a/.trellis/scripts/common/cli_adapter.py +++ /dev/null @@ -1,950 +0,0 @@ -""" -CLI Adapter for Multi-Platform Support. - -Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, Devin, Qoder, CodeBuddy, GitHub Copilot, Factory Droid, and Pi Agent interfaces. - -Supported platforms: -- claude: Claude Code (default) -- opencode: OpenCode -- cursor: Cursor IDE -- iflow: iFlow CLI -- codex: Codex CLI (skills-based) -- kilo: Kilo CLI -- kiro: Kiro Code (skills-based) -- gemini: Gemini CLI -- antigravity: Antigravity (workflow-based) -- devin: Devin (formerly Windsurf; workflow-based) -- qoder: Qoder -- codebuddy: CodeBuddy -- copilot: GitHub Copilot (VS Code) -- droid: Factory Droid (commands-based) -- pi: Pi Agent (extension-backed) -- trae: Trae IDE (IDE-only, hooks-based) -- omp: Oh My Pi -- grok: Grok Build (pull-based skills/agents; no hook context injection) -- kimi: Kimi Code (pull-based skills; commands delivered as skills; no hook context injection) - -Usage: - from common.cli_adapter import CLIAdapter - - adapter = CLIAdapter("opencode") - cmd = adapter.build_run_command( - agent="dispatch", - session_id="abc123", - prompt="Start the pipeline" - ) -""" - -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path -from typing import ClassVar, Literal - -Platform = Literal[ - "claude", - "opencode", - "cursor", - "iflow", - "codex", - "kilo", - "kiro", - "gemini", - "antigravity", - "devin", - "qoder", - "codebuddy", - "copilot", - "droid", - "pi", - "trae", - "omp", - "grok", - "kimi", -] - - -@dataclass -class CLIAdapter: - """Adapter for different AI coding CLI tools.""" - - platform: Platform - - # ========================================================================= - # Agent Name Mapping - # ========================================================================= - - # OpenCode has built-in agents that cannot be overridden - # See: https://github.com/sst/opencode/issues/4271 - # Note: Class-level constant, not a dataclass field - _AGENT_NAME_MAP: ClassVar[dict[Platform, dict[str, str]]] = { - "claude": {}, # No mapping needed - "opencode": { - "plan": "trellis-plan", # 'plan' is built-in in OpenCode - }, - } - - def get_agent_name(self, agent: str) -> str: - """Get platform-specific agent name. - - Args: - agent: Original agent name (e.g., 'plan', 'dispatch') - - Returns: - Platform-specific agent name (e.g., 'trellis-plan' for OpenCode) - """ - mapping = self._AGENT_NAME_MAP.get(self.platform, {}) - return mapping.get(agent, agent) - - # ========================================================================= - # Agent Path - # ========================================================================= - - @property - def config_dir_name(self) -> str: - """Get platform-specific config directory name. - - Returns: - Directory name ('.claude', '.opencode', '.cursor', '.iflow', '.codex', '.kilocode', '.kiro', '.gemini', '.agent', '.devin', '.qoder', '.codebuddy', '.github/copilot', '.factory', '.pi', or '.trae') - """ - if self.platform == "opencode": - return ".opencode" - elif self.platform == "cursor": - return ".cursor" - elif self.platform == "iflow": - return ".iflow" - elif self.platform == "codex": - return ".codex" - elif self.platform == "kilo": - return ".kilocode" - elif self.platform == "kiro": - return ".kiro" - elif self.platform == "gemini": - return ".gemini" - elif self.platform == "antigravity": - return ".agent" - elif self.platform == "devin": - return ".devin" - elif self.platform == "qoder": - return ".qoder" - elif self.platform == "codebuddy": - return ".codebuddy" - elif self.platform == "copilot": - return ".github/copilot" - elif self.platform == "droid": - return ".factory" - elif self.platform == "pi": - return ".pi" - elif self.platform == "trae": - return ".trae" - elif self.platform == "omp": - return ".omp" - elif self.platform == "grok": - return ".grok" - elif self.platform == "kimi": - return ".kimi-code" - else: - return ".claude" - - def get_config_dir(self, project_root: Path) -> Path: - """Get platform-specific config directory. - - Args: - project_root: Project root directory - - Returns: - Path to config directory (.claude, .opencode, .cursor, .iflow, .codex, .kilocode, .kiro, .gemini, .agent, .devin, .qoder, .codebuddy, .github/copilot, .factory, .pi, or .trae) - """ - return project_root / self.config_dir_name - - def get_agent_path(self, agent: str, project_root: Path) -> Path: - """Get path to agent definition file. - - Args: - agent: Agent name (original, before mapping) - project_root: Project root directory - - Returns: - Path to agent definition file (.md for most platforms, .toml for Codex) - """ - mapped_name = self.get_agent_name(agent) - if self.platform == "codex": - return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.toml" - return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.md" - - def get_commands_path(self, project_root: Path, *parts: str) -> Path: - """Get path to commands directory or specific command file. - - Args: - project_root: Project root directory - *parts: Additional path parts (e.g., 'trellis', 'finish-work.md') - - Returns: - Path to commands directory or file - - Note: - Cursor uses prefix naming: .cursor/commands/trellis-<name>.md - Antigravity uses workflow directory: .agent/workflows/<name>.md - Devin uses workflow directory: .devin/workflows/trellis-<name>.md - Copilot uses prompt files: .github/prompts/<name>.prompt.md - Pi uses prompt templates: .pi/prompts/trellis-<name>.md - Claude/OpenCode use subdirectory: .claude/commands/trellis/<name>.md - """ - if self.platform == "pi": - prompts_dir = self.get_config_dir(project_root) / "prompts" - if not parts: - return prompts_dir - if len(parts) >= 2 and parts[0] == "trellis": - filename = parts[-1] - if filename.endswith(".md"): - filename = filename[:-3] - return prompts_dir / f"trellis-{filename}.md" - return prompts_dir / Path(*parts) - # OMP and Grok: flat slash commands under .{platform}/commands/trellis-<name>.md - if self.platform in ("omp", "grok"): - commands_dir = self.get_config_dir(project_root) / "commands" - if not parts: - return commands_dir - if len(parts) >= 2 and parts[0] == "trellis": - filename = parts[-1] - if filename.endswith(".md"): - filename = filename[:-3] - return commands_dir / f"trellis-{filename}.md" - return commands_dir / Path(*parts) - - # Kimi: commands are skills under .kimi-code/skills/trellis-<name>/SKILL.md - if self.platform == "kimi": - skills_dir = self.get_config_dir(project_root) / "skills" - if not parts: - return skills_dir - if len(parts) >= 2 and parts[0] == "trellis": - filename = parts[-1] - if filename.endswith(".md"): - filename = filename[:-3] - return skills_dir / f"trellis-{filename}" / "SKILL.md" - return skills_dir / Path(*parts) - - if self.platform == "devin": - workflow_dir = self.get_config_dir(project_root) / "workflows" - if not parts: - return workflow_dir - if len(parts) >= 2 and parts[0] == "trellis": - filename = parts[-1] - return workflow_dir / f"trellis-{filename}" - return workflow_dir / Path(*parts) - - if self.platform in ("antigravity", "kilo"): - workflow_dir = self.get_config_dir(project_root) / "workflows" - if not parts: - return workflow_dir - if len(parts) >= 2 and parts[0] == "trellis": - filename = parts[-1] - return workflow_dir / filename - return workflow_dir / Path(*parts) - - if self.platform == "copilot": - prompts_dir = project_root / ".github" / "prompts" - if not parts: - return prompts_dir - if len(parts) >= 2 and parts[0] == "trellis": - filename = parts[-1] - if filename.endswith(".md"): - filename = filename[:-3] - return prompts_dir / f"{filename}.prompt.md" - return prompts_dir / Path(*parts) - - if not parts: - return self.get_config_dir(project_root) / "commands" - - # Cursor uses prefix naming instead of subdirectory - if self.platform == "cursor" and len(parts) >= 2 and parts[0] == "trellis": - # Convert trellis/<name>.md to trellis-<name>.md - filename = parts[-1] - return ( - self.get_config_dir(project_root) / "commands" / f"trellis-{filename}" - ) - - return self.get_config_dir(project_root) / "commands" / Path(*parts) - - def get_trellis_command_path(self, name: str) -> str: - """Get relative path to a trellis command file. - - Args: - name: Command name without extension (e.g., 'finish-work', 'check') - - Returns: - Relative path string for use in JSONL entries - - Note: - Cursor: .cursor/commands/trellis-<name>.md - Codex: .agents/skills/trellis-<name>/SKILL.md - Kiro: .kiro/skills/trellis-<name>/SKILL.md - Gemini: .gemini/commands/trellis/<name>.toml - Antigravity: .agent/workflows/<name>.md - Devin: .devin/workflows/trellis-<name>.md - Pi: .pi/prompts/trellis-<name>.md - Others: .{platform}/commands/trellis/<name>.md - """ - if self.platform == "cursor": - return f".cursor/commands/trellis-{name}.md" - elif self.platform == "codex": - # 0.5.0-beta.0 renamed all skill dirs to add the `trellis-` prefix - # (see that release's manifest for the 60+ rename entries). - return f".agents/skills/trellis-{name}/SKILL.md" - elif self.platform == "kiro": - return f".kiro/skills/trellis-{name}/SKILL.md" - elif self.platform == "gemini": - return f".gemini/commands/trellis/{name}.toml" - elif self.platform == "antigravity": - return f".agent/workflows/{name}.md" - elif self.platform == "devin": - return f".devin/workflows/trellis-{name}.md" - elif self.platform == "kilo": - return f".kilocode/workflows/{name}.md" - elif self.platform == "copilot": - return f".github/prompts/{name}.prompt.md" - elif self.platform == "droid": - return f".factory/commands/trellis/{name}.md" - elif self.platform == "pi": - return f".pi/prompts/trellis-{name}.md" - elif self.platform in ("omp", "grok"): - return f"{self.config_dir_name}/commands/trellis-{name}.md" - elif self.platform == "kimi": - return f".kimi-code/skills/trellis-{name}/SKILL.md" - else: - return f"{self.config_dir_name}/commands/trellis/{name}.md" - - # ========================================================================= - # Environment Variables - # ========================================================================= - - def get_non_interactive_env(self) -> dict[str, str]: - """Get environment variables for non-interactive mode. - - Returns: - Dict of environment variables to set - """ - if self.platform == "opencode": - return {"OPENCODE_NON_INTERACTIVE": "1"} - elif self.platform == "iflow": - return {"IFLOW_NON_INTERACTIVE": "1"} - elif self.platform == "codex": - return {"CODEX_NON_INTERACTIVE": "1"} - elif self.platform == "kiro": - return {"KIRO_NON_INTERACTIVE": "1"} - elif self.platform == "gemini": - return {} # Gemini CLI doesn't have a non-interactive env var - elif self.platform == "antigravity": - return {} - elif self.platform == "devin": - return {} - elif self.platform == "qoder": - return {} - elif self.platform == "codebuddy": - return {} - elif self.platform == "copilot": - return {} - elif self.platform == "droid": - return {} - elif self.platform == "pi": - return {} - elif self.platform == "trae": - return {} - elif self.platform == "omp": - return {} - elif self.platform == "grok": - return {} - elif self.platform == "kimi": - return {} - else: - return {"CLAUDE_NON_INTERACTIVE": "1"} - - # ========================================================================= - # CLI Command Building - # ========================================================================= - - def build_run_command( - self, - agent: str, - prompt: str, - session_id: str | None = None, - skip_permissions: bool = True, - verbose: bool = True, - json_output: bool = True, - ) -> list[str]: - """Build CLI command for running an agent. - - Args: - agent: Agent name (will be mapped if needed) - prompt: Prompt to send to the agent - session_id: Optional session ID (Claude Code only for creation) - skip_permissions: Whether to skip permission prompts - verbose: Whether to enable verbose output - json_output: Whether to use JSON output format - - Returns: - List of command arguments - """ - mapped_agent = self.get_agent_name(agent) - - if self.platform == "opencode": - cmd = ["opencode", "run"] - cmd.extend(["--agent", mapped_agent]) - - # Note: OpenCode 'run' mode is non-interactive by default - # No equivalent to Claude Code's --dangerously-skip-permissions - # See: https://github.com/anomalyco/opencode/issues/9070 - - if json_output: - cmd.extend(["--format", "json"]) - - if verbose: - cmd.extend(["--log-level", "DEBUG", "--print-logs"]) - - # Note: OpenCode doesn't support --session-id on creation - # Session ID must be extracted from logs after startup - - cmd.append(prompt) - - elif self.platform == "iflow": - cmd = ["iflow", "-y", "-p"] - cmd.append(f"${mapped_agent} {prompt}") - elif self.platform == "codex": - cmd = ["codex", "exec"] - cmd.append(prompt) - elif self.platform == "kiro": - cmd = ["kiro", "run", prompt] - elif self.platform == "gemini": - cmd = ["gemini"] - cmd.append(prompt) - elif self.platform == "antigravity": - raise ValueError( - "Antigravity workflows are UI slash commands; CLI agent run is not supported." - ) - elif self.platform == "devin": - raise ValueError( - "Devin workflows are UI slash commands; CLI agent run is not supported." - ) - elif self.platform == "qoder": - cmd = ["qodercli", "-p", prompt] - elif self.platform == "codebuddy": - raise ValueError( - "CodeBuddy does not support non-interactive mode (no CLI agent)" - ) - elif self.platform == "copilot": - raise ValueError( - "GitHub Copilot is IDE-only; CLI agent run is not supported." - ) - elif self.platform == "droid": - raise ValueError( - "Factory Droid CLI agent run is not yet supported." - ) - elif self.platform == "pi": - cmd = ["pi", "-p", prompt] - elif self.platform == "trae": - raise ValueError( - "Trae is IDE-only; CLI agent run is not supported." - ) - elif self.platform == "omp": - raise ValueError( - "OMP uses native task tool for agent runs; CLI agent run is not supported." - ) - elif self.platform == "grok": - # Headless single-prompt; sub-agents use in-process spawn_subagent. - cmd = ["grok", "-p", prompt, "--yolo"] - elif self.platform == "kimi": - # Headless single-prompt with auto-approval; sub-agents are the - # built-in coder/explore/plan agents dispatched in-session. - cmd = ["kimi", "-p", prompt, "--yolo"] - - else: # claude - cmd = ["claude", "-p"] - cmd.extend(["--agent", mapped_agent]) - - if session_id: - cmd.extend(["--session-id", session_id]) - - if skip_permissions: - cmd.append("--dangerously-skip-permissions") - - if json_output: - cmd.extend(["--output-format", "stream-json"]) - - if verbose: - cmd.append("--verbose") - - cmd.append(prompt) - - return cmd - - def build_resume_command(self, session_id: str) -> list[str]: - """Build CLI command for resuming a session. - - Args: - session_id: Session ID to resume (ignored for iFlow) - - Returns: - List of command arguments - """ - if self.platform == "opencode": - return ["opencode", "run", "--session", session_id] - elif self.platform == "iflow": - # iFlow uses -c to continue most recent conversation - # session_id is ignored as iFlow doesn't support session IDs - return ["iflow", "-c"] - elif self.platform == "codex": - return ["codex", "resume", session_id] - elif self.platform == "kiro": - return ["kiro", "resume", session_id] - elif self.platform == "gemini": - return ["gemini", "--resume", session_id] - elif self.platform == "antigravity": - raise ValueError( - "Antigravity workflows are UI slash commands; CLI resume is not supported." - ) - elif self.platform == "devin": - raise ValueError( - "Devin workflows are UI slash commands; CLI resume is not supported." - ) - elif self.platform == "qoder": - return ["qodercli", "--resume", session_id] - elif self.platform == "codebuddy": - raise ValueError( - "CodeBuddy does not support non-interactive mode (no CLI agent)" - ) - elif self.platform == "copilot": - raise ValueError( - "GitHub Copilot is IDE-only; CLI resume is not supported." - ) - elif self.platform == "droid": - raise ValueError( - "Factory Droid CLI resume is not yet supported." - ) - elif self.platform == "pi": - return ["pi", "-c", session_id] - elif self.platform == "trae": - raise ValueError( - "Trae is IDE-only; CLI resume is not supported." - ) - elif self.platform == "omp": - raise ValueError( - "OMP uses native task tool for agent runs; CLI resume is not supported." - ) - elif self.platform == "grok": - return ["grok", "-c"] - elif self.platform == "kimi": - return ["kimi", "--session", session_id] - else: - return ["claude", "--resume", session_id] - - def get_resume_command_str(self, session_id: str, cwd: str | None = None) -> str: - """Get human-readable resume command string. - - Args: - session_id: Session ID to resume - cwd: Optional working directory to cd into - - Returns: - Command string for display - """ - cmd = self.build_resume_command(session_id) - cmd_str = " ".join(cmd) - - if cwd: - return f"cd {cwd} && {cmd_str}" - return cmd_str - - # ========================================================================= - # Platform Detection Helpers - # ========================================================================= - - @property - def is_opencode(self) -> bool: - """Check if platform is OpenCode.""" - return self.platform == "opencode" - - @property - def is_claude(self) -> bool: - """Check if platform is Claude Code.""" - return self.platform == "claude" - - @property - def is_cursor(self) -> bool: - """Check if platform is Cursor.""" - return self.platform == "cursor" - - @property - def is_iflow(self) -> bool: - """Check if platform is iFlow CLI.""" - return self.platform == "iflow" - - @property - def cli_name(self) -> str: - """Get CLI executable name. - - Note: Cursor doesn't have a CLI tool, returns None-like value. - """ - if self.is_opencode: - return "opencode" - elif self.is_cursor: - return "cursor" # Note: Cursor is IDE-only, no CLI - elif self.platform == "iflow": - return "iflow" - elif self.platform == "kiro": - return "kiro" - elif self.platform == "gemini": - return "gemini" - elif self.platform == "antigravity": - return "agy" - elif self.platform == "devin": - return "devin" - elif self.platform == "qoder": - return "qodercli" - elif self.platform == "codebuddy": - return "codebuddy" - elif self.platform == "copilot": - return "copilot" - elif self.platform == "droid": - return "droid" - elif self.platform == "pi": - return "pi" - elif self.platform == "trae": - return "trae" - elif self.platform == "omp": - return "omp" - elif self.platform == "grok": - return "grok" - elif self.platform == "kimi": - return "kimi" - else: - return "claude" - - @property - def supports_cli_agents(self) -> bool: - """Check if platform supports running agents via CLI. - - Claude Code, OpenCode, iFlow, and Codex support CLI agent execution. - Cursor is IDE-only and doesn't support CLI agents. - """ - return self.platform in ( - "claude", - "opencode", - "iflow", - "codex", - "pi", - "grok", - "kimi", - ) - - @property - def requires_agent_definition_file(self) -> bool: - """Check if platform requires an agent definition file (.md/.toml) to run. - - Claude Code, OpenCode, iFlow: require agent .md files (--agent flag). - Codex: auto-discovers agents from .codex/agents/*.toml, no --agent flag. - """ - return self.platform in ("claude", "opencode", "iflow") - - # ========================================================================= - # Session ID Handling - # ========================================================================= - - @property - def supports_session_id_on_create(self) -> bool: - """Check if platform supports specifying session ID on creation. - - Claude Code: Yes (--session-id) - OpenCode: No (auto-generated, extract from logs) - iFlow: No (no session ID support) - """ - return self.platform == "claude" - - def extract_session_id_from_log(self, log_content: str) -> str | None: - """Extract session ID from log output (OpenCode only). - - OpenCode generates session IDs in format: ses_xxx - - Args: - log_content: Log file content - - Returns: - Session ID if found, None otherwise - """ - import re - - # OpenCode session ID pattern - match = re.search(r"ses_[a-zA-Z0-9]+", log_content) - if match: - return match.group(0) - return None - - -# ============================================================================= -# Factory Function -# ============================================================================= - - -def get_cli_adapter(platform: str = "claude") -> CLIAdapter: - """Get CLI adapter for the specified platform. - - Args: - platform: Platform name ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'devin', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', or 'trae') - - Returns: - CLIAdapter instance - - Raises: - ValueError: If platform is not supported - - Note: - 'windsurf' is accepted as a deprecated alias for 'devin' (Windsurf was - renamed to Devin) and normalized before validation. - """ - # Deprecated alias: Windsurf was renamed to Devin. - if platform == "windsurf": - platform = "devin" - if platform not in ( - "claude", - "opencode", - "cursor", - "iflow", - "codex", - "kilo", - "kiro", - "gemini", - "antigravity", - "devin", - "qoder", - "codebuddy", - "copilot", - "droid", - "pi", - "trae", - "omp", - "grok", - "kimi", - ): - raise ValueError( - f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'devin', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', 'trae', 'omp', 'grok', or 'kimi')" - ) - - return CLIAdapter(platform=platform) # type: ignore - - -_ALL_PLATFORM_CONFIG_DIRS = ( - ".claude", - ".cursor", - ".iflow", - ".opencode", - ".codex", - ".kilocode", - ".kiro", - ".gemini", - ".agent", - ".devin", - ".windsurf", # deprecated: pre-rename Devin config dir (still a platform signal) - ".qoder", - ".codebuddy", - ".github/copilot", - ".factory", - ".pi", - ".trae", - ".omp", - ".grok", - ".kimi-code", -) -"""Platform-specific config directory names used by detect_platform exclusion -checks. `.agents/skills/` is NOT listed here: it is a shared cross-platform -layer (written by Codex, also consumed by Amp/Cline/Warp/etc. via the -agentskills.io standard), not a single-platform signal. Its presence must not -block detection of Kiro, Antigravity, Devin, or other platforms.""" - - -def _has_other_platform_dir(project_root: Path, exclude: set[str]) -> bool: - """Check if any platform config dir exists besides those in *exclude*.""" - return any( - (project_root / d).is_dir() - for d in _ALL_PLATFORM_CONFIG_DIRS - if d not in exclude - ) - - -def detect_platform(project_root: Path) -> Platform: - """Auto-detect platform based on existing config directories. - - Detection order: - 1. TRELLIS_PLATFORM environment variable (if set) - 2. .opencode directory exists → opencode - 3. .iflow directory exists → iflow - 4. .cursor directory exists (without .claude) → cursor - 5. .gemini directory exists → gemini - 6. .codex exists and no other platform dirs → codex - 7. .kilocode directory exists → kilo - 8. .kiro/skills exists and no other platform dirs → kiro - 9. .agent/workflows exists and no other platform dirs → antigravity - 10. .devin/workflows (or legacy .windsurf/workflows) exists and no other platform dirs → devin - 11. .codebuddy directory exists → codebuddy - 12. .qoder directory exists → qoder - 13. .github/copilot directory exists → copilot - 14. .factory directory exists → droid - 15. .pi directory exists → pi - 16. .trae directory exists → trae - 17. Default → claude - - Args: - project_root: Project root directory - - Returns: - Detected platform ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'devin', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', 'trae', or default 'claude') - """ - import os - - # Check environment variable first - env_platform = os.environ.get("TRELLIS_PLATFORM", "").lower() - # Deprecated alias: Windsurf was renamed to Devin. - if env_platform == "windsurf": - env_platform = "devin" - if env_platform in ( - "claude", - "opencode", - "cursor", - "iflow", - "codex", - "kilo", - "kiro", - "gemini", - "antigravity", - "devin", - "qoder", - "codebuddy", - "copilot", - "droid", - "pi", - "trae", - "omp", - "grok", - "kimi", - ): - return env_platform # type: ignore - - # Check for .opencode directory (OpenCode-specific) - if (project_root / ".opencode").is_dir(): - return "opencode" - - # Check for .iflow directory (iFlow-specific) - if (project_root / ".iflow").is_dir(): - return "iflow" - - # Check for .cursor directory (Cursor-specific) - # Only detect as cursor if .claude doesn't exist (to avoid confusion) - if (project_root / ".cursor").is_dir() and not (project_root / ".claude").is_dir(): - return "cursor" - - # Check for .gemini directory (Gemini CLI-specific) - if (project_root / ".gemini").is_dir(): - return "gemini" - - # Check for .codex directory (Codex-specific) - # .agents/skills/ alone does NOT trigger codex detection (it's a shared standard) - if (project_root / ".codex").is_dir() and not _has_other_platform_dir( - project_root, {".codex", ".agents"} - ): - return "codex" - - # Check for .kilocode directory (Kilo-specific) - if (project_root / ".kilocode").is_dir(): - return "kilo" - - # Check for Kiro skills directory only when no other platform config exists - if (project_root / ".kiro" / "skills").is_dir() and not _has_other_platform_dir( - project_root, {".kiro"} - ): - return "kiro" - - # Check for Antigravity workflow directory only when no other platform config exists - if ( - project_root / ".agent" / "workflows" - ).is_dir() and not _has_other_platform_dir( - project_root, {".agent", ".gemini"} - ): - return "antigravity" - - # Check for Devin workflow directory only when no other platform config - # exists. `.windsurf/workflows` is the legacy pre-rename path (still detected - # as devin for back-compat until users migrate via `trellis update --migrate`). - if ( - (project_root / ".devin" / "workflows").is_dir() - or (project_root / ".windsurf" / "workflows").is_dir() - ) and not _has_other_platform_dir( - project_root, {".devin", ".windsurf"} - ): - return "devin" - - # Check for .codebuddy directory (CodeBuddy-specific) - if (project_root / ".codebuddy").is_dir(): - return "codebuddy" - - # Check for .qoder directory (Qoder-specific) - if (project_root / ".qoder").is_dir(): - return "qoder" - - # Check for .github/copilot directory (GitHub Copilot-specific) - if (project_root / ".github" / "copilot").is_dir(): - return "copilot" - - # Check for .factory directory (Factory Droid-specific) - if (project_root / ".factory").is_dir(): - return "droid" - - # Check for .pi directory (Pi Agent-specific) - if (project_root / ".pi").is_dir(): - return "pi" - - # Check for .trae directory (Trae IDE-specific) - if (project_root / ".trae").is_dir(): - return "trae" - - # Check for .omp directory (OMP-specific) - if (project_root / ".omp").is_dir(): - return "omp" - - # Check for .grok directory (Grok Build-specific) - if (project_root / ".grok").is_dir(): - return "grok" - - # Check for .kimi-code directory (Kimi Code-specific) - if (project_root / ".kimi-code").is_dir(): - return "kimi" - - # Fallback: checkout only has the Codex shared-skills layer - # (.agents/skills/trellis-* dirs) and no explicit platform config dir. - # Happens on fresh clones where .codex/ is gitignored/absent but the - # shared skills were committed to git. Must guard against the case - # where .claude/ or any other platform dir also exists — .agents/skills/ - # can legitimately coexist with any platform as a shared consumption - # layer for Amp/Cline/Warp/etc. - agents_skills = project_root / ".agents" / "skills" - if agents_skills.is_dir() and not _has_other_platform_dir( - project_root, set() - ): - try: - for entry in agents_skills.iterdir(): - if entry.is_dir() and entry.name.startswith("trellis-"): - return "codex" - except OSError: - pass - - return "claude" - - -def get_cli_adapter_auto(project_root: Path) -> CLIAdapter: - """Get CLI adapter with auto-detected platform. - - Args: - project_root: Project root directory - - Returns: - CLIAdapter instance for detected platform - """ - platform = detect_platform(project_root) - return CLIAdapter(platform=platform) diff --git a/.trellis/scripts/common/config.py b/.trellis/scripts/common/config.py deleted file mode 100755 index 99f79b2..0000000 --- a/.trellis/scripts/common/config.py +++ /dev/null @@ -1,569 +0,0 @@ -#!/usr/bin/env python3 -""" -Trellis configuration reader. - -Reads settings from .trellis/config.yaml with sensible defaults. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -from .paths import DIR_WORKFLOW, get_repo_root - - -# ============================================================================= -# YAML Simple Parser (no dependencies) -# ============================================================================= - - -def _unquote(s: str) -> str: - """Remove exactly one layer of matching surrounding quotes. - - Unlike str.strip('"'), this only removes the outermost pair, - preserving any nested quotes inside the value. - - Examples: - _unquote('"hello"') -> 'hello' - _unquote("'hello'") -> 'hello' - _unquote('"echo \\'hi\\'"') -> "echo 'hi'" - _unquote('hello') -> 'hello' - _unquote('"hello\\'') -> '"hello\\'' (mismatched, unchanged) - """ - if len(s) >= 2 and s[0] == s[-1] and s[0] in ('"', "'"): - return s[1:-1] - return s - - -def _strip_inline_comment(value: str) -> str: - """Strip ` # …` inline comments while preserving `#` inside quoted strings. - - YAML treats ` #` (space-hash) as a comment opener; bare `#` inside a token - is part of the value. Quoted strings are immune. - - Mirrors :func:`common.trellis_config._strip_inline_comment` so both - parsers handle ``key: value # comment`` identically. - """ - in_quote: str | None = None - for idx, ch in enumerate(value): - if in_quote: - if ch == in_quote: - in_quote = None - continue - if ch in ('"', "'"): - in_quote = ch - continue - if ch == "#" and (idx == 0 or value[idx - 1].isspace()): - return value[:idx] - return value - - -def parse_simple_yaml(content: str) -> dict: - """Parse simple YAML with nested dict support (no dependencies). - - Supports: - - key: value (string) - - key: (followed by list items) - - item1 - - item2 - - key: (followed by nested dict) - nested_key: value - nested_key2: - - item - - Uses indentation to detect nesting (2+ spaces deeper = child). - - Args: - content: YAML content string. - - Returns: - Parsed dict (values can be str, list[str], or dict). - """ - lines = content.splitlines() - result: dict = {} - _parse_yaml_block(lines, 0, 0, result) - return result - - -def _parse_yaml_block( - lines: list[str], start: int, min_indent: int, target: dict -) -> int: - """Parse a YAML block into target dict, returning next line index.""" - i = start - current_list: list | None = None - - while i < len(lines): - line = lines[i] - stripped = line.strip() - - # Skip empty lines and comments - if not stripped or stripped.startswith("#"): - i += 1 - continue - - # Calculate indentation - indent = len(line) - len(line.lstrip()) - - # If dedented past our block, we're done - if indent < min_indent: - break - - if stripped.startswith("- "): - if current_list is not None: - current_list.append(_unquote(stripped[2:].strip())) - i += 1 - elif ":" in stripped: - key, _, value = stripped.partition(":") - key = key.strip() - value = _strip_inline_comment(value).strip() - was_quoted = len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'") - value = _unquote(value) - current_list = None - - if value or was_quoted: - # key: value (an explicit quoted "" is a value, not "no value") - target[key] = value - i += 1 - else: - # key: (no value) — peek ahead to determine list vs nested dict - next_i, next_line = _next_content_line(lines, i + 1) - if next_i >= len(lines): - target[key] = {} - i = next_i - elif next_line.strip().startswith("- "): - # It's a list - current_list = [] - target[key] = current_list - i += 1 - else: - next_indent = len(next_line) - len(next_line.lstrip()) - if next_indent > indent: - # It's a nested dict - nested: dict = {} - target[key] = nested - i = _parse_yaml_block(lines, i + 1, next_indent, nested) - else: - # Empty value, same or less indent follows - target[key] = {} - i += 1 - else: - i += 1 - - return i - - -def _next_content_line(lines: list[str], start: int) -> tuple[int, str]: - """Find the next non-empty, non-comment line.""" - i = start - while i < len(lines): - stripped = lines[i].strip() - if stripped and not stripped.startswith("#"): - return i, lines[i] - i += 1 - return i, "" - - -# Defaults -DEFAULT_SESSION_COMMIT_MESSAGE = "chore: record journal" -DEFAULT_MAX_JOURNAL_LINES = 2000 -DEFAULT_SESSION_AUTO_COMMIT = True -DEFAULT_CODEX_DISPATCH_MODE = "auto" - -CONFIG_FILE = "config.yaml" - - -def _is_true_config_value(value: object) -> bool: - """Return True when a config value represents an enabled flag.""" - if isinstance(value, bool): - return value - if isinstance(value, str): - return value.strip().lower() == "true" - return False - - -def _get_config_path(repo_root: Path | None = None) -> Path: - """Get path to config.yaml.""" - root = repo_root or get_repo_root() - return root / DIR_WORKFLOW / CONFIG_FILE - - -def _load_config(repo_root: Path | None = None) -> dict: - """Load and parse config.yaml. Returns empty dict on any error.""" - config_file = _get_config_path(repo_root) - try: - content = config_file.read_text(encoding="utf-8") - return parse_simple_yaml(content) - except (OSError, IOError): - return {} - - -def get_session_commit_message(repo_root: Path | None = None) -> str: - """Get the commit message for auto-committing session records.""" - config = _load_config(repo_root) - return config.get("session_commit_message", DEFAULT_SESSION_COMMIT_MESSAGE) - - -def get_max_journal_lines(repo_root: Path | None = None) -> int: - """Get the maximum lines per journal file.""" - config = _load_config(repo_root) - value = config.get("max_journal_lines", DEFAULT_MAX_JOURNAL_LINES) - try: - return int(value) - except (ValueError, TypeError): - return DEFAULT_MAX_JOURNAL_LINES - - -def get_session_auto_commit(repo_root: Path | None = None) -> bool: - """Whether scripts should auto-stage + auto-commit session/task changes. - - Governs both ``add_session.py:_auto_commit_workspace`` and - ``task_store.py:_auto_commit_archive``. - - Default: ``True`` (existing behavior — auto-stage + auto-commit). - Set ``session_auto_commit: false`` in ``.trellis/config.yaml`` to skip - auto-staging entirely; the journal/archive files are still written to - disk, but the user manages ``git add`` / ``git commit`` themselves. - - Accepts native YAML booleans (``true`` / ``false``) and the string - aliases ``true / false / yes / no / 1 / 0 / on / off`` (case-insensitive). - Invalid values fall back to ``True`` with a stderr warning. - """ - config = _load_config(repo_root) - raw = config.get("session_auto_commit", DEFAULT_SESSION_AUTO_COMMIT) - if isinstance(raw, bool): - return raw - s = str(raw).strip().lower() - if s in ("true", "yes", "1", "on"): - return True - if s in ("false", "no", "0", "off"): - return False - print( - f"[WARN] invalid session_auto_commit value: {raw!r}; using true (default)", - file=sys.stderr, - ) - return DEFAULT_SESSION_AUTO_COMMIT - - -def get_codex_dispatch_mode(repo_root: Path | None = None) -> str: - """Return Codex dispatch mode. - - Default is ``auto``, which dispatches Trellis sub-agents and uses native - context injection with a child-side fallback. ``inline`` is an explicit - opt-out. ``sub-agent`` remains a backwards-compatible alias for ``auto``. - - Invalid explicit configuration falls back to ``inline`` rather than - unexpectedly dispatching a sub-agent. This CLI-facing parser is the only - place that emits a warning for invalid values; hook readers fail safely - without producing per-turn warning noise. - """ - config = _load_config(repo_root) - codex = config.get("codex") - if codex is None: - return DEFAULT_CODEX_DISPATCH_MODE - if not isinstance(codex, dict): - print( - f"[WARN] invalid codex config: {codex!r}; using inline", - file=sys.stderr, - ) - return "inline" - - raw = codex.get("dispatch_mode", DEFAULT_CODEX_DISPATCH_MODE) - mode = str(raw).strip().lower() - if mode in ("auto", "inline"): - return mode - if mode == "sub-agent": - return "auto" - print( - f"[WARN] invalid codex.dispatch_mode value: {raw!r}; using inline", - file=sys.stderr, - ) - return "inline" - - -DEFAULT_CONTEXT_INJECTION_MAX_FILE_BYTES = 32768 -DEFAULT_CONTEXT_INJECTION_MAX_ARTIFACT_BYTES = 65536 -DEFAULT_CONTEXT_INJECTION_MAX_TOTAL_BYTES = 131072 - - -def get_context_injection_limits(repo_root: Path | None = None) -> dict[str, int]: - """Return sub-agent context injection byte limits. - - Reads the ``context_injection:`` section of ``.trellis/config.yaml``: - - context_injection: - max_file_bytes: 32768 - max_artifact_bytes: 65536 - max_total_bytes: 131072 - - ``0`` disables the corresponding limit. Missing keys use their default; - invalid (non-int or negative) values fall back to the default for that - key with a stderr warning. - """ - defaults = { - "max_file_bytes": DEFAULT_CONTEXT_INJECTION_MAX_FILE_BYTES, - "max_artifact_bytes": DEFAULT_CONTEXT_INJECTION_MAX_ARTIFACT_BYTES, - "max_total_bytes": DEFAULT_CONTEXT_INJECTION_MAX_TOTAL_BYTES, - } - - config = _load_config(repo_root) - section = config.get("context_injection") - if not isinstance(section, dict): - return defaults - - result = dict(defaults) - for key, default_value in defaults.items(): - if key not in section: - continue - raw = section[key] - try: - value = int(raw) - except (TypeError, ValueError): - print( - f"[WARN] invalid context_injection.{key} value: {raw!r}; " - f"using default {default_value}", - file=sys.stderr, - ) - continue - if value < 0: - print( - f"[WARN] invalid context_injection.{key} value: {raw!r}; " - f"using default {default_value}", - file=sys.stderr, - ) - continue - result[key] = value - - return result - - -DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD = "no-trellis" - - -def get_prompt_injection_config(repo_root: Path | None = None) -> dict[str, str]: - """Return per-turn prompt injection config. - - Reads the ``prompt_injection:`` section of ``.trellis/config.yaml``: - - prompt_injection: - skip_keyword: "no-trellis" # "" disables the escape hatch entirely - - ``skip_keyword`` is the word-boundary, case-insensitive keyword that, when - present in the user's prompt, makes the per-turn workflow-state injection - emit nothing for that turn. Defaults to ``"no-trellis"``. A non-string - value falls back to the default. - """ - defaults = {"skip_keyword": DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD} - - config = _load_config(repo_root) - section = config.get("prompt_injection") - if not isinstance(section, dict): - return defaults - - result = dict(defaults) - raw = section.get("skip_keyword", DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD) - if isinstance(raw, str): - result["skip_keyword"] = raw - return result - - -def get_hooks(event: str, repo_root: Path | None = None) -> list[str]: - """Get hook commands for a lifecycle event. - - Args: - event: Event name (e.g. "after_create", "after_archive"). - repo_root: Repository root path. - - Returns: - List of shell commands to execute, empty if none configured. - """ - config = _load_config(repo_root) - hooks = config.get("hooks") - if not isinstance(hooks, dict): - return [] - commands = hooks.get(event) - if isinstance(commands, list): - return [str(c) for c in commands] - return [] - - -# ============================================================================= -# Monorepo / Packages -# ============================================================================= - - -def get_packages(repo_root: Path | None = None) -> dict[str, dict] | None: - """Get monorepo package declarations. - - Returns: - Dict mapping package name to its config (path, type, etc.), - or None if not configured (single-repo mode). - - Example return: - {"cli": {"path": "packages/cli"}, "docs-site": {"path": "docs-site", "type": "submodule"}} - """ - config = _load_config(repo_root) - packages = config.get("packages") - if not isinstance(packages, dict): - return None - # Ensure each value is a dict (filter out scalar entries) - filtered = {k: v for k, v in packages.items() if isinstance(v, dict)} - if not filtered: - return None - return filtered - - -def get_default_package(repo_root: Path | None = None) -> str | None: - """Get the default package name from config. - - Returns: - Package name string, or None if not configured. - """ - config = _load_config(repo_root) - value = config.get("default_package") - return str(value) if value else None - - -def get_submodule_packages(repo_root: Path | None = None) -> dict[str, str]: - """Get packages that are git submodules. - - Returns: - Dict mapping package name to its path for submodule-type packages. - Empty dict if none configured. - - Example return: - {"docs-site": "docs-site"} - """ - packages = get_packages(repo_root) - if packages is None: - return {} - return { - name: cfg.get("path", name) - for name, cfg in packages.items() - if cfg.get("type") == "submodule" - } - - -def get_git_packages(repo_root: Path | None = None) -> dict[str, str]: - """Get packages that have their own independent git repository. - - These are sub-directories with their own .git (not submodules), - marked with ``git: true`` in config.yaml. - - Returns: - Dict mapping package name to its path for git-repo packages. - Empty dict if none configured. - - Example config:: - - packages: - backend: - path: iqs - git: true - - Example return:: - - {"backend": "iqs"} - """ - packages = get_packages(repo_root) - if packages is None: - return {} - return { - name: cfg.get("path", name) - for name, cfg in packages.items() - if _is_true_config_value(cfg.get("git")) - } - - -def is_monorepo(repo_root: Path | None = None) -> bool: - """Check if the project is configured as a monorepo (has packages in config).""" - return get_packages(repo_root) is not None - - -def get_spec_base(package: str | None = None, repo_root: Path | None = None) -> str: - """Get the spec directory base path relative to .trellis/. - - Single-repo: returns "spec" - Monorepo with package: returns "spec/<package>" - Monorepo without package: returns "spec" (caller should specify package) - """ - if package and is_monorepo(repo_root): - return f"spec/{package}" - return "spec" - - -def validate_package(package: str, repo_root: Path | None = None) -> bool: - """Check if a package name is valid in this project. - - Single-repo (no packages configured): always returns True. - Monorepo: returns True only if package exists in config.yaml packages. - """ - packages = get_packages(repo_root) - if packages is None: - return True # Single-repo, no validation needed - return package in packages - - -def resolve_package( - task_package: str | None = None, - repo_root: Path | None = None, -) -> str | None: - """Resolve package from inferred sources with validation. - - Checks in order: task_package → default_package. - Invalid inferred values print a warning to stderr and are skipped. - - Returns: - Resolved package name, or None if no valid package found. - - Note: - CLI --package should be validated separately by the caller - (fail-fast with available packages list on error). - """ - packages = get_packages(repo_root) - if packages is None: - return None # Single-repo, no package needed - - # Try task_package (guard against non-string values from malformed JSON) - if task_package and isinstance(task_package, str): - if task_package in packages: - return task_package - print( - f"Warning: task.json package '{task_package}' not found in config, skipping", - file=sys.stderr, - ) - - # Try default_package - default = get_default_package(repo_root) - if default: - if default in packages: - return default - print( - f"Warning: default_package '{default}' not found in config, skipping", - file=sys.stderr, - ) - - return None - - -def get_spec_scope(repo_root: Path | None = None) -> list[str] | str | None: - """Get session.spec_scope configuration. - - Returns: - list[str]: Package names to include in spec scanning. - str: "active_task" to use current task's package. - None: No scope configured (scan all packages). - """ - config = _load_config(repo_root) - session = config.get("session") - if not isinstance(session, dict): - return None - - scope = session.get("spec_scope") - if scope is None: - return None - if isinstance(scope, str): - return scope # e.g. "active_task" - if isinstance(scope, list): - return [str(s) for s in scope] - return None diff --git a/.trellis/scripts/common/developer.py b/.trellis/scripts/common/developer.py deleted file mode 100755 index c203a31..0000000 --- a/.trellis/scripts/common/developer.py +++ /dev/null @@ -1,190 +0,0 @@ -#!/usr/bin/env python3 -""" -Developer management utilities. - -Provides: - init_developer - Initialize developer - ensure_developer - Ensure developer is initialized (exit if not) - show_developer_info - Show developer information -""" - -from __future__ import annotations - -import sys -from datetime import datetime -from pathlib import Path - -from .paths import ( - DIR_WORKFLOW, - DIR_WORKSPACE, - DIR_TASKS, - FILE_DEVELOPER, - FILE_JOURNAL_PREFIX, - get_repo_root, - get_developer, - check_developer, -) - - -# ============================================================================= -# Developer Initialization -# ============================================================================= - -def init_developer(name: str, repo_root: Path | None = None) -> bool: - """Initialize developer. - - Creates: - - .trellis/.developer file with developer info - - .trellis/workspace/<name>/ directory structure - - Initial journal file and index.md - - Args: - name: Developer name. - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - True on success, False on error. - """ - if not name: - print("Error: developer name is required", file=sys.stderr) - return False - - if repo_root is None: - repo_root = get_repo_root() - - dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER - workspace_dir = repo_root / DIR_WORKFLOW / DIR_WORKSPACE / name - - # Create .developer file - initialized_at = datetime.now().isoformat() - try: - dev_file.write_text( - f"name={name}\ninitialized_at={initialized_at}\n", - encoding="utf-8" - ) - except (OSError, IOError) as e: - print(f"Error: Failed to create .developer file: {e}", file=sys.stderr) - return False - - # Create workspace directory structure - try: - workspace_dir.mkdir(parents=True, exist_ok=True) - except (OSError, IOError) as e: - print(f"Error: Failed to create workspace directory: {e}", file=sys.stderr) - return False - - # Create initial journal file - journal_file = workspace_dir / f"{FILE_JOURNAL_PREFIX}1.md" - if not journal_file.exists(): - today = datetime.now().strftime("%Y-%m-%d") - journal_content = f"""# Journal - {name} (Part 1) - -> AI development session journal -> Started: {today} - ---- - -""" - try: - journal_file.write_text(journal_content, encoding="utf-8") - except (OSError, IOError) as e: - print(f"Error: Failed to create journal file: {e}", file=sys.stderr) - return False - - # Create index.md with markers for auto-update - index_file = workspace_dir / "index.md" - if not index_file.exists(): - index_content = f"""# Workspace Index - {name} - -> Journal tracking for AI development sessions. - ---- - -## Current Status - -<!-- @@@auto:current-status --> -- **Active File**: `journal-1.md` -- **Total Sessions**: 0 -- **Last Active**: - -<!-- @@@/auto:current-status --> - ---- - -## Active Documents - -<!-- @@@auto:active-documents --> -| File | Lines | Status | -|------|-------|--------| -| `journal-1.md` | ~0 | Active | -<!-- @@@/auto:active-documents --> - ---- - -## Session History - -<!-- @@@auto:session-history --> -| # | Date | Title | Commits | Branch | -|---|------|-------|---------|--------| -<!-- @@@/auto:session-history --> - ---- - -## Notes - -- Sessions are appended to journal files -- New journal file created when current exceeds 2000 lines -- Use `add_session.py` to record sessions -""" - try: - index_file.write_text(index_content, encoding="utf-8") - except (OSError, IOError) as e: - print(f"Error: Failed to create index.md: {e}", file=sys.stderr) - return False - - print(f"Developer initialized: {name}") - print(f" .developer file: {dev_file}") - print(f" Workspace dir: {workspace_dir}") - - return True - - -def ensure_developer(repo_root: Path | None = None) -> None: - """Ensure developer is initialized, exit if not. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - """ - if repo_root is None: - repo_root = get_repo_root() - - if not check_developer(repo_root): - print("Error: Developer not initialized.", file=sys.stderr) - print(f"Run: python3 ./{DIR_WORKFLOW}/scripts/init_developer.py <your-name>", file=sys.stderr) - sys.exit(1) - - -def show_developer_info(repo_root: Path | None = None) -> None: - """Show developer information. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - """ - if repo_root is None: - repo_root = get_repo_root() - - developer = get_developer(repo_root) - - if not developer: - print("Developer: (not initialized)") - else: - print(f"Developer: {developer}") - print(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/") - print(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/") - - -# ============================================================================= -# Main Entry (for testing) -# ============================================================================= - -if __name__ == "__main__": - show_developer_info() diff --git a/.trellis/scripts/common/git.py b/.trellis/scripts/common/git.py deleted file mode 100755 index 426836c..0000000 --- a/.trellis/scripts/common/git.py +++ /dev/null @@ -1,74 +0,0 @@ -""" -Git command execution utility. - -Single source of truth for running git commands across all Trellis scripts. -""" - -from __future__ import annotations - -import subprocess -from pathlib import Path - - -def run_git( - args: list[str], - cwd: Path | None = None, - timeout: float | None = None, -) -> tuple[int, str, str]: - """Run a git command and return (returncode, stdout, stderr). - - Uses UTF-8 encoding with -c i18n.logOutputEncoding=UTF-8 to ensure - consistent output across all platforms (Windows, macOS, Linux). Callers - may provide a timeout for best-effort probes; normal Git operations remain - unbounded by default. - """ - try: - git_args = ["git", "-c", "i18n.logOutputEncoding=UTF-8"] + args - result = subprocess.run( - git_args, - cwd=cwd, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=timeout, - ) - return result.returncode, result.stdout, result.stderr - except Exception as e: - return 1, "", str(e) - - -def resolve_default_branch(repo_root: Path) -> str | None: - """Resolve the repository's default branch (origin/HEAD target). - - Tries the local `refs/remotes/origin/HEAD` symbolic ref first (no - network access), then falls back to `git remote show origin` (which - may hit the network but also repairs a missing/stale symbolic-ref). - Returns None when neither resolves, so callers can fall back to their - own pre-existing behavior. - """ - rc, out, _ = run_git(["symbolic-ref", "refs/remotes/origin/HEAD"], cwd=repo_root) - if rc == 0 and out.strip(): - return out.strip().rsplit("/", 1)[-1] - - rc, out, _ = run_git(["remote", "show", "origin"], cwd=repo_root) - if rc == 0: - for line in out.splitlines(): - line = line.strip() - if line.startswith("HEAD branch:"): - branch = line.split(":", 1)[1].strip() - if branch and branch != "(unknown)": - return branch - - return None - - -def branch_exists_locally(branch: str, repo_root: Path) -> bool: - """Check whether a local branch ref exists in the repository.""" - if not branch: - return False - rc, _, _ = run_git( - ["rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"], - cwd=repo_root, - ) - return rc == 0 diff --git a/.trellis/scripts/common/git_context.py b/.trellis/scripts/common/git_context.py deleted file mode 100755 index 23fc6ec..0000000 --- a/.trellis/scripts/common/git_context.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Git and Session Context utilities. - -Entry shim — delegates to session_context and packages_context. - -Provides: - output_json - Output context in JSON format - output_text - Output context in text format -""" - -from __future__ import annotations - -import json - -from .git import run_git -from .session_context import ( - get_context_json, - get_context_text, - get_context_record_json, - get_context_text_record, - output_json, - output_text, -) -from .packages_context import ( - get_context_packages_text, - get_context_packages_json, -) -from .trellis_config import read_trellis_config -from .workflow_phase import ( - filter_platform, - get_phase_index, - get_step, - resolve_effective_platform, -) - -# Backward-compatible alias — external modules import this name -_run_git_command = run_git - - -# ============================================================================= -# Main Entry -# ============================================================================= - -def main() -> None: - """CLI entry point.""" - import argparse - - parser = argparse.ArgumentParser(description="Get Session Context for AI Agent") - parser.add_argument( - "--json", - "-j", - action="store_true", - help="Output in JSON format (works with any --mode)", - ) - parser.add_argument( - "--mode", - "-m", - choices=["default", "record", "packages", "phase"], - default="default", - help="Output mode: default (full context), record (for record-session), packages (package info only), phase (workflow step extraction)", - ) - parser.add_argument( - "--step", - help="Step id for --mode phase, e.g. 1.1, 2.2. Omit to get the Phase Index.", - ) - parser.add_argument( - "--platform", - help="Platform name for --mode phase, e.g. cursor, claude-code. Filters platform-tagged blocks.", - ) - - args = parser.parse_args() - - if args.mode == "record": - if args.json: - print(json.dumps(get_context_record_json(), indent=2, ensure_ascii=False)) - else: - print(get_context_text_record()) - elif args.mode == "packages": - if args.json: - print(json.dumps(get_context_packages_json(), indent=2, ensure_ascii=False)) - else: - print(get_context_packages_text()) - elif args.mode == "phase": - content = get_step(args.step) if args.step else get_phase_index() - if not content.strip(): - if args.step: - parser.exit(2, f"Step not found: {args.step}\n") - else: - parser.exit(2, "Phase Index section not found in workflow.md\n") - if args.platform: - effective = resolve_effective_platform( - args.platform, read_trellis_config() - ) - content = filter_platform(content, effective) - print(content, end="") - else: - if args.json: - output_json() - else: - output_text() - - -if __name__ == "__main__": - main() diff --git a/.trellis/scripts/common/io.py b/.trellis/scripts/common/io.py deleted file mode 100755 index c918c1f..0000000 --- a/.trellis/scripts/common/io.py +++ /dev/null @@ -1,61 +0,0 @@ -""" -JSON file I/O utilities. - -Provides read_json and write_json as the single source of truth -for JSON file operations across all Trellis scripts. -""" - -from __future__ import annotations - -import json -import os -import tempfile -from pathlib import Path - - -def read_json(path: Path) -> dict | None: - """Read and parse a JSON file. - - Returns None if the file doesn't exist, is invalid JSON, or can't be read. - """ - try: - return json.loads(path.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return None - - -def write_json(path: Path, data: dict) -> bool: - """Write dict to JSON file with pretty formatting. - - The write is atomic: content goes to a temp file in the same directory - and is then renamed over the target. A crash or Ctrl-C mid-write leaves - the existing file intact rather than truncated, so a corrupted task.json - can never make a task silently vanish from `task.py list`. - - Returns True on success, False on error. - """ - payload = json.dumps(data, indent=2, ensure_ascii=False) - try: - fd, tmp = tempfile.mkstemp( - dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp" - ) - except OSError: - return False - - try: - try: - f = os.fdopen(fd, "w", encoding="utf-8") - except OSError: - # fdopen never took ownership of fd; close it ourselves. - os.close(fd) - raise - with f: - f.write(payload) - os.replace(tmp, path) - return True - except OSError: - try: - os.unlink(tmp) - except OSError: - pass - return False diff --git a/.trellis/scripts/common/log.py b/.trellis/scripts/common/log.py deleted file mode 100755 index 839c643..0000000 --- a/.trellis/scripts/common/log.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -Terminal output utilities: colors and structured logging. - -Single source of truth for Colors and log_* functions -used across all Trellis scripts. -""" - -from __future__ import annotations - - -class Colors: - """ANSI color codes for terminal output.""" - - RED = "\033[0;31m" - GREEN = "\033[0;32m" - YELLOW = "\033[1;33m" - BLUE = "\033[0;34m" - CYAN = "\033[0;36m" - DIM = "\033[2m" - NC = "\033[0m" # No Color / Reset - - -def colored(text: str, color: str) -> str: - """Apply ANSI color to text.""" - return f"{color}{text}{Colors.NC}" - - -def log_info(msg: str) -> None: - """Print info-level message with [INFO] prefix.""" - print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}") - - -def log_success(msg: str) -> None: - """Print success message with [SUCCESS] prefix.""" - print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}") - - -def log_warn(msg: str) -> None: - """Print warning message with [WARN] prefix.""" - print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}") - - -def log_error(msg: str) -> None: - """Print error message with [ERROR] prefix.""" - print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}") diff --git a/.trellis/scripts/common/packages_context.py b/.trellis/scripts/common/packages_context.py deleted file mode 100755 index e7d4e8c..0000000 --- a/.trellis/scripts/common/packages_context.py +++ /dev/null @@ -1,238 +0,0 @@ -#!/usr/bin/env python3 -""" -Package discovery and context output. - -Provides: - get_packages_info - Get structured package info - get_packages_section - Build PACKAGES text section - get_context_packages_text - Full packages text output (--mode packages) - get_context_packages_json - Full packages JSON output (--mode packages --json) -""" - -from __future__ import annotations - -from pathlib import Path - -from .config import _is_true_config_value, get_default_package, get_packages, get_spec_scope -from .paths import ( - DIR_SPEC, - DIR_WORKFLOW, - get_current_task, - get_repo_root, -) -from .tasks import load_task - - -# ============================================================================= -# Internal Helpers -# ============================================================================= - -def _scan_spec_layers(spec_dir: Path, package: str | None = None) -> list[str]: - """Scan spec directory for available layers (subdirectories). - - For monorepo: scans spec/<package>/ - For single-repo: scans spec/ - """ - target = spec_dir / package if package else spec_dir - if not target.is_dir(): - return [] - return sorted( - d.name for d in target.iterdir() if d.is_dir() and d.name != "guides" - ) - - -def _get_active_task_package(repo_root: Path) -> str | None: - """Get the package field from the active task's task.json.""" - current = get_current_task(repo_root) - if not current: - return None - ct = load_task(repo_root / current) - return ct.package if ct and ct.package else None - - -def _resolve_scope_set( - packages: dict, - spec_scope, - task_pkg: str | None, - default_pkg: str | None, -) -> set | None: - """Resolve spec_scope to a set of allowed package names, or None for full scan.""" - if not packages: - return None - - if spec_scope is None: - return None - - if isinstance(spec_scope, str) and spec_scope == "active_task": - if task_pkg and task_pkg in packages: - return {task_pkg} - if default_pkg and default_pkg in packages: - return {default_pkg} - return None - - if isinstance(spec_scope, list): - valid = {e for e in spec_scope if e in packages} - if valid: - return valid - # All invalid: fallback - if task_pkg and task_pkg in packages: - return {task_pkg} - if default_pkg and default_pkg in packages: - return {default_pkg} - return None - - return None - - -# ============================================================================= -# Public Functions -# ============================================================================= - -def get_packages_info(repo_root: Path) -> list[dict]: - """Get structured package info for monorepo projects. - - Returns list of dicts with keys: name, path, type, default, specLayers, - isSubmodule, isGitRepo. - Returns empty list for single-repo projects. - """ - packages = get_packages(repo_root) - if not packages: - return [] - - default_pkg = get_default_package(repo_root) - spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC - result = [] - - for pkg_name, pkg_config in packages.items(): - pkg_path = pkg_config.get("path", pkg_name) if isinstance(pkg_config, dict) else str(pkg_config) - pkg_type = pkg_config.get("type", "local") if isinstance(pkg_config, dict) else "local" - pkg_git = pkg_config.get("git", False) if isinstance(pkg_config, dict) else False - layers = _scan_spec_layers(spec_dir, pkg_name) - - result.append({ - "name": pkg_name, - "path": pkg_path, - "type": pkg_type, - "default": pkg_name == default_pkg, - "specLayers": layers, - "isSubmodule": pkg_type == "submodule", - "isGitRepo": _is_true_config_value(pkg_git), - }) - - return result - - -def get_packages_section(repo_root: Path) -> str: - """Build the PACKAGES section for text output.""" - spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC - pkg_info = get_packages_info(repo_root) - - lines: list[str] = [] - lines.append("## PACKAGES") - - if not pkg_info: - lines.append("(single-repo mode)") - layers = _scan_spec_layers(spec_dir) - if layers: - lines.append(f"Spec layers: {', '.join(layers)}") - return "\n".join(lines) - - default_pkg = get_default_package(repo_root) - - for pkg in pkg_info: - layers_str = f" [{', '.join(pkg['specLayers'])}]" if pkg["specLayers"] else "" - submodule_tag = " (submodule)" if pkg["isSubmodule"] else "" - git_repo_tag = " (git repo)" if pkg["isGitRepo"] else "" - default_tag = " *" if pkg["default"] else "" - lines.append( - f"- {pkg['name']:<16} {pkg['path']:<20}{layers_str}{submodule_tag}{git_repo_tag}{default_tag}" - ) - - if default_pkg: - lines.append(f"Default package: {default_pkg}") - - return "\n".join(lines) - - -def get_context_packages_text(repo_root: Path | None = None) -> str: - """Get packages context as formatted text (for --mode packages).""" - if repo_root is None: - repo_root = get_repo_root() - - pkg_info = get_packages_info(repo_root) - lines: list[str] = [] - - if not pkg_info: - spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC - lines.append("Single-repo project (no packages configured)") - lines.append("") - layers = _scan_spec_layers(spec_dir) - if layers: - lines.append(f"Spec layers: {', '.join(layers)}") - return "\n".join(lines) - - # Resolve scope for annotations - packages_dict = get_packages(repo_root) or {} - default_pkg = get_default_package(repo_root) - spec_scope = get_spec_scope(repo_root) - task_pkg = _get_active_task_package(repo_root) - scope_set = _resolve_scope_set(packages_dict, spec_scope, task_pkg, default_pkg) - - lines.append("## PACKAGES") - lines.append("") - for pkg in pkg_info: - default_tag = " (default)" if pkg["default"] else "" - type_tag = f" [{pkg['type']}]" if pkg["type"] != "local" else "" - git_tag = " [git repo]" if pkg["isGitRepo"] else "" - - # Scope annotation - scope_tag = "" - if scope_set is not None and pkg["name"] not in scope_set: - scope_tag = " (out of scope)" - - lines.append(f"### {pkg['name']}{default_tag}{type_tag}{git_tag}{scope_tag}") - lines.append(f"Path: {pkg['path']}") - if pkg["specLayers"]: - lines.append(f"Spec layers: {', '.join(pkg['specLayers'])}") - for layer in pkg["specLayers"]: - lines.append(f" - .trellis/spec/{pkg['name']}/{layer}/index.md") - else: - lines.append("Spec: not configured") - lines.append("") - - # Also show shared guides - guides_dir = repo_root / DIR_WORKFLOW / DIR_SPEC / "guides" - if guides_dir.is_dir(): - lines.append("### Shared Guides (always included)") - lines.append("Path: .trellis/spec/guides/index.md") - lines.append("") - - return "\n".join(lines) - - -def get_context_packages_json(repo_root: Path | None = None) -> dict: - """Get packages context as a dictionary (for --mode packages --json).""" - if repo_root is None: - repo_root = get_repo_root() - - pkg_info = get_packages_info(repo_root) - - if not pkg_info: - spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC - layers = _scan_spec_layers(spec_dir) - return { - "mode": "single-repo", - "specLayers": layers, - } - - default_pkg = get_default_package(repo_root) - spec_scope = get_spec_scope(repo_root) - task_pkg = _get_active_task_package(repo_root) - - return { - "mode": "monorepo", - "packages": pkg_info, - "defaultPackage": default_pkg, - "specScope": spec_scope, - "activeTaskPackage": task_pkg, - } diff --git a/.trellis/scripts/common/paths.py b/.trellis/scripts/common/paths.py deleted file mode 100755 index 814e8e8..0000000 --- a/.trellis/scripts/common/paths.py +++ /dev/null @@ -1,480 +0,0 @@ -#!/usr/bin/env python3 -""" -Common path utilities for Trellis workflow. - -Provides: - get_repo_root - Get repository root directory - get_developer - Get developer name - get_workspace_dir - Get developer workspace directory - get_tasks_dir - Get tasks directory - get_active_journal_file - Get current journal file -""" - -from __future__ import annotations - -import re -from datetime import datetime -from pathlib import Path - - -# ============================================================================= -# Path Constants (change here to rename directories) -# ============================================================================= - -# Directory names -DIR_WORKFLOW = ".trellis" -DIR_WORKSPACE = "workspace" -DIR_TASKS = "tasks" -DIR_ARCHIVE = "archive" -DIR_SPEC = "spec" -DIR_SCRIPTS = "scripts" - -# File names -FILE_DEVELOPER = ".developer" -FILE_CURRENT_TASK = ".current-task" -FILE_TASK_JSON = "task.json" -FILE_JOURNAL_PREFIX = "journal-" - - -# ============================================================================= -# Repository Root -# ============================================================================= - -def get_repo_root(start_path: Path | None = None) -> Path: - """Find the nearest directory containing .trellis/ folder. - - This handles nested git repos correctly (e.g., test project inside another repo). - - Args: - start_path: Starting directory to search from. Defaults to current directory. - - Returns: - Path to repository root, or current directory if no .trellis/ found. - """ - current = (start_path or Path.cwd()).resolve() - - while current != current.parent: - if (current / DIR_WORKFLOW).is_dir(): - return current - current = current.parent - - # Fallback to current directory if no .trellis/ found - return Path.cwd().resolve() - - -# ============================================================================= -# Developer -# ============================================================================= - -def get_developer(repo_root: Path | None = None) -> str | None: - """Get developer name from .developer file. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Developer name or None if not initialized. - """ - if repo_root is None: - repo_root = get_repo_root() - - dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER - - if not dev_file.is_file(): - return None - - try: - content = dev_file.read_text(encoding="utf-8") - for line in content.splitlines(): - if line.startswith("name="): - return line.split("=", 1)[1].strip() - except (OSError, IOError): - pass - - return None - - -def check_developer(repo_root: Path | None = None) -> bool: - """Check if developer is initialized. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - True if developer is initialized. - """ - return get_developer(repo_root) is not None - - -# ============================================================================= -# Tasks Directory -# ============================================================================= - -def get_tasks_dir(repo_root: Path | None = None) -> Path: - """Get tasks directory path. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Path to tasks directory. - """ - if repo_root is None: - repo_root = get_repo_root() - return repo_root / DIR_WORKFLOW / DIR_TASKS - - -# ============================================================================= -# Workspace Directory -# ============================================================================= - -def get_workspace_dir(repo_root: Path | None = None) -> Path | None: - """Get developer workspace directory. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Path to workspace directory or None if developer not set. - """ - if repo_root is None: - repo_root = get_repo_root() - - developer = get_developer(repo_root) - if developer: - return repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer - return None - - -# ============================================================================= -# Journal File -# ============================================================================= - -def get_active_journal_file(repo_root: Path | None = None) -> Path | None: - """Get the current active journal file. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Path to active journal file or None if not found. - """ - if repo_root is None: - repo_root = get_repo_root() - - workspace_dir = get_workspace_dir(repo_root) - if workspace_dir is None or not workspace_dir.is_dir(): - return None - - latest: Path | None = None - highest = 0 - - for f in workspace_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"): - if not f.is_file(): - continue - - # Extract number from filename - name = f.stem # e.g., "journal-1" - match = re.search(r"(\d+)$", name) - if match: - num = int(match.group(1)) - if num > highest: - highest = num - latest = f - - return latest - - -def count_lines(file_path: Path) -> int: - """Count lines in a file. - - Args: - file_path: Path to file. - - Returns: - Number of lines, or 0 if file doesn't exist. - """ - if not file_path.is_file(): - return 0 - - try: - return len(file_path.read_text(encoding="utf-8").splitlines()) - except (OSError, IOError): - return 0 - - -# ============================================================================= -# Current Task Management -# ============================================================================= - -def normalize_task_ref(task_ref: str) -> str: - """Normalize a task ref for stable runtime storage. - - Stored refs should prefer repo-relative POSIX paths like - `.trellis/tasks/03-27-my-task`, even on Windows. Absolute paths are preserved - unless they can later be converted back to repo-relative form by callers. - """ - normalized = task_ref.strip() - if not normalized: - return "" - - path_obj = Path(normalized) - if path_obj.is_absolute(): - return str(path_obj) - - normalized = normalized.replace("\\", "/") - while normalized.startswith("./"): - normalized = normalized[2:] - - if normalized.startswith(f"{DIR_TASKS}/"): - return f"{DIR_WORKFLOW}/{normalized}" - - return normalized - - -def resolve_task_ref(task_ref: str, repo_root: Path | None = None) -> Path | None: - """Resolve a task ref to an absolute task directory path 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() - - normalized = normalize_task_ref(task_ref) - if not normalized: - return None - - path_obj = Path(normalized) - if path_obj.is_absolute(): - candidate = path_obj - elif normalized.startswith(f"{DIR_WORKFLOW}/"): - candidate = repo_root / path_obj - else: - candidate = repo_root / DIR_WORKFLOW / DIR_TASKS / 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() - root = repo_root.resolve() - except OSError: - return None - - try: - resolved.relative_to(root) - except ValueError: - return None - - return resolved - - -def get_current_task( - repo_root: Path | None = None, - platform_input: dict | None = None, - platform: str | None = None, -) -> str | None: - """Get current task directory path (relative to repo_root). - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Relative path to current task directory or None. - """ - if repo_root is None: - repo_root = get_repo_root() - - from .active_task import resolve_active_task - - return resolve_active_task(repo_root, platform_input, platform).task_path - - -def get_current_task_abs( - repo_root: Path | None = None, - platform_input: dict | None = None, - platform: str | None = None, -) -> Path | None: - """Get current task directory absolute path. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Absolute path to current task directory or None. - """ - if repo_root is None: - repo_root = get_repo_root() - - relative = get_current_task(repo_root, platform_input, platform) - if relative: - return resolve_task_ref(relative, repo_root) - return None - - -def get_current_task_source( - repo_root: Path | None = None, - platform_input: dict | None = None, - platform: str | None = None, -) -> tuple[str, str | None, str | None]: - """Get active task source as (`source`, `context_key`, `task_path`).""" - if repo_root is None: - repo_root = get_repo_root() - - from .active_task import get_current_task_source as _get_source - - return _get_source(repo_root, platform_input, platform) - - -def set_current_task( - task_path: str, - repo_root: Path | None = None, - platform_input: dict | None = None, - platform: str | None = None, -) -> bool: - """Set current task in session scope. - - Args: - task_path: Task directory path (relative to repo_root). - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - True on success, False on error. - """ - if repo_root is None: - repo_root = get_repo_root() - - from .active_task import set_active_task - - return set_active_task( - task_path, - repo_root, - platform_input=platform_input, - platform=platform, - ) is not None - - -def clear_current_task( - repo_root: Path | None = None, - platform_input: dict | None = None, - platform: str | None = None, -) -> bool: - """Clear current task in session scope. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - True on success. - """ - if repo_root is None: - repo_root = get_repo_root() - - from .active_task import clear_active_task - - clear_active_task( - repo_root, - platform_input=platform_input, - platform=platform, - ) - return True - - -def has_current_task(repo_root: Path | None = None) -> bool: - """Check if has current task. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - True if current task is set. - """ - return get_current_task(repo_root) is not None - - -# ============================================================================= -# Task ID Generation -# ============================================================================= - -def generate_task_date_prefix() -> str: - """Generate task ID based on date (MM-DD format). - - Returns: - Date prefix string (e.g., "01-21"). - """ - return datetime.now().strftime("%m-%d") - - -# ============================================================================= -# Monorepo / Package Paths -# ============================================================================= - - -def get_spec_dir(package: str | None = None, repo_root: Path | None = None) -> Path: - """Get the spec directory path. - - Single-repo: .trellis/spec - Monorepo with package: .trellis/spec/<package> - - Uses lazy import to avoid circular dependency with config.py. - """ - if repo_root is None: - repo_root = get_repo_root() - - from .config import get_spec_base - - base = get_spec_base(package, repo_root) - return repo_root / DIR_WORKFLOW / base - - -def get_package_path(package: str, repo_root: Path | None = None) -> Path | None: - """Get a package's source directory absolute path from config. - - Returns: - Absolute path to the package directory, or None if not found. - """ - if repo_root is None: - repo_root = get_repo_root() - - from .config import get_packages - - packages = get_packages(repo_root) - if not packages or package not in packages: - return None - - info = packages[package] - if isinstance(info, dict): - rel_path = info.get("path", package) - else: - rel_path = str(info) - - return repo_root / rel_path - - -# ============================================================================= -# Main Entry (for testing) -# ============================================================================= - -if __name__ == "__main__": - repo = get_repo_root() - print(f"Repository root: {repo}") - print(f"Developer: {get_developer(repo)}") - print(f"Tasks dir: {get_tasks_dir(repo)}") - print(f"Workspace dir: {get_workspace_dir(repo)}") - print(f"Journal file: {get_active_journal_file(repo)}") - print(f"Current task: {get_current_task(repo)}") diff --git a/.trellis/scripts/common/safe_commit.py b/.trellis/scripts/common/safe_commit.py deleted file mode 100755 index 1a1cba2..0000000 --- a/.trellis/scripts/common/safe_commit.py +++ /dev/null @@ -1,315 +0,0 @@ -""" -Safe git-add helpers for Trellis-owned paths. - -Why this module exists ----------------------- -A real user incident: a project's `.gitignore` listed `.trellis/` (company-wide -template / personal habit). When `add_session.py` and `task.py archive` ran -their auto-commit and `git add` failed with `ignored by .gitignore`, the AI -agent driving the workflow "fixed" it by retrying with -`git add -f .trellis/` — which fan-out-included every ignored subtree -(`.trellis/.backup-*/`, `.trellis/worktrees/`, `.trellis/.template-hashes.json`, -`.trellis/.runtime/`), committing 548 files / 83474 lines of caches/backups. - -Design ------- -- Scripts only stage SPECIFIC product paths (journal files, index.md, the - current task dir, the archive dir). Never the whole `.trellis/` tree. -- If plain `git add <specific>` fails with "ignored by", DO NOT retry with - ``-f``. The presence of `.trellis/` in `.gitignore` is treated as user - intent ("keep .trellis/ local-only"). The script warns and skips the - auto-commit; users who want auto-staging can either fix their `.gitignore` - or set ``session_auto_commit: false`` and manage git themselves. -- The warning includes a negative example: ``Do NOT use `git add -f .trellis/` ...`` - so any AI rereading the log doesn't reinvent the bug. - -History note: 0.5.10 introduced an automatic ``git add -f`` retry on the -specific paths. That was reverted in 0.5.11 — auto-forcing into a tree the -user had gitignored violates user intent even when the path list is narrow. -The wider-grain forbidden command stays forbidden, and the narrow-grain auto -``-f`` is gone too. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -from .git import run_git -from .paths import ( - DIR_ARCHIVE, - DIR_TASKS, - DIR_WORKFLOW, - DIR_WORKSPACE, - FILE_JOURNAL_PREFIX, - get_developer, -) - - -# Paths under .trellis/ that must NEVER be auto-staged. Listed here so the -# warning to the user can show concrete subpaths to ignore individually -# instead of ignoring the whole `.trellis/` tree. -TRELLIS_IGNORED_SUBPATHS = ( - ".trellis/.backup-*", - ".trellis/worktrees/", - ".trellis/.template-hashes.json", - ".trellis/.runtime/", - ".trellis/.cache/", -) - - -def safe_trellis_paths_to_add( - repo_root: Path, - task_name: str | None = None, -) -> list[str]: - """Return the list of repo-relative paths the auto-commit should stage. - - Only includes paths that exist on disk so callers don't pass non-existent - arguments to git. The caller is responsible for `git diff --cached` - checking afterwards. - - Included: - - .trellis/workspace/<developer>/journal-*.md - - .trellis/workspace/<developer>/index.md - - .trellis/tasks/<task_name>/ (ONLY the current task dir when - ``task_name`` is passed; plus its archive location if the task - already lives under archive/) - - Excluded (intentionally — these must not be staged): - - .trellis/.backup-*, .trellis/worktrees/, - .trellis/.template-hashes.json, .trellis/.runtime/, .trellis/.cache/ - - Scope contract (see #303 / break-loop analysis): when ``task_name`` is - passed, the task segment stages ONLY that task directory — it never walks - ``tasks_dir.iterdir()`` over all active tasks. This mirrors - :func:`safe_archive_paths_to_add` and prevents dirty changes in OTHER - parallel-window task dirs from being bundled into the session auto-commit. - - Backwards-compat: with no ``task_name``, the function walks every active - task directory (+ the archive subtree) the old wide way. New callers - should always pass ``task_name``. - """ - paths: list[str] = [] - - # Workspace journal files + index.md - developer = get_developer(repo_root) - if developer: - ws = repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer - if ws.is_dir(): - for f in sorted(ws.glob(f"{FILE_JOURNAL_PREFIX}*.md")): - if f.is_file(): - paths.append( - f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{f.name}" - ) - index_md = ws / "index.md" - if index_md.is_file(): - paths.append( - f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/index.md" - ) - - tasks_dir = repo_root / DIR_WORKFLOW / DIR_TASKS - if not tasks_dir.is_dir(): - return paths - - if task_name is not None: - # Narrow scope — ONLY the current task directory (active or archived). - # Never iterdir() all tasks: parallel-window dirty task dirs must not - # leak into the session auto-commit. - active_task = tasks_dir / task_name - if active_task.is_dir(): - paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}") - archived_task = tasks_dir / DIR_ARCHIVE / task_name - if archived_task.is_dir(): - paths.append( - f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{task_name}" - ) - return paths - - # Legacy wide scope (no task_name): each direct child of tasks/ that is a - # directory and not the archive root, plus the whole archive subtree. - for child in sorted(tasks_dir.iterdir()): - if not child.is_dir(): - continue - if child.name == DIR_ARCHIVE: - continue - paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child.name}") - - archive_dir = tasks_dir / DIR_ARCHIVE - if archive_dir.is_dir(): - paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}") - - return paths - - -def safe_archive_paths_to_add( - repo_root: Path, - task_name: str | None = None, - modified_children: list[str] | None = None, -) -> list[str]: - """Return paths to stage after `task.py archive`. - - Scoped to ONLY the paths the archive operation actually touched: - - - the archive subtree (where the freshly-moved task lives) - - the source task directory (for source-side deletes; caller pairs - this with `git rm --cached` since `git add` won't stage deletes - for a path that no longer exists in the working tree) - - any child task directories whose `task.json` was edited to drop - the archived parent (parent-children relationship update) - - This narrow scope avoids "scope creep" — dirty changes in OTHER - active task dirs (parallel-window edits) are NOT bundled into the - archive commit. Callers handle each kind of change in its own - commit boundary. - - Backwards-compat: with no arguments, the function walks the whole - `.trellis/tasks/` subtree the old way (active tasks + archive). New - callers should always pass `task_name`. - """ - paths: list[str] = [] - tasks_dir = repo_root / DIR_WORKFLOW / DIR_TASKS - if not tasks_dir.is_dir(): - return paths - - archive_dir = tasks_dir / DIR_ARCHIVE - - if task_name is not None: - # Narrow scope — only paths that still exist on disk (so - # `git add` doesn't choke on the moved-away source). The caller - # handles the source-side deletes via `git rm --cached` - # explicitly. - if archive_dir.is_dir(): - paths.append( - f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}" - ) - for child_name in modified_children or []: - paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child_name}") - return paths - - # Legacy wide scope (no task_name): preserve old behavior so callers - # that have not been updated keep working. - if archive_dir.is_dir(): - paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}") - for child in sorted(tasks_dir.iterdir()): - if not child.is_dir(): - continue - if child.name == DIR_ARCHIVE: - continue - paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child.name}") - return paths - - -def _stderr_indicates_ignored(stderr: str) -> bool: - """git add error indicates the path is excluded by .gitignore.""" - if not stderr: - return False - lowered = stderr.lower() - return "ignored by" in lowered - - -def safe_git_add( - paths: list[str], repo_root: Path -) -> tuple[bool, bool, str]: - """Run `git add` on specific paths; never retry with -f. - - Returns ``(success, used_force, stderr)``. The ``used_force`` field is - kept for signature compatibility with the 0.5.10 implementation but is - always ``False`` — we never auto-force. - - Behavior: - - No paths passed → success, no force, empty stderr. - - Plain ``git add -- <paths>`` succeeds → return success. - - Plain fails (any reason — ignored or otherwise) → return failure with - the stderr. Callers should inspect the stderr (see - :func:`print_gitignore_warning`) and skip the auto-commit. - """ - if not paths: - return True, False, "" - - rc, _, err = run_git(["add", "--", *paths], cwd=repo_root) - if rc == 0: - return True, False, "" - return False, False, err - - -def print_gitignore_warning(paths: list[str]) -> None: - """Explain to the user (and any AI reading the log) what to do. - - CRITICAL: includes the negative example - ``Do NOT use `git add -f .trellis/``` — agents reading the warning are - known to invent that command, which fans out to ignored caches/backups. - """ - print( - "[WARN] git add failed because .trellis/ paths are ignored by your .gitignore.", - file=sys.stderr, - ) - print( - "[WARN] Skipping auto-commit. The journal/task files were still written to disk;", - file=sys.stderr, - ) - print( - "[WARN] git was not touched.", - file=sys.stderr, - ) - print("[WARN]", file=sys.stderr) - print( - "[WARN] Trellis manages these specific paths and they should be tracked:", - file=sys.stderr, - ) - if paths: - for p in paths: - print(f"[WARN] {p}", file=sys.stderr) - else: - print( - "[WARN] .trellis/workspace/<developer>/{journal-*.md,index.md}", - file=sys.stderr, - ) - print( - "[WARN] .trellis/tasks/<task-dir>/", - file=sys.stderr, - ) - print( - "[WARN] .trellis/tasks/archive/", - file=sys.stderr, - ) - print("[WARN]", file=sys.stderr) - print( - "[WARN] Recommended: change your .gitignore from `.trellis/` to specific", - file=sys.stderr, - ) - print( - "[WARN] subpaths that should remain ignored, e.g.:", - file=sys.stderr, - ) - for sub in TRELLIS_IGNORED_SUBPATHS: - print(f"[WARN] {sub}", file=sys.stderr) - print("[WARN]", file=sys.stderr) - print( - "[WARN] Or, if you intentionally keep .trellis/ local-only, set in", - file=sys.stderr, - ) - print( - "[WARN] .trellis/config.yaml:", - file=sys.stderr, - ) - print( - "[WARN] session_auto_commit: false", - file=sys.stderr, - ) - print( - "[WARN] so the scripts skip git entirely and you can review / commit", - file=sys.stderr, - ) - print( - "[WARN] manually with `git status` / `git add` / `git commit`.", - file=sys.stderr, - ) - print("[WARN]", file=sys.stderr) - print( - "[WARN] Do NOT use `git add -f .trellis/` — it pulls in backups, worktrees,", - file=sys.stderr, - ) - print( - "[WARN] and runtime caches that should never be committed.", - file=sys.stderr, - ) diff --git a/.trellis/scripts/common/session_context.py b/.trellis/scripts/common/session_context.py deleted file mode 100755 index 1e3fe1f..0000000 --- a/.trellis/scripts/common/session_context.py +++ /dev/null @@ -1,892 +0,0 @@ -#!/usr/bin/env python3 -""" -Session context generation (default + record modes). - -Provides: - get_context_json - JSON output for default mode - get_context_text - Text output for default mode - get_context_record_json - JSON for record mode - get_context_text_record - Text for record mode - output_json - Print JSON - output_text - Print text - get_update_hint - Once-per-session "update available" line -""" - -from __future__ import annotations - -import json -import os -import re -import subprocess -import sys -from pathlib import Path - -from .active_task import resolve_context_key -from .config import get_git_packages -from .git import run_git -from .packages_context import get_packages_section -from .tasks import iter_active_tasks, load_task, get_all_statuses, children_progress -from .paths import ( - DIR_SCRIPTS, - DIR_SPEC, - DIR_TASKS, - DIR_WORKFLOW, - DIR_WORKSPACE, - count_lines, - get_active_journal_file, - get_current_task, - get_current_task_source, - get_developer, - get_repo_root, - get_tasks_dir, -) - - -# ============================================================================= -# Helpers -# ============================================================================= - -_PACKAGE_NAME = "@mindfoldhq/trellis" -_UPDATE_CHECK_TIMEOUT_SECONDS = 1.0 -_VERSION_RE = re.compile( - r"^\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?\s*$" -) -_VERSION_TOKEN_RE = re.compile(r"\b\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?\b") -_POLYREPO_IGNORED_DIRS = { - "node_modules", - "target", - "dist", - "build", - "out", - "bin", - "obj", - "vendor", - "coverage", - "tmp", - "__pycache__", -} -_POLYREPO_SCAN_MAX_DEPTH = 2 -_POLYREPO_SCAN_MAX_REPOS = 8 -_GIT_PROBE_TIMEOUT_SECONDS = 2.0 - - -def _is_git_worktree(path: Path) -> bool: - """Return True when path is inside a Git worktree.""" - rc, out, _ = run_git( - ["rev-parse", "--is-inside-work-tree"], - cwd=path, - timeout=_GIT_PROBE_TIMEOUT_SECONDS, - ) - return rc == 0 and out.strip().lower() == "true" - - -def _parse_recent_commits(log_output: str) -> list[dict]: - """Parse `git log --oneline` output into structured commit entries.""" - commits = [] - for line in log_output.splitlines(): - if not line.strip(): - continue - parts = line.split(" ", 1) - if len(parts) >= 2: - commits.append({"hash": parts[0], "message": parts[1]}) - elif len(parts) == 1: - commits.append({"hash": parts[0], "message": ""}) - return commits - - -def _collect_git_repo_info(name: str, rel_path: str, repo_dir: Path) -> dict | None: - """Collect Git status for one known repository directory.""" - if not (repo_dir / ".git").exists(): - return None - - status_rc, status_out, _ = run_git( - ["status", "--porcelain"], - cwd=repo_dir, - timeout=_GIT_PROBE_TIMEOUT_SECONDS, - ) - if status_rc != 0: - return None - changes = len([line for line in status_out.splitlines() if line.strip()]) - - _, branch_out, _ = run_git( - ["branch", "--show-current"], - cwd=repo_dir, - timeout=_GIT_PROBE_TIMEOUT_SECONDS, - ) - branch = branch_out.strip() or "unknown" - - _, log_out, _ = run_git( - ["log", "--oneline", "-5"], - cwd=repo_dir, - timeout=_GIT_PROBE_TIMEOUT_SECONDS, - ) - - return { - "name": name, - "path": rel_path, - "branch": branch, - "isClean": changes == 0, - "uncommittedChanges": changes, - "recentCommits": _parse_recent_commits(log_out), - } - - -def _collect_root_git_info(repo_root: Path) -> dict: - """Collect root Git info without pretending a non-Git root is clean.""" - if not _is_git_worktree(repo_root): - return { - "isRepo": False, - "branch": "", - "isClean": False, - "uncommittedChanges": 0, - "recentCommits": [], - } - - _, branch_out, _ = run_git( - ["branch", "--show-current"], - cwd=repo_root, - timeout=_GIT_PROBE_TIMEOUT_SECONDS, - ) - branch = branch_out.strip() or "unknown" - - status_rc, status_out, _ = run_git( - ["status", "--porcelain"], - cwd=repo_root, - timeout=_GIT_PROBE_TIMEOUT_SECONDS, - ) - status_lines = [line for line in status_out.splitlines() if line.strip()] - - _, short_out, _ = run_git( - ["status", "--short"], - cwd=repo_root, - timeout=_GIT_PROBE_TIMEOUT_SECONDS, - ) - - _, log_out, _ = run_git( - ["log", "--oneline", "-5"], - cwd=repo_root, - timeout=_GIT_PROBE_TIMEOUT_SECONDS, - ) - - return { - "isRepo": True, - "branch": branch, - "isClean": status_rc == 0 and len(status_lines) == 0, - "uncommittedChanges": len(status_lines), - "statusShort": short_out.splitlines(), - "recentCommits": _parse_recent_commits(log_out), - } - - -def _discover_child_git_repos(repo_root: Path) -> list[tuple[str, str]]: - """Discover child Git repositories using the init-time polyrepo heuristic.""" - found: list[str] = [] - overflow = False - - def is_candidate_dir(path: Path) -> bool: - name = path.name - return not name.startswith(".") and name not in _POLYREPO_IGNORED_DIRS - - def scan(rel_dir: Path, depth: int) -> None: - nonlocal overflow - if overflow: - return - if depth >= _POLYREPO_SCAN_MAX_DEPTH: - return - abs_dir = repo_root / rel_dir - try: - children = sorted(abs_dir.iterdir(), key=lambda p: p.name) - except OSError: - return - - for child in children: - if not child.is_dir() or not is_candidate_dir(child): - continue - - child_rel = ( - rel_dir / child.name if rel_dir != Path(".") else Path(child.name) - ) - if (child / ".git").exists(): - if len(found) >= _POLYREPO_SCAN_MAX_REPOS: - overflow = True - return - found.append(child_rel.as_posix()) - continue - scan(child_rel, depth + 1) - - scan(Path("."), 0) - if overflow: - print( - "warning: found more than " - f"{_POLYREPO_SCAN_MAX_REPOS} child Git repositories; " - "skipping automatic Git status collection. Configure explicit " - "packages entries with path and git: true in .trellis/config.yaml.", - file=sys.stderr, - ) - return [] - if len(found) < 2: - return [] - return [(path.replace("/", "_"), path) for path in sorted(found)] - - -def _collect_package_git_info( - repo_root: Path, - discover_unconfigured: bool = False, -) -> list[dict]: - """Collect Git status for independent package repositories. - - Packages marked with ``git: true`` in config.yaml are authoritative. - When the Trellis root is not a Git repo and no configured package repos are - available, optionally fall back to the bounded polyrepo child scan. - - Returns: - List of dicts with keys: name, path, branch, isClean, - uncommittedChanges, recentCommits. - Empty list if no git-repo packages are configured. - """ - git_pkgs = get_git_packages(repo_root) - result = [] - for pkg_name, pkg_path in git_pkgs.items(): - pkg_dir = repo_root / pkg_path - info = _collect_git_repo_info(pkg_name, pkg_path, pkg_dir) - if info is not None: - result.append(info) - - if result or not discover_unconfigured: - return result - - discovered = [] - for pkg_name, pkg_path in _discover_child_git_repos(repo_root): - info = _collect_git_repo_info(pkg_name, pkg_path, repo_root / pkg_path) - if info is not None: - discovered.append(info) - return discovered - - -def _append_root_git_context(lines: list[str], root_git_info: dict) -> None: - """Append root Git status without misleading non-Git roots.""" - lines.append("## GIT STATUS") - if not root_git_info["isRepo"]: - lines.append("Root is not a Git repository.") - lines.append("Run Git commands from the package repository paths listed below.") - else: - lines.append(f"Branch: {root_git_info['branch']}") - if root_git_info["isClean"]: - lines.append("Working directory: Clean") - else: - lines.append( - f"Working directory: {root_git_info['uncommittedChanges']} " - "uncommitted change(s)" - ) - lines.append("") - lines.append("Changes:") - for line in root_git_info.get("statusShort", [])[:10]: - lines.append(line) - lines.append("") - - lines.append("## RECENT COMMITS") - if not root_git_info["isRepo"]: - lines.append( - "Root has no Git commit history because it is not a Git repository." - ) - elif root_git_info["recentCommits"]: - for commit in root_git_info["recentCommits"]: - lines.append(f"{commit['hash']} {commit['message']}") - else: - lines.append("(no commits)") - lines.append("") - - -def _append_package_git_context(lines: list[str], package_git_info: list[dict]) -> None: - """Append Git status and recent commits for package repositories.""" - for pkg in package_git_info: - lines.append(f"## GIT STATUS ({pkg['name']}: {pkg['path']})") - lines.append(f"Branch: {pkg['branch']}") - if pkg["isClean"]: - lines.append("Working directory: Clean") - else: - lines.append( - f"Working directory: {pkg['uncommittedChanges']} uncommitted change(s)" - ) - lines.append("") - lines.append(f"## RECENT COMMITS ({pkg['name']}: {pkg['path']})") - if pkg["recentCommits"]: - for commit in pkg["recentCommits"]: - lines.append(f"{commit['hash']} {commit['message']}") - else: - lines.append("(no commits)") - lines.append("") - - -def _read_project_version(repo_root: Path) -> str | None: - try: - version = (repo_root / DIR_WORKFLOW / ".version").read_text( - encoding="utf-8" - ).strip() - except OSError: - return None - return version or None - - -def _fetch_trellis_version_output() -> str | None: - try: - result = subprocess.run( - ["trellis", "--version"], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=_UPDATE_CHECK_TIMEOUT_SECONDS, - ) - except (OSError, subprocess.SubprocessError, TimeoutError): - return None - - if result.returncode != 0: - return None - output = f"{result.stdout}\n{result.stderr}".strip() - return output or None - - -def _extract_available_update_version(output: str) -> str | None: - update_match = re.search( - r"Trellis update available:\s*" - r"(?P<current>\S+)\s*(?:→|->)\s*(?P<latest>\S+)", - output, - ) - if update_match: - return update_match.group("latest").strip() - candidates = _VERSION_TOKEN_RE.findall(output) - return candidates[-1] if candidates else None - - -def _resolve_available_update_version() -> str | None: - output = _fetch_trellis_version_output() - if not output: - return None - return _extract_available_update_version(output) - - -def _parse_version(version: str) -> tuple[tuple[int, int, int], tuple[str, ...] | None] | None: - match = _VERSION_RE.match(version) - if not match: - return None - major, minor, patch, prerelease = match.groups() - numbers = (int(major), int(minor or "0"), int(patch or "0")) - prerelease_parts = tuple(prerelease.split(".")) if prerelease else None - return numbers, prerelease_parts - - -def _compare_prerelease( - left: tuple[str, ...] | None, - right: tuple[str, ...] | None, -) -> int: - if left is None and right is None: - return 0 - if left is None: - return 1 - if right is None: - return -1 - - for left_part, right_part in zip(left, right): - if left_part == right_part: - continue - left_numeric = left_part.isdigit() - right_numeric = right_part.isdigit() - if left_numeric and right_numeric: - left_int = int(left_part) - right_int = int(right_part) - return (left_int > right_int) - (left_int < right_int) - if left_numeric: - return -1 - if right_numeric: - return 1 - return (left_part > right_part) - (left_part < right_part) - - return (len(left) > len(right)) - (len(left) < len(right)) - - -def _compare_versions(left: str, right: str) -> int | None: - parsed_left = _parse_version(left) - parsed_right = _parse_version(right) - if parsed_left is None or parsed_right is None: - return None - - left_numbers, left_prerelease = parsed_left - right_numbers, right_prerelease = parsed_right - if left_numbers != right_numbers: - return (left_numbers > right_numbers) - (left_numbers < right_numbers) - return _compare_prerelease(left_prerelease, right_prerelease) - - -def _update_marker_path(repo_root: Path, 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()}" - safe_key = re.sub(r"[^A-Za-z0-9._-]+", "_", context_key).strip("._-") - if not safe_key: - safe_key = "session" - return ( - repo_root - / DIR_WORKFLOW - / ".runtime" - / f"update-check-{safe_key[:160]}.marker" - ) - - -def _mark_update_check_attempted( - repo_root: Path, - context_key: str | None = None, -) -> bool: - marker_path = _update_marker_path(repo_root, context_key) - if marker_path.exists(): - return False - try: - marker_path.parent.mkdir(parents=True, exist_ok=True) - marker_path.write_text("checked\n", encoding="utf-8") - except OSError: - pass - return True - - -def get_update_hint(repo_root: Path, 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 - - current_version = _read_project_version(repo_root) - if not current_version: - return None - - latest_version = _resolve_available_update_version() - if not latest_version: - return None - - _mark_update_check_attempted(repo_root, context_key) - comparison = _compare_versions(current_version, latest_version) - if comparison is None or comparison >= 0: - return None - - return ( - f"Trellis update available: {current_version} -> {latest_version}, " - "run trellis update" - ) - - -# ============================================================================= -# JSON Output -# ============================================================================= - -def get_context_json(repo_root: Path | None = None) -> dict: - """Get context as a dictionary. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Context dictionary. - """ - if repo_root is None: - repo_root = get_repo_root() - - developer = get_developer(repo_root) - tasks_dir = get_tasks_dir(repo_root) - journal_file = get_active_journal_file(repo_root) - - journal_lines = 0 - journal_relative = "" - if journal_file and developer: - journal_lines = count_lines(journal_file) - journal_relative = ( - f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}" - ) - - root_git_info = _collect_root_git_info(repo_root) - - # Tasks - tasks = [ - { - "dir": t.dir_name, - "name": t.name, - "status": t.status, - "children": list(t.children), - "parent": t.parent, - } - for t in iter_active_tasks(tasks_dir) - ] - - # Package git repos (independent sub-repositories) - pkg_git_info = _collect_package_git_info( - repo_root, - discover_unconfigured=not root_git_info["isRepo"], - ) - - result = { - "developer": developer or "", - "git": { - "isRepo": root_git_info["isRepo"], - "branch": root_git_info["branch"], - "isClean": root_git_info["isClean"], - "uncommittedChanges": root_git_info["uncommittedChanges"], - "recentCommits": root_git_info["recentCommits"], - }, - "tasks": { - "active": tasks, - "directory": f"{DIR_WORKFLOW}/{DIR_TASKS}", - }, - "journal": { - "file": journal_relative, - "lines": journal_lines, - "nearLimit": journal_lines > 1800, - }, - } - - if pkg_git_info: - result["packageGit"] = pkg_git_info - - return result - - -def output_json(repo_root: Path | None = None) -> None: - """Output context in JSON format. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - """ - context = get_context_json(repo_root) - print(json.dumps(context, indent=2, ensure_ascii=False)) - - -# ============================================================================= -# Text Output -# ============================================================================= - -def get_context_text(repo_root: Path | None = None) -> str: - """Get context as formatted text. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Formatted text output. - """ - if repo_root is None: - repo_root = get_repo_root() - - lines = [] - lines.append("========================================") - lines.append("SESSION CONTEXT") - lines.append("========================================") - lines.append("") - - developer = get_developer(repo_root) - - # Developer section - lines.append("## DEVELOPER") - if not developer: - lines.append( - f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>" - ) - return "\n".join(lines) - - lines.append(f"Name: {developer}") - lines.append("") - - root_git_info = _collect_root_git_info(repo_root) - _append_root_git_context(lines, root_git_info) - - # Package git repos — independent sub-repositories - _append_package_git_context( - lines, - _collect_package_git_info( - repo_root, - discover_unconfigured=not root_git_info["isRepo"], - ), - ) - - # Current task - lines.append("## CURRENT TASK") - current_task = get_current_task(repo_root) - if current_task: - current_task_dir = repo_root / current_task - source_type, context_key, _ = get_current_task_source(repo_root) - lines.append(f"Path: {current_task}") - lines.append( - f"Source: {source_type}" + (f":{context_key}" if context_key else "") - ) - - ct = load_task(current_task_dir) - if ct: - lines.append(f"Name: {ct.name}") - lines.append(f"Status: {ct.status}") - lines.append(f"Created: {ct.raw.get('createdAt', 'unknown')}") - if ct.description: - lines.append(f"Description: {ct.description}") - - # Check for prd.md - prd_file = current_task_dir / "prd.md" - if prd_file.is_file(): - lines.append("") - lines.append("[!] This task has prd.md - read it for task details") - else: - lines.append("(none)") - lines.append("") - - # Active tasks - lines.append("## ACTIVE TASKS") - tasks_dir = get_tasks_dir(repo_root) - task_count = 0 - - # Collect all task data for hierarchy display - all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)} - all_statuses = {name: t.status for name, t in all_tasks.items()} - - def _print_task_tree(name: str, indent: int = 0) -> None: - nonlocal task_count - t = all_tasks[name] - progress = children_progress(t.children, all_statuses) - prefix = " " * indent - lines.append(f"{prefix}- {name}/ ({t.status}){progress} @{t.assignee or '-'}") - task_count += 1 - for child in t.children: - if child in all_tasks: - _print_task_tree(child, indent + 1) - - for dir_name in sorted(all_tasks.keys()): - if not all_tasks[dir_name].parent: - _print_task_tree(dir_name) - - if task_count == 0: - lines.append("(no active tasks)") - lines.append(f"Total: {task_count} active task(s)") - lines.append("") - - # My tasks - lines.append("## MY TASKS (Assigned to me)") - my_task_count = 0 - - for t in all_tasks.values(): - if t.assignee == developer and t.status != "done": - progress = children_progress(t.children, all_statuses) - lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress}") - my_task_count += 1 - - if my_task_count == 0: - lines.append("(no tasks assigned to you)") - lines.append("") - - # Journal file - lines.append("## JOURNAL FILE") - journal_file = get_active_journal_file(repo_root) - if journal_file: - journal_lines = count_lines(journal_file) - relative = f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}" - lines.append(f"Active file: {relative}") - lines.append(f"Line count: {journal_lines} / 2000") - if journal_lines > 1800: - lines.append("[!] WARNING: Approaching 2000 line limit!") - else: - lines.append("No journal file found") - lines.append("") - - # Packages - packages_text = get_packages_section(repo_root) - if packages_text: - lines.append(packages_text) - lines.append("") - - # Paths - lines.append("## PATHS") - lines.append(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/") - lines.append(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/") - lines.append(f"Spec: {DIR_WORKFLOW}/{DIR_SPEC}/") - lines.append("") - - lines.append("========================================") - - return "\n".join(lines) - - -# ============================================================================= -# Record Mode -# ============================================================================= - -def get_context_record_json(repo_root: Path | None = None) -> dict: - """Get record-mode context as a dictionary. - - Focused on: my active tasks, git status, current task. - """ - if repo_root is None: - repo_root = get_repo_root() - - developer = get_developer(repo_root) - tasks_dir = get_tasks_dir(repo_root) - - root_git_info = _collect_root_git_info(repo_root) - - # My tasks (single pass — collect statuses and filter by assignee) - all_tasks_list = list(iter_active_tasks(tasks_dir)) - all_statuses = {t.dir_name: t.status for t in all_tasks_list} - - my_tasks = [] - for t in all_tasks_list: - if t.assignee == developer: - done = sum( - 1 for c in t.children - if all_statuses.get(c) in ("completed", "done") - ) - my_tasks.append({ - "dir": t.dir_name, - "title": t.title, - "status": t.status, - "priority": t.priority, - "children": list(t.children), - "childrenDone": done, - "parent": t.parent, - "meta": t.meta, - }) - - # Current task - current_task_info = None - current_task = get_current_task(repo_root) - if current_task: - source_type, context_key, _ = get_current_task_source(repo_root) - ct = load_task(repo_root / current_task) - if ct: - current_task_info = { - "path": current_task, - "name": ct.name, - "status": ct.status, - "source": source_type, - "contextKey": context_key, - } - - # Package git repos - pkg_git_info = _collect_package_git_info( - repo_root, - discover_unconfigured=not root_git_info["isRepo"], - ) - - result = { - "developer": developer or "", - "git": { - "isRepo": root_git_info["isRepo"], - "branch": root_git_info["branch"], - "isClean": root_git_info["isClean"], - "uncommittedChanges": root_git_info["uncommittedChanges"], - "recentCommits": root_git_info["recentCommits"], - }, - "myTasks": my_tasks, - "currentTask": current_task_info, - } - - if pkg_git_info: - result["packageGit"] = pkg_git_info - - return result - - -def get_context_text_record(repo_root: Path | None = None) -> str: - """Get context as formatted text for record-session mode. - - Focused output: MY ACTIVE TASKS first (with [!!!] emphasis), - then GIT STATUS, RECENT COMMITS, CURRENT TASK. - """ - if repo_root is None: - repo_root = get_repo_root() - - lines: list[str] = [] - lines.append("========================================") - lines.append("SESSION CONTEXT (RECORD MODE)") - lines.append("========================================") - lines.append("") - - developer = get_developer(repo_root) - if not developer: - lines.append( - f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>" - ) - return "\n".join(lines) - - # MY ACTIVE TASKS — first and prominent - lines.append(f"## [!!!] MY ACTIVE TASKS (Assigned to {developer})") - lines.append("[!] Review whether any should be archived before recording this session.") - lines.append("") - - tasks_dir = get_tasks_dir(repo_root) - my_task_count = 0 - - # Single pass — collect all tasks and filter by assignee - all_statuses = get_all_statuses(tasks_dir) - - for t in iter_active_tasks(tasks_dir): - if t.assignee == developer: - progress = children_progress(t.children, all_statuses) - lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress} — {t.dir_name}") - my_task_count += 1 - - if my_task_count == 0: - lines.append("(no active tasks assigned to you)") - lines.append("") - - root_git_info = _collect_root_git_info(repo_root) - _append_root_git_context(lines, root_git_info) - - # Package git repos — independent sub-repositories - _append_package_git_context( - lines, - _collect_package_git_info( - repo_root, - discover_unconfigured=not root_git_info["isRepo"], - ), - ) - - # CURRENT TASK - lines.append("## CURRENT TASK") - current_task = get_current_task(repo_root) - if current_task: - source_type, context_key, _ = get_current_task_source(repo_root) - lines.append(f"Path: {current_task}") - lines.append( - f"Source: {source_type}" + (f":{context_key}" if context_key else "") - ) - ct = load_task(repo_root / current_task) - if ct: - lines.append(f"Name: {ct.name}") - lines.append(f"Status: {ct.status}") - else: - lines.append("(none)") - lines.append("") - - lines.append("========================================") - - return "\n".join(lines) - - -def output_text(repo_root: Path | None = None) -> None: - """Output context in text format. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - """ - if repo_root is None: - repo_root = get_repo_root() - update_hint = get_update_hint(repo_root) - if update_hint: - print(update_hint) - print("") - print(get_context_text(repo_root)) diff --git a/.trellis/scripts/common/task_context.py b/.trellis/scripts/common/task_context.py deleted file mode 100755 index 5d10df7..0000000 --- a/.trellis/scripts/common/task_context.py +++ /dev/null @@ -1,372 +0,0 @@ -#!/usr/bin/env python3 -""" -Task JSONL context management. - -Provides: - cmd_add_context - Add entry to JSONL context file - cmd_validate - Validate JSONL context files - cmd_list_context - List JSONL context entries - -Note: - ``cmd_init_context`` was removed in v0.5.0-beta.12. JSONL context files - are now seeded at ``task.py create`` time with a self-describing - ``_example`` line; the AI agent curates real entries during planning when - the task needs sub-agent/spec context. See ``.trellis/workflow.md`` for the - current planning artifact contract. -""" - -from __future__ import annotations - -import argparse -import json -from pathlib import Path - -from .config import get_context_injection_limits -from .git import branch_exists_locally -from .io import read_json -from .log import Colors, colored -from .paths import 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 -# one of these extensions outside .trellis/spec/, docs/docs-site, or the -# task's own directory get a hygiene warning in `task.py validate` — the -# reader is a sub-agent, not a human, so code paths belong in the diff the -# agent reads itself, not in implement.jsonl / check.jsonl. -_CODE_FILE_EXTENSIONS = { - ".ts", - ".tsx", - ".js", - ".jsx", - ".mjs", - ".cjs", - ".py", - ".go", - ".rs", - ".java", - ".rb", - ".c", - ".cc", - ".cpp", - ".h", -} - - -# ============================================================================= -# Command: add-context -# ============================================================================= - -def cmd_add_context(args: argparse.Namespace) -> int: - """Add entry to JSONL context file.""" - repo_root = get_repo_root() - target_dir = resolve_task_dir(args.dir, repo_root) - - jsonl_name = args.file - path = args.path - reason = args.reason or "Added manually" - - if not target_dir or not target_dir.is_dir(): - print(colored(f"Error: Directory not found: {target_dir}", Colors.RED)) - return 1 - - # Support shorthand - if not jsonl_name.endswith(".jsonl"): - jsonl_name = f"{jsonl_name}.jsonl" - - jsonl_file = target_dir / jsonl_name - full_path = repo_root / path - - entry_type = "file" - if full_path.is_dir(): - entry_type = "directory" - if not path.endswith("/"): - path = f"{path}/" - elif not full_path.is_file(): - print(colored(f"Error: Path not found: {path}", Colors.RED)) - return 1 - - # Check if already exists - if jsonl_file.is_file(): - content = jsonl_file.read_text(encoding="utf-8") - if f'"{path}"' in content: - print(colored(f"Warning: Entry already exists for {path}", Colors.YELLOW)) - return 0 - - # Add entry - entry: dict - if entry_type == "directory": - entry = {"file": path, "type": "directory", "reason": reason} - else: - entry = {"file": path, "reason": reason} - - with jsonl_file.open("a", encoding="utf-8") as f: - f.write(json.dumps(entry, ensure_ascii=False) + "\n") - - print(colored(f"Added {entry_type}: {path}", Colors.GREEN)) - return 0 - - -# ============================================================================= -# Command: validate -# ============================================================================= - -def cmd_validate(args: argparse.Namespace) -> int: - """Validate JSONL context files.""" - repo_root = get_repo_root() - target_dir = resolve_task_dir(args.dir, repo_root) - - if not target_dir or not target_dir.is_dir(): - print(colored("Error: task directory required", Colors.RED)) - return 1 - - print(colored("=== Validating Context Files ===", Colors.BLUE)) - print(f"Target dir: {target_dir}") - print() - - # Warn (don't fail validation) when the recorded branch is stale — it - # was likely already merged and deleted (#399 item 2). - task_json_path = target_dir / FILE_TASK_JSON - if task_json_path.is_file(): - task_data = read_json(task_json_path) - stored_branch = task_data.get("branch") if task_data else None - if stored_branch and not branch_exists_locally(stored_branch, repo_root): - print( - colored( - f"Warning: recorded branch '{stored_branch}' no longer exists locally " - "(likely merged and deleted).", - Colors.YELLOW, - ) - ) - print() - - total_errors = 0 - for jsonl_name in ["implement.jsonl", "check.jsonl"]: - jsonl_file = target_dir / jsonl_name - errors = _validate_jsonl(jsonl_file, repo_root, target_dir) - total_errors += errors - - print() - if total_errors == 0: - print(colored("✓ All validations passed", Colors.GREEN)) - return 0 - else: - print(colored(f"✗ Validation failed ({total_errors} errors)", Colors.RED)) - return 1 - - -def _is_exempt_from_code_file_warning(file_path: str, task_rel: str) -> bool: - """Whether a jsonl entry path is exempt from the code-file hygiene warning. - - Exempt: spec docs (``.trellis/spec/``), documentation (``docs``, - ``docs-site``), and the task's own directory (execution plans, generated - artifacts, etc. legitimately live there). - """ - posix_path = file_path.replace("\\", "/").lstrip("/") - exempt_prefixes = (".trellis/spec/", "docs/", "docs-site/") - if posix_path.startswith(exempt_prefixes): - return True - if task_rel and (posix_path == task_rel or posix_path.startswith(f"{task_rel}/")): - return True - return False - - -def _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 - 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. - - Beyond hard errors (missing file/dir, invalid JSON), this also prints - non-blocking hygiene warnings (never counted in ``errors``, never change - the exit code): entries that look like code files rather than - spec/research docs, and entries whose file size exceeds the configured - sub-agent context injection cap (``context_injection.max_file_bytes``). - """ - file_name = jsonl_file.name - errors = 0 - - if not jsonl_file.is_file(): - print(f" {colored(f'{file_name}: not found (skipped)', Colors.YELLOW)}") - return 0 - - task_rel = "" - if task_dir is not None: - try: - task_rel = task_dir.resolve().relative_to(repo_root.resolve()).as_posix() - except ValueError: - task_rel = "" - - max_file_bytes = get_context_injection_limits(repo_root).get("max_file_bytes", 0) - - line_num = 0 - real_entries = 0 - for line in jsonl_file.read_text(encoding="utf-8").splitlines(): - line_num += 1 - if not line.strip(): - continue - - try: - data = json.loads(line) - except json.JSONDecodeError: - print(f" {colored(f'{file_name}:{line_num}: Invalid JSON', Colors.RED)}") - errors += 1 - continue - - file_path = data.get("file") - entry_type = data.get("type", "file") - - if not file_path: - # Seed / comment row — skip silently - continue - - real_entries += 1 - full_path = _resolve_context_entry_path(file_path, repo_root, task_dir) - if entry_type == "directory": - 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 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 - - extension = Path(file_path).suffix.lower() - if extension in _CODE_FILE_EXTENSIONS and not _is_exempt_from_code_file_warning( - file_path, task_rel - ): - warning_message = ( - f"{file_name}:{line_num}: Warning: {file_path} looks like a code file — " - "implement/check.jsonl should reference spec/research docs; " - "agents read code themselves" - ) - print(f" {colored(warning_message, Colors.YELLOW)}") - - if max_file_bytes: - size = full_path.stat().st_size - if size > max_file_bytes: - warning_message = ( - f"{file_name}:{line_num}: Warning: {file_path} is {size} bytes, " - f"exceeds context_injection.max_file_bytes ({max_file_bytes}); " - "injection will truncate it" - ) - print(f" {colored(warning_message, Colors.YELLOW)}") - - if errors == 0: - print(f" {colored(f'{file_name}: ✓ ({real_entries} entries)', Colors.GREEN)}") - else: - print(f" {colored(f'{file_name}: ✗ ({errors} errors)', Colors.RED)}") - - return errors - - -# ============================================================================= -# Command: list-context -# ============================================================================= - -def cmd_list_context(args: argparse.Namespace) -> int: - """List JSONL context entries.""" - repo_root = get_repo_root() - target_dir = resolve_task_dir(args.dir, repo_root) - - if not target_dir or not target_dir.is_dir(): - print(colored("Error: task directory required", Colors.RED)) - return 1 - - print(colored("=== Context Files ===", Colors.BLUE)) - print() - - for jsonl_name in ["implement.jsonl", "check.jsonl"]: - jsonl_file = target_dir / jsonl_name - if not jsonl_file.is_file(): - continue - - print(colored(f"[{jsonl_name}]", Colors.CYAN)) - - count = 0 - seed_only = True - for line in jsonl_file.read_text(encoding="utf-8").splitlines(): - if not line.strip(): - continue - - try: - data = json.loads(line) - except json.JSONDecodeError: - continue - - file_path = data.get("file") - if not file_path: - # Seed / comment row — don't count as a real entry - continue - seed_only = False - - count += 1 - entry_type = data.get("type", "file") - reason = data.get("reason", "-") - - if entry_type == "directory": - print(f" {colored(f'{count}.', Colors.GREEN)} [DIR] {file_path}") - else: - print(f" {colored(f'{count}.', Colors.GREEN)} {file_path}") - print(f" {colored('→', Colors.YELLOW)} {reason}") - - if seed_only: - print(f" {colored('(no curated entries yet — only seed row)', Colors.YELLOW)}") - - print() - - return 0 diff --git a/.trellis/scripts/common/task_queue.py b/.trellis/scripts/common/task_queue.py deleted file mode 100755 index f7485e2..0000000 --- a/.trellis/scripts/common/task_queue.py +++ /dev/null @@ -1,188 +0,0 @@ -#!/usr/bin/env python3 -""" -Task queue utility functions. - -Provides: - list_tasks_by_status - List tasks by status - list_pending_tasks - List tasks with pending status - list_tasks_by_assignee - List tasks by assignee - list_my_tasks - List tasks assigned to current developer - get_task_stats - Get P0/P1/P2/P3 counts -""" - -from __future__ import annotations - -from pathlib import Path - -from .paths import ( - get_repo_root, - get_developer, - get_tasks_dir, -) -from .tasks import iter_active_tasks - - -# ============================================================================= -# Internal helper -# ============================================================================= - -def _task_to_dict(t) -> dict: - """Convert TaskInfo to the dict format callers expect.""" - return { - "priority": t.priority, - "id": t.raw.get("id", ""), - "title": t.title, - "status": t.status, - "assignee": t.assignee or "-", - "dir": t.dir_name, - "children": list(t.children), - "parent": t.parent, - } - - -# ============================================================================= -# Public Functions -# ============================================================================= - -def list_tasks_by_status( - filter_status: str | None = None, - repo_root: Path | None = None -) -> list[dict]: - """List tasks by status. - - Args: - filter_status: Optional status filter. - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - List of task info dicts with keys: priority, id, title, status, assignee. - """ - if repo_root is None: - repo_root = get_repo_root() - - tasks_dir = get_tasks_dir(repo_root) - results = [] - - for t in iter_active_tasks(tasks_dir): - if filter_status and t.status != filter_status: - continue - results.append(_task_to_dict(t)) - - return results - - -def list_pending_tasks(repo_root: Path | None = None) -> list[dict]: - """List pending tasks. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - List of task info dicts. - """ - return list_tasks_by_status("planning", repo_root) - - -def list_tasks_by_assignee( - assignee: str, - filter_status: str | None = None, - repo_root: Path | None = None -) -> list[dict]: - """List tasks assigned to a specific developer. - - Args: - assignee: Developer name. - filter_status: Optional status filter. - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - List of task info dicts. - """ - if repo_root is None: - repo_root = get_repo_root() - - tasks_dir = get_tasks_dir(repo_root) - results = [] - - for t in iter_active_tasks(tasks_dir): - if (t.assignee or "-") != assignee: - continue - if filter_status and t.status != filter_status: - continue - results.append(_task_to_dict(t)) - - return results - - -def list_my_tasks( - filter_status: str | None = None, - repo_root: Path | None = None -) -> list[dict]: - """List tasks assigned to current developer. - - Args: - filter_status: Optional status filter. - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - List of task info dicts. - - Raises: - ValueError: If developer not set. - """ - if repo_root is None: - repo_root = get_repo_root() - - developer = get_developer(repo_root) - if not developer: - raise ValueError("Developer not set") - - return list_tasks_by_assignee(developer, filter_status, repo_root) - - -def get_task_stats(repo_root: Path | None = None) -> dict[str, int]: - """Get task statistics. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Dict with keys: P0, P1, P2, P3, Total. - """ - if repo_root is None: - repo_root = get_repo_root() - - tasks_dir = get_tasks_dir(repo_root) - stats = {"P0": 0, "P1": 0, "P2": 0, "P3": 0, "Total": 0} - - for t in iter_active_tasks(tasks_dir): - if t.priority in stats: - stats[t.priority] += 1 - stats["Total"] += 1 - - return stats - - -def format_task_stats(stats: dict[str, int]) -> str: - """Format task stats as string. - - Args: - stats: Stats dict from get_task_stats. - - Returns: - Formatted string like "P0:0 P1:1 P2:2 P3:0 Total:3". - """ - return f"P0:{stats['P0']} P1:{stats['P1']} P2:{stats['P2']} P3:{stats['P3']} Total:{stats['Total']}" - - -# ============================================================================= -# Main Entry (for testing) -# ============================================================================= - -if __name__ == "__main__": - stats = get_task_stats() - print(format_task_stats(stats)) - print() - print("Pending tasks:") - for task in list_pending_tasks(): - print(f" {task['priority']}|{task['id']}|{task['title']}|{task['status']}|{task['assignee']}") diff --git a/.trellis/scripts/common/task_store.py b/.trellis/scripts/common/task_store.py deleted file mode 100755 index 0cc8cbe..0000000 --- a/.trellis/scripts/common/task_store.py +++ /dev/null @@ -1,985 +0,0 @@ -#!/usr/bin/env python3 -""" -Task CRUD operations. - -Provides: - ensure_tasks_dir - Ensure tasks directory exists - cmd_create - Create a new task - cmd_archive - Archive completed task - cmd_set_branch - Set git branch for task - cmd_set_base_branch - Set PR target branch - cmd_set_scope - Set scope for PR title - cmd_set_meta - Set/overwrite a task metadata key - cmd_add_subtask - Link child task to parent - cmd_remove_subtask - Unlink child task from parent -""" - -from __future__ import annotations - -import argparse -import json -import re -import sys -from datetime import datetime -from pathlib import Path - -from .config import ( - get_codex_dispatch_mode, - get_packages, - get_session_auto_commit, - is_monorepo, - resolve_package, - validate_package, -) -from .git import branch_exists_locally, resolve_default_branch, run_git -from .io import read_json, write_json -from .log import Colors, colored -from .paths import ( - DIR_ARCHIVE, - DIR_TASKS, - DIR_WORKFLOW, - FILE_TASK_JSON, - generate_task_date_prefix, - get_developer, - get_repo_root, - get_tasks_dir, -) -from .safe_commit import ( - print_gitignore_warning, - safe_archive_paths_to_add, - safe_git_add, -) -from .task_utils import ( - archive_task_complete, - find_task_by_name, - is_within_tasks_dir, - resolve_task_dir, - run_task_hooks, -) - - -# ============================================================================= -# Helper Functions -# ============================================================================= - -def _slugify(title: str) -> str: - """Convert title to slug (only works with ASCII).""" - result = title.lower() - result = re.sub(r"[^a-z0-9]", "-", result) - result = re.sub(r"-+", "-", result) - result = result.strip("-") - return result - - -def ensure_tasks_dir(repo_root: Path) -> Path: - """Ensure tasks directory exists.""" - tasks_dir = get_tasks_dir(repo_root) - archive_dir = tasks_dir / "archive" - - if not tasks_dir.exists(): - tasks_dir.mkdir(parents=True) - print(colored(f"Created tasks directory: {tasks_dir}", Colors.GREEN), file=sys.stderr) - - if not archive_dir.exists(): - archive_dir.mkdir(parents=True) - - return tasks_dir - - -def _find_archived_task_by_dir_name(tasks_dir: Path, dir_name: str) -> Path | None: - """Find an archived task directory with the exact active-task dir name.""" - archive_dir = tasks_dir / DIR_ARCHIVE - if not archive_dir.is_dir(): - return None - - for month_dir in sorted(archive_dir.iterdir()): - if not month_dir.is_dir(): - continue - candidate = month_dir / dir_name - if candidate.is_dir(): - return candidate - - return None - - -def _repo_relative_path(path: Path, repo_root: Path) -> str: - """Format a path relative to the repo root when possible.""" - try: - return path.relative_to(repo_root).as_posix() - except ValueError: - return str(path) - - -# ============================================================================= -# Sub-agent platform detection + JSONL seeding -# ============================================================================= - -# Config directories of platforms that consume implement.jsonl / check.jsonl. -# Keep in sync with src/types/ai-tools.ts AI_TOOLS entries — these are the -# platforms listed in workflow.md's "agent-capable" Skill Routing block. -# Codex is checked separately because explicit inline mode does not consume -# JSONL. Kilo / Antigravity / Devin are NOT in this list either: they load -# specs through skills instead of JSONL. -_SUBAGENT_CONFIG_DIRS: tuple[str, ...] = ( - ".claude", - ".cursor", - ".kiro", - ".gemini", - ".opencode", - ".qoder", - ".codebuddy", - ".factory", # Factory Droid - ".github/copilot", - ".pi", # Pi Agent - ".trae", # Trae IDE - ".omp", # Oh My Pi - ".zcode", # ZCode - ".grok", # Grok Build - ".kimi-code", # Kimi Code -) -_CODEX_CONFIG_DIR = ".codex" - -_SEED_EXAMPLE = ( - "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. " - "Put spec/research files only — no code paths. " - "Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. " - "Delete this line once real entries are added." -) - - -def _has_subagent_platform(repo_root: Path) -> bool: - """Return True if any sub-agent-capable platform is configured. - - Detected by probing well-known config directories at the repo root. Codex - counts by default through ``codex.dispatch_mode: auto`` (including the - legacy ``sub-agent`` alias); explicit inline mode loads context through - skills, not JSONL. - """ - for config_dir in _SUBAGENT_CONFIG_DIRS: - if (repo_root / config_dir).is_dir(): - return True - if (repo_root / _CODEX_CONFIG_DIR).is_dir(): - return get_codex_dispatch_mode(repo_root) == "auto" - return False - - -def _write_seed_jsonl(path: Path) -> None: - """Write a one-line seed JSONL file with a self-describing ``_example``. - - The seed row has no ``file`` field, so downstream consumers (hooks + - preludes) that iterate entries via ``item.get("file")`` naturally skip - it. The row exists purely as an in-file prompt for the AI curator. - """ - seed = {"_example": _SEED_EXAMPLE} - path.write_text(json.dumps(seed, ensure_ascii=False) + "\n", encoding="utf-8") - - -def _parse_meta_pairs(pairs: list[str] | None) -> dict[str, str] | None: - """Parse repeatable ``--meta key=value`` pairs into a dict. - - Returns ``None`` (after printing an error naming the bad value) on the - first malformed pair: missing ``=`` or an empty key. Values are stored - as-is (strings, no nesting, no type coercion). - """ - meta: dict[str, str] = {} - for pair in pairs or []: - key, sep, value = pair.partition("=") - if not sep or not key: - print( - colored(f"Error: malformed --meta value '{pair}' (expected key=value)", Colors.RED), - file=sys.stderr, - ) - return None - meta[key] = value - return meta - - -def _default_prd_content(title: str, description: str | None = None) -> str: - """Return the default PRD skeleton created with every task.""" - goal = (description or "").strip() or "TBD." - heading = title.strip() or "Untitled task" - return f"""# {heading} - -## Goal - -{goal} - -## Requirements - -- TBD - -## Acceptance Criteria - -- [ ] TBD - -## Notes - -- Keep `prd.md` focused on requirements, constraints, and acceptance criteria. -- Lightweight tasks can remain PRD-only. -- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`. -""" - - -# ============================================================================= -# Command: create -# ============================================================================= - -def cmd_create(args: argparse.Namespace) -> int: - """Create a new task.""" - repo_root = get_repo_root() - - if not args.title: - print(colored("Error: title is required", Colors.RED), file=sys.stderr) - return 1 - - # Validate --meta (CLI source: fail-fast, before any directory is created) - meta = _parse_meta_pairs(getattr(args, "meta", None)) - if meta is None: - return 1 - - # Validate --package (CLI source: fail-fast) - package: str | None = getattr(args, "package", None) - if not is_monorepo(repo_root): - # Single-repo: ignore --package, no package prefix - if package: - print(colored(f"Warning: --package ignored in single-repo project", Colors.YELLOW), file=sys.stderr) - package = None - elif package: - if not validate_package(package, repo_root): - packages = get_packages(repo_root) - available = ", ".join(sorted(packages.keys())) if packages else "(none)" - print(colored(f"Error: unknown package '{package}'. Available: {available}", Colors.RED), file=sys.stderr) - return 1 - else: - # Inferred: default_package → None (no task.json yet for create) - package = resolve_package(repo_root=repo_root) - - # Default assignee to current developer - assignee = args.assignee - if not assignee: - assignee = get_developer(repo_root) - if not assignee: - print(colored("Error: No developer set. Run init_developer.py first or use --assignee", Colors.RED), file=sys.stderr) - return 1 - - ensure_tasks_dir(repo_root) - - # Get current developer as creator - creator = get_developer(repo_root) or assignee - - # Generate slug if not provided - slug = args.slug or _slugify(args.title) - if not slug: - print(colored("Error: could not generate slug from title", Colors.RED), file=sys.stderr) - return 1 - - # Create task directory with MM-DD-slug format - tasks_dir = get_tasks_dir(repo_root) - date_prefix = generate_task_date_prefix() - - # Guard against date-prefixed --slug (e.g. a full task dir name pasted in), - # which would otherwise produce MM-DD-MM-DD-slug (issue #377). Only an - # explicit --slug is guarded; title-derived slugs are left untouched. - if args.slug: - m = re.match(r"^(\d{2})-(\d{2})-(.+)$", slug) - if m and 1 <= int(m.group(1)) <= 12 and 1 <= int(m.group(2)) <= 31: - slug_prefix = f"{m.group(1)}-{m.group(2)}" - if slug_prefix == date_prefix: - slug = m.group(3) - print( - colored( - f'warning: --slug should not include the MM-DD prefix; normalized to "{slug}"', - Colors.YELLOW, - ), - file=sys.stderr, - ) - else: - print( - colored( - f"Error: --slug starts with a date prefix ({slug_prefix}-), but task.py create always uses today's date ({date_prefix}).", - Colors.RED, - ), - file=sys.stderr, - ) - print(f"Pass only the slug body, e.g. --slug {m.group(3)}", file=sys.stderr) - return 1 - - dir_name = f"{date_prefix}-{slug}" - task_dir = tasks_dir / dir_name - task_json_path = task_dir / FILE_TASK_JSON - - archived_task_dir = _find_archived_task_by_dir_name(tasks_dir, dir_name) - if archived_task_dir: - print(colored(f"Error: Task already archived: {dir_name}", Colors.RED), file=sys.stderr) - print(f"Archived at: {_repo_relative_path(archived_task_dir, repo_root)}", file=sys.stderr) - print("Use a new slug if you intend to create a new task.", file=sys.stderr) - return 1 - - if task_dir.exists(): - print(colored(f"Warning: Task directory already exists: {dir_name}", Colors.YELLOW), file=sys.stderr) - else: - task_dir.mkdir(parents=True) - - today = datetime.now().strftime("%Y-%m-%d") - - # Record the PR target branch. Prefer the repo's actual default branch - # (origin/HEAD) so creating a task from a feature branch doesn't - # mis-stamp that feature branch as the PR target (#399 item 1). Falls - # back to the checked-out branch when the default can't be resolved - # (no remote configured, offline, etc.) — the pre-existing behavior. - # --base-branch lets the caller override both when neither is correct. - _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) - current_branch = branch_out.strip() or "main" - explicit_base_branch: str | None = getattr(args, "base_branch", None) - if explicit_base_branch: - base_branch = explicit_base_branch - else: - resolved_base_branch = resolve_default_branch(repo_root) - if resolved_base_branch: - base_branch = resolved_base_branch - else: - base_branch = current_branch - print( - colored( - f"warning: could not resolve the repository's default branch " - f"(no remote configured, offline, etc.); stamping base_branch as " - f"the checked-out branch '{base_branch}'. Pass --base-branch to override.", - Colors.YELLOW, - ), - file=sys.stderr, - ) - - description = (args.description or "").strip() - if not description.strip(): - print( - colored( - "warning: task description is empty; pass --description to improve search and later audits.", - Colors.YELLOW, - ), - file=sys.stderr, - ) - - task_data = { - "id": slug, - "name": slug, - "title": args.title, - "description": description, - "status": "planning", - "dev_type": None, - "scope": None, - "package": package, - "priority": args.priority, - "creator": creator, - "assignee": assignee, - "createdAt": today, - "completedAt": None, - "branch": None, - "base_branch": base_branch, - "worktree_path": None, - "commit": None, - "pr_url": None, - "subtasks": [], - "children": [], - "parent": None, - "relatedFiles": [], - "notes": "", - "meta": meta, - } - - write_json(task_json_path, task_data) - - prd_path = task_dir / "prd.md" - if not prd_path.exists(): - prd_path.write_text( - _default_prd_content(args.title, description), - encoding="utf-8", - ) - - # Seed implement.jsonl / check.jsonl for sub-agent-capable platforms. - # Agent curates real entries during planning when the task needs them. - # Agent-less platforms (Kilo / Antigravity / Devin) skip this — they - # load specs via the trellis-before-dev skill instead of JSONL. - seeded_jsonl = False - if _has_subagent_platform(repo_root): - for jsonl_name in ("implement.jsonl", "check.jsonl"): - jsonl_path = task_dir / jsonl_name - if not jsonl_path.exists(): - _write_seed_jsonl(jsonl_path) - seeded_jsonl = True - - # Handle --parent: establish bidirectional link - if args.parent: - parent_dir = resolve_task_dir(args.parent, repo_root) - if not parent_dir or not (parent_dir / FILE_TASK_JSON).is_file(): - print(colored(f"Warning: Parent task.json not found: {args.parent}", Colors.YELLOW), file=sys.stderr) - else: - parent_json_path = parent_dir / FILE_TASK_JSON - parent_data = read_json(parent_json_path) - if parent_data: - # Add child to parent's children list - parent_children = parent_data.get("children", []) - if dir_name not in parent_children: - parent_children.append(dir_name) - parent_data["children"] = parent_children - write_json(parent_json_path, parent_data) - - # Set parent in child's task.json - task_data["parent"] = parent_dir.name - write_json(task_json_path, task_data) - - print(colored(f"Linked as child of: {parent_dir.name}", Colors.GREEN), file=sys.stderr) - - # Auto-activate the new task so the per-turn breadcrumb fires planning - # state. Best-effort: gracefully degrade if no session identity (CLI run - # outside an AI session) — the task is still created, the user can run - # task.py start later. Pointer is session-scoped so this never affects - # other AI sessions. - if getattr(args, "no_start", False): - print( - colored( - "Skipped session activation (--no-start); run task.py start when ready.", - Colors.YELLOW, - ), - file=sys.stderr, - ) - else: - try: - from .active_task import resolve_context_key, set_active_task - except Exception as exc: - print( - colored(f"Warning: session activation unavailable (import failed: {exc})", Colors.YELLOW), - file=sys.stderr, - ) - else: - try: - context_key = resolve_context_key() - except Exception as exc: - print( - colored(f"Warning: session activation failed (context resolution: {exc})", Colors.YELLOW), - file=sys.stderr, - ) - else: - # No session identity is the normal CLI-outside-an-AI-session - # case (see comment above) — stay silent, not a failure. - if context_key: - try: - rel_dir = task_dir.relative_to(repo_root).as_posix() - except ValueError: - rel_dir = str(task_dir) - try: - active = set_active_task(rel_dir, repo_root) - except Exception as exc: - print( - colored(f"Warning: session activation failed (pointer persistence: {exc})", Colors.YELLOW), - file=sys.stderr, - ) - else: - if active: - print( - colored(f"Activated task for this session: {active.task_path}", Colors.GREEN), - file=sys.stderr, - ) - print(f"Source: {active.source}", file=sys.stderr) - else: - print( - colored("Warning: session activation failed (no pointer returned)", Colors.YELLOW), - file=sys.stderr, - ) - - print(colored(f"Created task: {dir_name}", Colors.GREEN), file=sys.stderr) - print("", file=sys.stderr) - print(colored("Next steps:", Colors.BLUE), file=sys.stderr) - print(" - Fill prd.md with requirements and acceptance criteria", file=sys.stderr) - print(" - Lightweight task: PRD-only is valid", file=sys.stderr) - print(" - Complex task: add design.md and implement.md before task.py start", file=sys.stderr) - if seeded_jsonl: - print( - " - Curate implement.jsonl / check.jsonl as spec/research manifests when sub-agents need context", - file=sys.stderr, - ) - print(" - Use /trellis:continue or phase context to decide the next step", file=sys.stderr) - print("", file=sys.stderr) - - # Output relative path for script chaining - print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}") - - run_task_hooks("after_create", task_json_path, repo_root) - return 0 - - -# ============================================================================= -# Command: archive -# ============================================================================= - -def cmd_archive(args: argparse.Namespace) -> int: - """Archive completed task.""" - repo_root = get_repo_root() - task_name = args.name - - if not task_name: - print(colored("Error: Task name is required", Colors.RED), file=sys.stderr) - return 1 - - tasks_dir = get_tasks_dir(repo_root) - - # Resolve task directory (supports task name, relative path, or absolute path) - task_dir = resolve_task_dir(task_name, repo_root) - - if not task_dir or not task_dir.is_dir(): - print(colored(f"Error: Task not found: {task_name}", Colors.RED), file=sys.stderr) - print("Active tasks:", file=sys.stderr) - # Import lazily to avoid circular dependency - from .tasks import iter_active_tasks - for t in iter_active_tasks(tasks_dir): - print(f" - {t.dir_name}/", file=sys.stderr) - return 1 - - # Refuse to archive anything that isn't a real task directly under - # .trellis/tasks/. A mistyped name (e.g. "src") resolves to repo_root/src, - # which is a dir but not a task — without this guard archive would move the - # user's source directory out of the repo. - if not is_within_tasks_dir(task_dir, repo_root): - print(colored( - f"Error: refusing to archive '{task_name}': " - f"{task_dir} is not a task under {tasks_dir}", - Colors.RED), file=sys.stderr) - return 1 - - dir_name = task_dir.name - task_json_path = task_dir / FILE_TASK_JSON - - # Update status before archiving - today = datetime.now().strftime("%Y-%m-%d") - # Names of child task dirs whose task.json gets modified below; passed - # into safe_archive_paths_to_add so they're staged in this commit. - modified_children: list[str] = [] - if task_json_path.is_file(): - data = read_json(task_json_path) - if data: - # Warn (don't block) when the recorded branch is stale — it was - # likely already merged and deleted (#399 item 2). - stored_branch = data.get("branch") - if stored_branch and not branch_exists_locally(stored_branch, repo_root): - print( - colored( - f"Warning: recorded branch '{stored_branch}' no longer exists locally " - "(likely merged and deleted).", - Colors.YELLOW, - ), - file=sys.stderr, - ) - - data["status"] = "completed" - data["completedAt"] = today - write_json(task_json_path, data) - - # Handle subtask relationships on archive. - # Keep this task in its parent's children list so progress - # counters (children_progress) stay consistent — children - # missing from the active set are treated as completed. - task_children = data.get("children", []) - - # If this is a parent, clear parent field in all children - if task_children: - for child_name in task_children: - child_dir_path = find_task_by_name(child_name, tasks_dir) - if child_dir_path: - child_json = child_dir_path / FILE_TASK_JSON - if child_json.is_file(): - child_data = read_json(child_json) - if child_data: - child_data["parent"] = None - write_json(child_json, child_data) - modified_children.append(child_dir_path.name) - - # Clear any session that still points at this task before the path moves. - from .active_task import clear_task_from_sessions - clear_task_from_sessions(str(task_dir), repo_root) - - # Archive - result = archive_task_complete(task_dir, repo_root) - if "archived_to" in result: - archive_dest = Path(result["archived_to"]) - year_month = archive_dest.parent.name - print(colored(f"Archived: {dir_name} -> archive/{year_month}/", Colors.GREEN), file=sys.stderr) - - # Auto-commit unless --no-commit - if not getattr(args, "no_commit", False): - if not _auto_commit_archive(dir_name, repo_root, modified_children): - print( - colored( - "Archive moved on disk, but git auto-commit did not complete. " - "Resolve `git status` before continuing.", - Colors.RED, - ), - file=sys.stderr, - ) - return 1 - - # Return the archive path - print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{year_month}/{dir_name}") - - # Run hooks with the archived path - archived_json = archive_dest / FILE_TASK_JSON - run_task_hooks("after_archive", archived_json, repo_root) - return 0 - - return 1 - - -def _auto_commit_archive( - task_name: str, - repo_root: Path, - modified_children: list[str] | None = None, -) -> bool: - """Stage Trellis-owned task paths and commit after archive. - - Scoped narrowly to the archived task's source + destination paths - plus any child task dirs whose ``task.json`` was edited (parent → - children relationship update). Dirty changes in OTHER active task - dirs are NOT bundled into the archive commit. - - If ``.gitignore`` blocks the paths, we warn + skip — we do NOT - retry with ``git add -f``. The warning explicitly forbids - ``git add -f .trellis/`` (which would fan out to caches/backups) - and points users at ``session_auto_commit: false``. - - Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when - set to ``false``, this function returns immediately without - touching git (the archive directory move on disk is unaffected). - """ - if not get_session_auto_commit(repo_root): - print( - "[OK] session_auto_commit: false — skipping git stage/commit.", - file=sys.stderr, - ) - return True - - source_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}" - rc, tracked_out, _ = run_git( - ["ls-files", "--", source_rel], - cwd=repo_root, - ) - source_was_tracked = rc == 0 and bool(tracked_out.strip()) - - paths = safe_archive_paths_to_add( - repo_root, task_name=task_name, modified_children=modified_children - ) - if not paths: - print("[OK] No task changes to commit.", file=sys.stderr) - return True - - success, _, err = safe_git_add(paths, repo_root) - if not success: - if err and "ignored by" in err.lower(): - print_gitignore_warning(paths) - else: - print( - f"[WARN] git add failed: {err.strip() if err else 'unknown error'}", - file=sys.stderr, - ) - return not source_was_tracked - - # Belt-and-suspenders for the phantom-delete bug: `safe_git_add` uses - # `git add` (no -A) which only stages additions/modifications. The - # source task directory was moved away by `shutil.move`, so its files - # need an explicit `git rm --cached` to stage the deletions in this - # same commit — otherwise they sit as uncommitted "phantom deletes" - # against HEAD until something later picks them up. - # - # `--ignore-unmatch` makes this a no-op when the task was never tracked - # (e.g. archiving a task that lived only in working tree). - run_git( - ["rm", "-r", "--cached", "--ignore-unmatch", "--", source_rel], - cwd=repo_root, - ) - - rc, _, _ = run_git( - ["diff", "--cached", "--quiet", "--", *paths, source_rel], - cwd=repo_root, - ) - if rc == 0: - print("[OK] No task changes to commit.", file=sys.stderr) - return True - - commit_msg = f"chore(task): archive {task_name}" - rc, _, err = run_git(["commit", "-m", commit_msg], cwd=repo_root) - if rc == 0: - print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr) - return True - else: - print(f"[WARN] Auto-commit failed: {err.strip()}", file=sys.stderr) - return not source_was_tracked - - -# ============================================================================= -# Command: add-subtask -# ============================================================================= - -def cmd_add_subtask(args: argparse.Namespace) -> int: - """Link a child task to a parent task.""" - repo_root = get_repo_root() - - parent_dir = resolve_task_dir(args.parent_dir, repo_root) - child_dir = resolve_task_dir(args.child_dir, repo_root) - - 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 - - if not parent_json_path.is_file(): - print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr) - return 1 - - if not child_json_path.is_file(): - print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr) - return 1 - - parent_data = read_json(parent_json_path) - child_data = read_json(child_json_path) - - if not parent_data or not child_data: - print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr) - return 1 - - # Check if child already has a parent - existing_parent = child_data.get("parent") - if existing_parent: - print(colored(f"Error: Child task already has a parent: {existing_parent}", Colors.RED), file=sys.stderr) - return 1 - - # Add child to parent's children list - parent_children = parent_data.get("children", []) - child_dir_name = child_dir.name - if child_dir_name not in parent_children: - parent_children.append(child_dir_name) - parent_data["children"] = parent_children - - # Set parent in child's task.json - child_data["parent"] = parent_dir.name - - # Write both - write_json(parent_json_path, parent_data) - write_json(child_json_path, child_data) - - print(colored(f"Linked: {child_dir.name} -> {parent_dir.name}", Colors.GREEN), file=sys.stderr) - return 0 - - -# ============================================================================= -# Command: remove-subtask -# ============================================================================= - -def cmd_remove_subtask(args: argparse.Namespace) -> int: - """Unlink a child task from a parent task.""" - repo_root = get_repo_root() - - parent_dir = resolve_task_dir(args.parent_dir, repo_root) - child_dir = resolve_task_dir(args.child_dir, repo_root) - - 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 - - if not parent_json_path.is_file(): - print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr) - return 1 - - if not child_json_path.is_file(): - print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr) - return 1 - - parent_data = read_json(parent_json_path) - child_data = read_json(child_json_path) - - if not parent_data or not child_data: - print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr) - return 1 - - # Remove child from parent's children list - parent_children = parent_data.get("children", []) - child_dir_name = child_dir.name - if child_dir_name in parent_children: - parent_children.remove(child_dir_name) - parent_data["children"] = parent_children - - # Clear parent in child's task.json - child_data["parent"] = None - - # Write both - write_json(parent_json_path, parent_data) - write_json(child_json_path, child_data) - - print(colored(f"Unlinked: {child_dir.name} from {parent_dir.name}", Colors.GREEN), file=sys.stderr) - return 0 - - -# ============================================================================= -# Command: set-branch -# ============================================================================= - -def cmd_set_branch(args: argparse.Namespace) -> int: - """Set git branch for task.""" - repo_root = get_repo_root() - target_dir = resolve_task_dir(args.dir, repo_root) - branch = args.branch - - if not branch: - print(colored("Error: Missing arguments", Colors.RED)) - print("Usage: python3 task.py set-branch <task-dir> <branch-name>") - return 1 - - 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: - return 1 - - data["branch"] = branch - write_json(task_json, data) - - print(colored(f"✓ Branch set to: {branch}", Colors.GREEN)) - return 0 - - -# ============================================================================= -# Command: set-base-branch -# ============================================================================= - -def cmd_set_base_branch(args: argparse.Namespace) -> int: - """Set the base branch (PR target) for task.""" - repo_root = get_repo_root() - target_dir = resolve_task_dir(args.dir, repo_root) - base_branch = args.base_branch - - if not base_branch: - print(colored("Error: Missing arguments", Colors.RED)) - print("Usage: python3 task.py set-base-branch <task-dir> <base-branch>") - print("Example: python3 task.py set-base-branch <dir> develop") - print() - print("This sets the target branch for PR (the branch your feature will merge into).") - return 1 - - 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: - return 1 - - data["base_branch"] = base_branch - write_json(task_json, data) - - print(colored(f"✓ Base branch set to: {base_branch}", Colors.GREEN)) - print(f" PR will target: {base_branch}") - return 0 - - -# ============================================================================= -# Command: set-scope -# ============================================================================= - -def cmd_set_scope(args: argparse.Namespace) -> int: - """Set scope for PR title.""" - repo_root = get_repo_root() - target_dir = resolve_task_dir(args.dir, repo_root) - scope = args.scope - - if not scope: - print(colored("Error: Missing arguments", Colors.RED)) - print("Usage: python3 task.py set-scope <task-dir> <scope>") - return 1 - - 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: - return 1 - - data["scope"] = scope - write_json(task_json, data) - - print(colored(f"✓ Scope set to: {scope}", Colors.GREEN)) - return 0 - - -# ============================================================================= -# Command: set-meta -# ============================================================================= - -def cmd_set_meta(args: argparse.Namespace) -> int: - """Set/overwrite one metadata key on an existing task.""" - repo_root = get_repo_root() - target_dir = resolve_task_dir(args.dir, repo_root) - key = args.key - value = args.value - - if not key: - print(colored("Error: Missing arguments", Colors.RED)) - print("Usage: python3 task.py set-meta <task-dir> <key> <value>") - return 1 - - 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: - return 1 - - meta = data.get("meta") - if not isinstance(meta, dict): - meta = {} - meta[key] = value - data["meta"] = meta - write_json(task_json, data) - - print(colored(f"✓ Meta set: {key} = {value}", Colors.GREEN)) - return 0 diff --git a/.trellis/scripts/common/task_utils.py b/.trellis/scripts/common/task_utils.py deleted file mode 100755 index 46cbec1..0000000 --- a/.trellis/scripts/common/task_utils.py +++ /dev/null @@ -1,309 +0,0 @@ -#!/usr/bin/env python3 -""" -Task utility functions. - -Provides: - is_safe_task_path - Validate task path is safe to operate on - find_task_by_name - Find task directory by name - resolve_task_dir - Resolve task directory from name, relative, or absolute path - archive_task_dir - Archive task to monthly directory - run_task_hooks - Run lifecycle hooks for task events -""" - -from __future__ import annotations - -import shutil -import sys -from datetime import datetime -from pathlib import Path - -from .paths import get_repo_root, get_tasks_dir - - -# ============================================================================= -# Path Safety -# ============================================================================= - -def is_safe_task_path(task_path: str, repo_root: Path | None = None) -> bool: - """Check if a relative task path is safe to operate on. - - Args: - task_path: Task path (relative to repo_root). - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - True if safe, False if dangerous. - """ - if repo_root is None: - repo_root = get_repo_root() - - normalized = task_path.replace("\\", "/") - - # Check empty or null - if not normalized or normalized == "null": - print("Error: empty or null task path", file=sys.stderr) - return False - - # Reject absolute paths - if Path(task_path).is_absolute(): - print(f"Error: absolute path not allowed: {task_path}", file=sys.stderr) - return False - - # Reject ".", "..", paths starting with "./" or "../", or containing ".." - if normalized in (".", "..") or normalized.startswith("./") or normalized.startswith("../") or ".." in normalized: - print(f"Error: path traversal not allowed: {task_path}", file=sys.stderr) - return False - - # Final check: ensure resolved path is not the repo root - abs_path = repo_root / Path(normalized) - if abs_path.exists(): - try: - resolved = abs_path.resolve() - root_resolved = repo_root.resolve() - if resolved == root_resolved: - print(f"Error: path resolves to repo root: {task_path}", file=sys.stderr) - return False - except (OSError, IOError): - pass - - return True - - -def is_within_tasks_dir(task_dir_abs: Path, repo_root: Path | None = None) -> bool: - """Check that a resolved task directory really is a task under the tasks dir. - - A real task lives directly at ``.trellis/tasks/<name>``. This returns True - only when ``task_dir_abs`` is an immediate child of the tasks directory. - - Guards archive: ``resolve_task_dir`` falls back to ``repo_root/<name>`` for - an unknown name, so a mistyped ``task.py archive src`` resolves to the real - ``src/`` source directory. Without this check archive would ``shutil.move`` - it out of the repo. Also rejects the tasks dir itself and anything nested - under ``archive/`` (already-archived tasks). - """ - if repo_root is None: - repo_root = get_repo_root() - try: - resolved = task_dir_abs.resolve() - tasks_resolved = get_tasks_dir(repo_root).resolve() - except (OSError, RuntimeError): - return False - if resolved.parent != tasks_resolved: - return False - return resolved.name != "archive" - - -# ============================================================================= -# Task Lookup -# ============================================================================= - -def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None: - """Find task directory by name (exact or suffix match). - - Args: - task_name: Task name to find. - tasks_dir: Tasks directory path. - - Returns: - Absolute path to task directory, or None if not found. - """ - if not task_name or not tasks_dir or not tasks_dir.is_dir(): - return None - - # Try exact match first - exact_match = tasks_dir / task_name - if exact_match.is_dir(): - return exact_match - - # Try suffix match (e.g., "my-task" matches "01-21-my-task") - for d in tasks_dir.iterdir(): - if d.is_dir() and d.name.endswith(f"-{task_name}"): - return d - - return None - - -# ============================================================================= -# Archive Operations -# ============================================================================= - -def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path | None: - """Archive a task directory to archive/{YYYY-MM}/. - - Args: - task_dir_abs: Absolute path to task directory. - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Path to archived directory, or None on error. - """ - if not task_dir_abs.is_dir(): - print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr) - return None - - # Get tasks directory (parent of the task) - tasks_dir = task_dir_abs.parent - archive_dir = tasks_dir / "archive" - year_month = datetime.now().strftime("%Y-%m") - month_dir = archive_dir / year_month - - # Create archive directory - try: - month_dir.mkdir(parents=True, exist_ok=True) - except (OSError, IOError) as e: - print(f"Error: Failed to create archive directory: {e}", file=sys.stderr) - return None - - # Move task to archive - task_name = task_dir_abs.name - dest = month_dir / task_name - - try: - shutil.move(str(task_dir_abs), str(dest)) - except (OSError, IOError, shutil.Error) as e: - print(f"Error: Failed to move task to archive: {e}", file=sys.stderr) - return None - - return dest - - -def archive_task_complete( - task_dir_abs: Path, - repo_root: Path | None = None -) -> dict[str, str]: - """Complete archive workflow: archive directory. - - Args: - task_dir_abs: Absolute path to task directory. - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Dict with archive result info. - """ - if not task_dir_abs.is_dir(): - print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr) - return {} - - archive_dest = archive_task_dir(task_dir_abs, repo_root) - if archive_dest: - return {"archived_to": str(archive_dest)} - - return {} - - -# ============================================================================= -# Task Directory Resolution -# ============================================================================= - -def resolve_task_dir(target_dir: str, repo_root: Path) -> Path | None: - """Resolve task directory to absolute path. - - Supports: - - Absolute path: /path/to/task - - Relative path: .trellis/tasks/01-31-my-task - - Task name: my-task (uses find_task_by_name for lookup) - - Args: - target_dir: Task directory specification. - repo_root: Repository root path. - - Returns: - Resolved absolute path, or None when it resolves outside - `repo_root`. Both sides are resolved before comparing, since - `repo_root` may itself sit behind a symlink (/tmp does on macOS). - """ - if not target_dir: - return Path() - - normalized = target_dir.replace("\\", "/") - while normalized.startswith("./"): - normalized = normalized[2:] - - # Absolute path - if Path(target_dir).is_absolute(): - candidate = Path(target_dir) - # Relative path (contains path separator or starts with .trellis) - elif "/" in normalized or normalized.startswith(".trellis"): - candidate = repo_root / Path(normalized) - else: - # Task name - try to find in tasks directory; fall back to treating - # it as a relative path when not found. - tasks_dir = get_tasks_dir(repo_root) - found = find_task_by_name(target_dir, tasks_dir) - candidate = found if found else repo_root / Path(normalized) - - try: - resolved = candidate.resolve() - root = repo_root.resolve() - except OSError: - return None - - try: - resolved.relative_to(root) - except ValueError: - return None - - return resolved - - -# ============================================================================= -# Lifecycle Hooks -# ============================================================================= - -def run_task_hooks(event: str, task_json_path: Path, repo_root: Path) -> None: - """Run lifecycle hooks for a task event. - - Args: - event: Event name (e.g. "after_create"). - task_json_path: Absolute path to the task's task.json. - repo_root: Repository root for cwd and config lookup. - """ - import os - import subprocess - - from .config import get_hooks - from .log import Colors, colored - - commands = get_hooks(event, repo_root) - if not commands: - return - - env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)} - - for cmd in commands: - try: - result = subprocess.run( - cmd, - shell=True, - cwd=repo_root, - env=env, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - if result.returncode != 0: - print( - colored(f"[WARN] Hook failed ({event}): {cmd}", Colors.YELLOW), - file=sys.stderr, - ) - if result.stderr.strip(): - print(f" {result.stderr.strip()}", file=sys.stderr) - except Exception as e: - print( - colored(f"[WARN] Hook error ({event}): {cmd} — {e}", Colors.YELLOW), - file=sys.stderr, - ) - - -# ============================================================================= -# Main Entry (for testing) -# ============================================================================= - -if __name__ == "__main__": - repo = get_repo_root() - tasks = get_tasks_dir(repo) - - print(f"Tasks dir: {tasks}") - print(f"is_safe_task_path('.trellis/tasks/test'): {is_safe_task_path('.trellis/tasks/test', repo)}") - print(f"is_safe_task_path('../test'): {is_safe_task_path('../test', repo)}") diff --git a/.trellis/scripts/common/tasks.py b/.trellis/scripts/common/tasks.py deleted file mode 100755 index 7b44094..0000000 --- a/.trellis/scripts/common/tasks.py +++ /dev/null @@ -1,112 +0,0 @@ -""" -Task data access layer. - -Single source of truth for loading and iterating task directories. -Replaces scattered task.json parsing across 9+ files. - -Provides: - load_task — Load a single task by directory path - iter_active_tasks — Iterate all non-archived tasks (sorted) - get_all_statuses — Get {dir_name: status} map for children progress -""" - -from __future__ import annotations - -from collections.abc import Iterator -from pathlib import Path - -from .io import read_json -from .paths import FILE_TASK_JSON -from .types import TaskInfo - - -def load_task(task_dir: Path) -> TaskInfo | None: - """Load task from a directory containing task.json. - - Args: - task_dir: Absolute path to the task directory. - - Returns: - TaskInfo if task.json exists and is valid, None otherwise. - """ - task_json = task_dir / FILE_TASK_JSON - if not task_json.is_file(): - return None - - data = read_json(task_json) - if not data: - return None - - return TaskInfo( - dir_name=task_dir.name, - directory=task_dir, - title=data.get("title") or data.get("name") or "unknown", - status=data.get("status", "unknown"), - assignee=data.get("assignee", ""), - priority=data.get("priority", "P2"), - children=tuple(data.get("children", [])), - parent=data.get("parent"), - package=data.get("package"), - raw=data, - ) - - -def iter_active_tasks(tasks_dir: Path) -> Iterator[TaskInfo]: - """Iterate all active (non-archived) tasks, sorted by directory name. - - Skips the "archive" directory and directories without valid task.json. - - Args: - tasks_dir: Path to the tasks directory. - - Yields: - TaskInfo for each valid task. - """ - if not tasks_dir.is_dir(): - return - - for d in sorted(tasks_dir.iterdir()): - if not d.is_dir() or d.name == "archive": - continue - info = load_task(d) - if info is not None: - yield info - - -def get_all_statuses(tasks_dir: Path) -> dict[str, str]: - """Get a {dir_name: status} mapping for all active tasks. - - Useful for computing children progress without loading full TaskInfo. - - Args: - tasks_dir: Path to the tasks directory. - - Returns: - Dict mapping directory names to status strings. - """ - return {t.dir_name: t.status for t in iter_active_tasks(tasks_dir)} - - -def children_progress( - children: tuple[str, ...] | list[str], - all_statuses: dict[str, str], -) -> str: - """Format children progress string like " [2/3 done]". - - Args: - children: List of child directory names. - all_statuses: Status map from get_all_statuses(). - - Returns: - Formatted string, or "" if no children. - """ - if not children: - return "" - # A child missing from active statuses has been archived (cmd_archive - # sets status=completed before moving the dir). Count it as done so - # parent progress doesn't regress when children are archived. - done = sum( - 1 for c in children - if c not in all_statuses or all_statuses.get(c) in ("completed", "done") - ) - return f" [{done}/{len(children)} done]" diff --git a/.trellis/scripts/common/trellis_config.py b/.trellis/scripts/common/trellis_config.py deleted file mode 100755 index a4a6d1e..0000000 --- a/.trellis/scripts/common/trellis_config.py +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env python3 -""" -Standalone reader for .trellis/config.yaml. - -Mirrors a minimal subset of common.config so callers (hooks, workflow_phase) -can read configuration without importing the full task/repo helpers. Returns -an empty dict on missing/malformed files so callers stay simple. -""" - -from __future__ import annotations - -from pathlib import Path -from typing import Optional - - -CONFIG_REL_PATH = ".trellis/config.yaml" - - -def _unquote(value: str) -> str: - if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"): - return value[1:-1] - return value - - -def _strip_inline_comment(value: str) -> str: - """Strip ` # …` inline comments while preserving `#` inside quoted strings. - - YAML treats ` #` (space-hash) as a comment opener; bare `#` inside a token - is part of the value. Quoted strings are immune. - """ - in_quote: str | None = None - for idx, ch in enumerate(value): - if in_quote: - if ch == in_quote: - in_quote = None - continue - if ch in ('"', "'"): - in_quote = ch - continue - if ch == "#" and (idx == 0 or value[idx - 1].isspace()): - return value[:idx] - return value - - -def _next_content_line(lines: list[str], start: int) -> tuple[int, str]: - i = start - while i < len(lines): - stripped = lines[i].strip() - if stripped and not stripped.startswith("#"): - return i, lines[i] - i += 1 - return i, "" - - -def _parse_yaml_block( - lines: list[str], start: int, min_indent: int, target: dict -) -> int: - i = start - current_list: list | None = None - - while i < len(lines): - line = lines[i] - stripped = line.strip() - - if not stripped or stripped.startswith("#"): - i += 1 - continue - - indent = len(line) - len(line.lstrip()) - if indent < min_indent: - break - - if stripped.startswith("- "): - if current_list is not None: - current_list.append(_unquote(stripped[2:].strip())) - i += 1 - elif ":" in stripped: - key, _, value = stripped.partition(":") - key = key.strip() - value = _strip_inline_comment(value).strip() - was_quoted = len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'") - value = _unquote(value) - current_list = None - - if value or was_quoted: - target[key] = value - i += 1 - else: - next_i, next_line = _next_content_line(lines, i + 1) - if next_i >= len(lines): - target[key] = {} - i = next_i - elif next_line.strip().startswith("- "): - current_list = [] - target[key] = current_list - i += 1 - else: - next_indent = len(next_line) - len(next_line.lstrip()) - if next_indent > indent: - nested: dict = {} - target[key] = nested - i = _parse_yaml_block(lines, i + 1, next_indent, nested) - else: - target[key] = {} - i += 1 - else: - i += 1 - - return i - - -def parse_simple_yaml(content: str) -> dict: - """Parse a small subset of YAML. See common.config for full doc.""" - lines = content.splitlines() - result: dict = {} - _parse_yaml_block(lines, 0, 0, result) - return result - - -def read_trellis_config(repo_root: Optional[Path] = None) -> dict: - """Read .trellis/config.yaml. Returns {} on missing or malformed file.""" - root = repo_root or Path.cwd() - config_file = root / CONFIG_REL_PATH - try: - content = config_file.read_text(encoding="utf-8") - except (FileNotFoundError, OSError): - return {} - try: - parsed = parse_simple_yaml(content) - except Exception: - return {} - return parsed if isinstance(parsed, dict) else {} diff --git a/.trellis/scripts/common/types.py b/.trellis/scripts/common/types.py deleted file mode 100755 index 5802e10..0000000 --- a/.trellis/scripts/common/types.py +++ /dev/null @@ -1,110 +0,0 @@ -""" -Core type definitions for Trellis task data. - -Provides: - TaskData — TypedDict for task.json shape (read-path type hints only) - TaskInfo — Frozen dataclass for loaded task (the public API type) - AgentRecord — TypedDict for registry.json agent entries -""" - -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path -from typing import TypedDict - - -# ============================================================================= -# task.json shape (TypedDict — used only for read-path type hints) -# ============================================================================= - -class TaskData(TypedDict, total=False): - """Shape of task.json on disk. - - Used only for type annotations when reading task.json. - Writes must use the original dict to avoid losing unknown fields. - """ - - id: str - name: str - title: str - description: str - status: str - dev_type: str - scope: str | None - package: str | None - priority: str - creator: str - assignee: str - createdAt: str - completedAt: str | None - branch: str | None - base_branch: str | None - worktree_path: str | None - commit: str | None - pr_url: str | None - subtasks: list[str] - children: list[str] - parent: str | None - relatedFiles: list[str] - notes: str - meta: dict - - -# ============================================================================= -# Loaded task object (frozen dataclass — the public API type) -# ============================================================================= - -@dataclass(frozen=True) -class TaskInfo: - """Immutable view of a loaded task. - - Created by load_task() / iter_active_tasks(). - Contains the commonly accessed fields; the original dict - is preserved in `raw` for write-back and uncommon field access. - """ - - dir_name: str - directory: Path - title: str - status: str - assignee: str - priority: str - children: tuple[str, ...] - parent: str | None - package: str | None - raw: dict # original dict — use for writes and uncommon fields - - @property - def name(self) -> str: - """Task name (id or name field).""" - return self.raw.get("name") or self.raw.get("id") or self.dir_name - - @property - def description(self) -> str: - return self.raw.get("description", "") - - @property - def branch(self) -> str | None: - return self.raw.get("branch") - - @property - def meta(self) -> dict: - return self.raw.get("meta", {}) - - -# ============================================================================= -# registry.json agent entry -# ============================================================================= - -class AgentRecord(TypedDict, total=False): - """Shape of an agent entry in registry.json.""" - - id: str - pid: int - task_dir: str - worktree_path: str - branch: str - platform: str - started_at: str - status: str diff --git a/.trellis/scripts/common/workflow_phase.py b/.trellis/scripts/common/workflow_phase.py deleted file mode 100755 index 9e1c619..0000000 --- a/.trellis/scripts/common/workflow_phase.py +++ /dev/null @@ -1,219 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Workflow Phase Extraction. - -Extracts step-level content from .trellis/workflow.md and optionally filters -platform-specific blocks. - -Platform marker syntax in workflow.md: - - [Claude Code, Cursor, ...] - agent-capable content - [/Claude Code, Cursor, ...] - -Provides: - get_phase_index - Extract the Phase Index section (no --step) - get_step - Extract a single step (#### X.X) section - filter_platform - Strip platform blocks that don't include the given name -""" - -from __future__ import annotations - -import re - -from .paths import DIR_WORKFLOW, get_repo_root - - -def _workflow_md_path(): - return get_repo_root() / DIR_WORKFLOW / "workflow.md" - -# Match a line that *is* a platform marker: "[A, B, C]" or "[/A, B, C]" -_MARKER_RE = re.compile(r"^\[(/?)([A-Za-z][^\[\]]*)\]\s*$") - -# Step heading: "#### 1.0 Title" or "#### 1.0 ..." -_STEP_HEADING_RE = re.compile(r"^####\s+(\d+\.\d+)\b.*$") - -# Phase Index starts here; Phase 1/2/3 step bodies follow; ends at Breadcrumbs. -_PHASE_INDEX_HEADING = "## Phase Index" - - -def _read_workflow() -> str: - path = _workflow_md_path() - if not path.exists(): - raise FileNotFoundError(f"workflow.md not found: {path}") - return path.read_text(encoding="utf-8") - - -def _parse_marker(line: str) -> tuple[bool, list[str]] | None: - """Parse a platform marker line. - - Returns: - (is_closing, [platform_names]) if line is a marker, else None. - """ - m = _MARKER_RE.match(line) - if not m: - return None - is_closing = m.group(1) == "/" - names = [p.strip() for p in m.group(2).split(",") if p.strip()] - return is_closing, names - - -def get_phase_index() -> str: - """Return the compact Phase Index summary from workflow.md. - - SessionStart and no-step phase context use this small summary as their - orientation payload. Detailed Phase 1/2/3 instructions are loaded with - ``get_step`` on demand. ``[workflow-state:STATUS]`` tag blocks are - consumed by the per-turn hook, so they're stripped from this output. - """ - text = _read_workflow() - lines = text.splitlines() - - start: int | None = None - end: int | None = None - for i, line in enumerate(lines): - stripped = line.strip() - if start is None and stripped == _PHASE_INDEX_HEADING: - start = i - continue - if start is not None and stripped == "## Phase 1: Plan": - end = i - break - - if start is None: - return "" - if end is None: - end = len(lines) - - section = "\n".join(lines[start:end]).rstrip() - # Strip [workflow-state:STATUS]...[/workflow-state:STATUS] blocks since - # they're injected separately by inject-workflow-state.py per-turn. - import re as _re - tag_re = _re.compile( - r"\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n.*?\n\s*\[/workflow-state:\1\]\n?", - _re.DOTALL, - ) - return tag_re.sub("", section).rstrip() + "\n" - - -def get_step(step_id: str) -> str: - """Return the `#### X.X` section matching step_id (header + body). - - Body ends at the next `####` or `---` or `##` heading (whichever comes first). - """ - text = _read_workflow() - lines = text.splitlines() - - start: int | None = None - for i, line in enumerate(lines): - m = _STEP_HEADING_RE.match(line) - if m and m.group(1) == step_id: - start = i - break - if start is None: - return "" - - end: int = len(lines) - for j in range(start + 1, len(lines)): - line = lines[j] - if line.startswith("#### "): - end = j - break - if line.startswith("## "): - end = j - break - # Horizontal rule at column 0 - if line.strip() == "---": - end = j - break - - return "\n".join(lines[start:end]).rstrip() + "\n" - - -def _platform_matches(platform: str, block_names: list[str]) -> bool: - """Case-insensitive fuzzy match: accept 'cursor', 'Cursor', 'claude-code', 'Claude Code'.""" - needle = platform.lower().replace("-", "").replace("_", "").replace(" ", "") - for name in block_names: - hay = name.lower().replace("-", "").replace("_", "").replace(" ", "") - if needle == hay: - return True - return False - - -def resolve_effective_platform(platform: str, config: dict) -> str: - """Map ``codex`` to a dispatch-mode-namespaced virtual platform name. - - When ``--platform codex`` is passed, return ``"codex-sub-agent"`` by - default or ``"codex-inline"`` when explicitly configured in - ``.trellis/config.yaml``. ``sub-agent`` remains an alias for ``auto``. - ``filter_platform`` then surfaces blocks whose marker lists include the - namespaced name (e.g. ``[codex-sub-agent, ...]`` or ``[codex-inline, Kilo, - Antigravity, Devin]``). - - Native Codex context injection supports the ``auto`` default. Invalid - explicit values fall back to ``inline`` safely; this renderer deliberately - does not warn because it can run in normal CLI output flows. - - Other platforms are returned unchanged. - """ - if platform == "codex": - mode = "auto" - codex_cfg = config.get("codex") if isinstance(config, dict) else None - if codex_cfg is not None: - if not isinstance(codex_cfg, dict): - mode = "inline" - else: - cfg_mode = str(codex_cfg.get("dispatch_mode", mode)).strip().lower() - if cfg_mode == "inline": - mode = "inline" - elif cfg_mode in ("auto", "sub-agent"): - mode = "auto" - else: - mode = "inline" - return "codex-sub-agent" if mode == "auto" else "codex-inline" - return platform - - -def filter_platform(content: str, platform: str) -> str: - """Keep lines outside any `[...]` block + lines inside blocks that include platform. - - Marker lines themselves are dropped from the output. - """ - lines = content.splitlines() - out: list[str] = [] - - in_block = False - keep_block = False - - for line in lines: - marker = _parse_marker(line) - if marker is not None: - is_closing, names = marker - if not is_closing: - in_block = True - keep_block = _platform_matches(platform, names) - else: - in_block = False - keep_block = False - continue # drop the marker line itself - - if in_block: - if keep_block: - out.append(line) - continue - out.append(line) - - # Collapse runs of 3+ blank lines that may arise from dropped markers - collapsed: list[str] = [] - blank_run = 0 - for line in out: - if line.strip() == "": - blank_run += 1 - if blank_run <= 2: - collapsed.append(line) - else: - blank_run = 0 - collapsed.append(line) - - return "\n".join(collapsed).rstrip() + "\n" diff --git a/.trellis/scripts/get_context.py b/.trellis/scripts/get_context.py deleted file mode 100755 index bc63463..0000000 --- a/.trellis/scripts/get_context.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python3 -""" -Get Session Context for AI Agent. - -Usage: - python3 get_context.py Output context in text format - python3 get_context.py --json Output context in JSON format -""" - -from __future__ import annotations - -from common.git_context import main - - -if __name__ == "__main__": - main() diff --git a/.trellis/scripts/get_developer.py b/.trellis/scripts/get_developer.py deleted file mode 100755 index f8a89eb..0000000 --- a/.trellis/scripts/get_developer.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env python3 -""" -Get current developer name. - -This is a wrapper that uses common/paths.py -""" - -from __future__ import annotations - -import sys - -from common.paths import get_developer - - -def main() -> None: - """CLI entry point.""" - developer = get_developer() - if developer: - print(developer) - else: - print("Developer not initialized", file=sys.stderr) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/.trellis/scripts/hooks/linear_sync.py b/.trellis/scripts/hooks/linear_sync.py deleted file mode 100755 index 5659fde..0000000 --- a/.trellis/scripts/hooks/linear_sync.py +++ /dev/null @@ -1,243 +0,0 @@ -#!/usr/bin/env python3 -"""Linear sync hook for Trellis task lifecycle. - -Syncs task events to Linear via the `linearis` CLI. - -Usage (called automatically by task.py hooks): - python3 .trellis/scripts/hooks/linear_sync.py create - python3 .trellis/scripts/hooks/linear_sync.py start - python3 .trellis/scripts/hooks/linear_sync.py archive - -Manual usage: - TASK_JSON_PATH=.trellis/tasks/<name>/task.json python3 .trellis/scripts/hooks/linear_sync.py sync - -Environment: - TASK_JSON_PATH - Absolute path to task.json (set by task.py) - -Configuration: - .trellis/hooks.local.json - Local config (gitignored), example: - { - "linear": { - "team": "TEAM_KEY", - "project": "Project Name", - "assignees": { - "dev-name": "linear-user-id" - } - } - } -""" - -from __future__ import annotations - -import json -import os -import subprocess -import sys -from pathlib import Path - -# ─── Configuration ──────────────────────────────────────────────────────────── - -# Trellis priority → Linear priority (1=Urgent, 2=High, 3=Medium, 4=Low) -PRIORITY_MAP = {"P0": 1, "P1": 2, "P2": 3, "P3": 4} - -# Linear status names (must match your team's workflow) -STATUS_IN_PROGRESS = "In Progress" -STATUS_DONE = "Done" - - -def _load_config() -> dict: - """Load local hook config from .trellis/hooks.local.json.""" - task_json_path = os.environ.get("TASK_JSON_PATH", "") - if task_json_path: - # Walk up from task.json to find .trellis/ - trellis_dir = Path(task_json_path).parent.parent.parent - else: - trellis_dir = Path(".trellis") - - config_path = trellis_dir / "hooks.local.json" - try: - with open(config_path, encoding="utf-8") as f: - return json.load(f) - except (OSError, json.JSONDecodeError): - return {} - - -CONFIG = _load_config() -LINEAR_CFG = CONFIG.get("linear", {}) - -TEAM = LINEAR_CFG.get("team", "") -PROJECT = LINEAR_CFG.get("project", "") -ASSIGNEE_MAP = LINEAR_CFG.get("assignees", {}) - -# ─── Helpers ────────────────────────────────────────────────────────────────── - - -def _read_task() -> tuple[dict, str]: - path = os.environ.get("TASK_JSON_PATH", "") - if not path: - print("TASK_JSON_PATH not set", file=sys.stderr) - sys.exit(1) - with open(path, encoding="utf-8") as f: - return json.load(f), path - - -def _write_task(data: dict, path: str) -> None: - with open(path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=False) - f.write("\n") - - -def _linearis(*args: str) -> dict | None: - result = subprocess.run( - ["linearis", *args], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - if result.returncode != 0: - print(f"linearis error: {result.stderr.strip()}", file=sys.stderr) - sys.exit(1) - stdout = result.stdout.strip() - if stdout: - return json.loads(stdout) - return None - - -def _get_linear_issue(task: dict) -> str | None: - meta = task.get("meta") - if isinstance(meta, dict): - return meta.get("linear_issue") - return None - - -# ─── Actions ────────────────────────────────────────────────────────────────── - - -def cmd_create() -> None: - if not TEAM: - print("No linear.team configured in hooks.local.json", file=sys.stderr) - sys.exit(1) - - task, path = _read_task() - - # Skip if already linked - if _get_linear_issue(task): - print(f"Already linked: {_get_linear_issue(task)}") - return - - title = task.get("title") or task.get("name") or "Untitled" - args = ["issues", "create", title, "--team", TEAM] - - # Map priority - priority = PRIORITY_MAP.get(task.get("priority", ""), 0) - if priority: - args.extend(["-p", str(priority)]) - - # Set project - if PROJECT: - args.extend(["--project", PROJECT]) - - # Assign to Linear user - assignee = task.get("assignee", "") - linear_user_id = ASSIGNEE_MAP.get(assignee) - if linear_user_id: - args.extend(["--assignee", linear_user_id]) - - # Link to parent's Linear issue if available - parent_issue = _resolve_parent_linear_issue(task) - if parent_issue: - args.extend(["--parent-ticket", parent_issue]) - - result = _linearis(*args) - if result and "identifier" in result: - if not isinstance(task.get("meta"), dict): - task["meta"] = {} - task["meta"]["linear_issue"] = result["identifier"] - _write_task(task, path) - print(f"Created Linear issue: {result['identifier']}") - - -def cmd_start() -> None: - task, _ = _read_task() - issue = _get_linear_issue(task) - if not issue: - return - _linearis("issues", "update", issue, "-s", STATUS_IN_PROGRESS) - print(f"Updated {issue} -> {STATUS_IN_PROGRESS}") - cmd_sync() - - -def cmd_archive() -> None: - task, _ = _read_task() - issue = _get_linear_issue(task) - if not issue: - return - _linearis("issues", "update", issue, "-s", STATUS_DONE) - print(f"Updated {issue} -> {STATUS_DONE}") - - -def cmd_sync() -> None: - """Sync prd.md content to Linear issue description.""" - task, _ = _read_task() - issue = _get_linear_issue(task) - if not issue: - print("No linear_issue in meta, run create first", file=sys.stderr) - sys.exit(1) - - # Find prd.md next to task.json - task_json_path = os.environ.get("TASK_JSON_PATH", "") - prd_path = Path(task_json_path).parent / "prd.md" - if not prd_path.is_file(): - print(f"No prd.md found at {prd_path}", file=sys.stderr) - sys.exit(1) - - description = prd_path.read_text(encoding="utf-8").strip() - _linearis("issues", "update", issue, "-d", description) - print(f"Synced prd.md to {issue} description") - - -# ─── Parent Issue Resolution ───────────────────────────────────────────────── - - -def _resolve_parent_linear_issue(task: dict) -> str | None: - """Find parent task's Linear issue identifier.""" - parent_name = task.get("parent") - if not parent_name: - return None - - task_json_path = os.environ.get("TASK_JSON_PATH", "") - if not task_json_path: - return None - - current_task_dir = Path(task_json_path).parent - tasks_dir = current_task_dir.parent - parent_json = tasks_dir / parent_name / "task.json" - - if parent_json.exists(): - try: - with open(parent_json, encoding="utf-8") as f: - parent_task = json.load(f) - return _get_linear_issue(parent_task) - except (json.JSONDecodeError, OSError): - pass - return None - - -# ─── Main ───────────────────────────────────────────────────────────────────── - -if __name__ == "__main__": - action = sys.argv[1] if len(sys.argv) > 1 else "" - actions = { - "create": cmd_create, - "start": cmd_start, - "archive": cmd_archive, - "sync": cmd_sync, - } - fn = actions.get(action) - if fn: - fn() - else: - print(f"Unknown action: {action}", file=sys.stderr) - print(f"Valid actions: {', '.join(actions)}", file=sys.stderr) - sys.exit(1) diff --git a/.trellis/scripts/init_developer.py b/.trellis/scripts/init_developer.py deleted file mode 100755 index 9fb53f5..0000000 --- a/.trellis/scripts/init_developer.py +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env python3 -""" -Initialize developer for workflow. - -Usage: - python3 init_developer.py <developer-name> - -This creates: - - .trellis/.developer file with developer info - - .trellis/workspace/<name>/ directory structure -""" - -from __future__ import annotations - -import sys - -from common.paths import ( - DIR_WORKFLOW, - FILE_DEVELOPER, - get_developer, -) -from common.developer import init_developer - - -def main() -> None: - """CLI entry point.""" - if len(sys.argv) < 2: - print(f"Usage: {sys.argv[0]} <developer-name>") - print() - print("Example:") - print(f" {sys.argv[0]} john") - sys.exit(1) - - name = sys.argv[1] - - # Check if already initialized - existing = get_developer() - if existing: - print(f"Developer already initialized: {existing}") - print() - print(f"To reinitialize, remove {DIR_WORKFLOW}/{FILE_DEVELOPER} first") - sys.exit(0) - - if init_developer(name): - sys.exit(0) - else: - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/.trellis/scripts/task.py b/.trellis/scripts/task.py deleted file mode 100755 index e67c763..0000000 --- a/.trellis/scripts/task.py +++ /dev/null @@ -1,612 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Task Management Script. - -Usage: - python3 task.py create "<title>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] [--package <pkg>] [--no-start] - python3 task.py add-context <dir> <file> <path> [reason] # Add jsonl entry - python3 task.py validate <dir> # Validate jsonl files - python3 task.py list-context <dir> # List jsonl entries - python3 task.py start <dir> # Set active task - python3 task.py current [--source] [--json] # Show active task - python3 task.py finish # Clear active task - python3 task.py set-branch <dir> <branch> # Set git branch - python3 task.py set-base-branch <dir> <branch> # Set PR target branch - python3 task.py set-scope <dir> <scope> # Set scope for PR title - python3 task.py set-meta <dir> <key> <value> # Set a task metadata key - python3 task.py archive <task-dir> # Archive completed task - python3 task.py list # List active tasks - python3 task.py list-archive [month] # List archived tasks - python3 task.py add-subtask <parent-dir> <child-dir> # Link child to parent - python3 task.py remove-subtask <parent-dir> <child-dir> # Unlink child from parent -""" - -from __future__ import annotations - -import argparse -import json -import sys - -from common.log import Colors, colored -from common.paths import ( - DIR_WORKFLOW, - DIR_TASKS, - FILE_TASK_JSON, - get_repo_root, - get_developer, - get_tasks_dir, - get_current_task, -) -from common.active_task import ( - clear_active_task, - resolve_active_task, - resolve_context_key, - set_active_task, -) -from common.io import read_json, write_json -from common.task_utils import resolve_task_dir, run_task_hooks -from common.tasks import iter_active_tasks, children_progress - -# Import command handlers from split modules (also re-exports for plan.py compatibility) -from common.task_store import ( - cmd_create, - cmd_archive, - cmd_set_branch, - cmd_set_base_branch, - cmd_set_scope, - cmd_set_meta, - cmd_add_subtask, - cmd_remove_subtask, -) -from common.task_context import ( - cmd_add_context, - cmd_validate, - cmd_list_context, -) - - -# ============================================================================= -# Command: start / finish -# ============================================================================= - -def cmd_start(args: argparse.Namespace) -> int: - """Set active task.""" - repo_root = get_repo_root() - task_input = args.dir - - if not task_input: - print(colored("Error: task directory or name required", Colors.RED)) - return 1 - - # Resolve task directory (supports task name, relative path, or absolute path) - full_path = resolve_task_dir(task_input, repo_root) - - if not full_path or 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. 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.resolve()).as_posix() - except ValueError: - # 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 - - if not resolve_context_key(): - # Degraded mode: no session identity available. - # Hook didn't inject TRELLIS_CONTEXT_ID (common on Windows + Claude Code, - # --continue resume path, fork distribution, hooks disabled, etc.). Skip - # per-session pointer write; AI continues based on conversation context. - print(colored( - "ℹ Session identity not available; active-task pointer not persisted " - "this session (degraded mode). AI continues based on conversation context.", - Colors.YELLOW, - )) - print(colored( - "Hint: run inside an AI IDE/session that exposes session identity, " - "or set TRELLIS_CONTEXT_ID before running task.py start.", - Colors.YELLOW, - )) - - # Still flip task.json status: planning → in_progress so downstream phases proceed. - if task_json_path.is_file(): - data = read_json(task_json_path) - if data and data.get("status") == "planning": - data["status"] = "in_progress" - if write_json(task_json_path, data): - print(colored("✓ Status: planning → in_progress (degraded)", Colors.GREEN)) - run_task_hooks("after_start", task_json_path, repo_root) - return 0 - - active = set_active_task(task_dir, repo_root) - if active: - print(colored(f"✓ Current task set to: {task_dir}", Colors.GREEN)) - print(f"Source: {active.source}") - - if task_json_path.is_file(): - data = read_json(task_json_path) - if data and data.get("status") == "planning": - data["status"] = "in_progress" - if write_json(task_json_path, data): - print(colored("✓ Status: planning → in_progress", Colors.GREEN)) - - print() - print(colored("The hook will now inject context from this task's jsonl files.", Colors.BLUE)) - - run_task_hooks("after_start", task_json_path, repo_root) - return 0 - else: - print(colored("Error: Failed to set current task", Colors.RED)) - return 1 - - -def cmd_finish(args: argparse.Namespace) -> int: - """Clear active task.""" - repo_root = get_repo_root() - active = clear_active_task(repo_root) - current = active.task_path - - if not current: - print(colored("No current task set", Colors.YELLOW)) - return 0 - - # Resolve task.json path before clearing - task_json_path = repo_root / current / FILE_TASK_JSON - - print(colored(f"✓ Cleared current task (was: {current})", Colors.GREEN)) - print(f"Source: {active.source}") - - if task_json_path.is_file(): - run_task_hooks("after_finish", task_json_path, repo_root) - return 0 - - -def cmd_current(args: argparse.Namespace) -> int: - """Show active task.""" - repo_root = get_repo_root() - active = resolve_active_task(repo_root) - - if getattr(args, "json", False): - task_obj = None - if active.task_path: - data = read_json(repo_root / active.task_path / FILE_TASK_JSON) or {} - task_obj = { - "dir": active.task_path, - "id": data.get("id") or data.get("name"), - "title": data.get("title"), - "status": data.get("status"), - "parent": data.get("parent"), - "children": data.get("children", []), - "branch": data.get("branch"), - "base_branch": data.get("base_branch"), - } - print(json.dumps({ - "current_task": task_obj, - "source": active.source, - "stale": active.stale, - }, ensure_ascii=False)) - return 0 if active.task_path else 1 - - if args.source: - print(f"Current task: {active.task_path or '(none)'}") - print(f"Source: {active.source}") - if active.stale: - print("State: stale") - return 0 if active.task_path else 1 - - if active.task_path: - print(active.task_path) - return 0 - - return 1 - - -# ============================================================================= -# Command: list -# ============================================================================= - -def _display_status(t, all_statuses: dict) -> str: - """Return the status label to show for a task in `list` output. - - A parent task's stored status stays "planning" until someone runs - `task.py start` on the parent directly, even while its children are - actively being worked — a misleading label for anyone scanning the - list (#399 item 3). Show "active" instead when at least one child is - past planning; the stored status.json value is left untouched. - """ - if t.status == "planning" and t.children: - child_in_flight = any( - all_statuses.get(c) not in (None, "planning") for c in t.children - ) - if child_in_flight: - return "active" - return t.status - - -def cmd_list(args: argparse.Namespace) -> int: - """List active tasks.""" - repo_root = get_repo_root() - tasks_dir = get_tasks_dir(repo_root) - current_task = get_current_task(repo_root) - developer = get_developer(repo_root) - filter_mine = args.mine - filter_status = args.status - as_json = getattr(args, "json", False) - - # Single pass: collect all tasks via shared iterator - all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)} - all_statuses = {name: t.status for name, t in all_tasks.items()} - - if as_json: - if filter_mine and not developer: - print(json.dumps({"error": "No developer set"}), file=sys.stderr) - return 1 - - items = [] - for dir_name in sorted(all_tasks.keys()): - t = all_tasks[dir_name] - if filter_mine and (t.assignee or "-") != developer: - continue - if filter_status and t.status != filter_status: - continue - items.append({ - "dir": f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}", - "id": t.raw.get("id") or dir_name, - "title": t.title, - "status": t.status, - "display_status": _display_status(t, all_statuses), - "priority": t.priority, - "assignee": t.assignee or None, - "parent": t.parent, - "children": list(t.children), - "package": t.package, - }) - print(json.dumps({"tasks": items}, ensure_ascii=False)) - return 0 - - if filter_mine: - if not developer: - print(colored("Error: No developer set. Run init_developer.py first", Colors.RED), file=sys.stderr) - return 1 - print(colored(f"My tasks (assignee: {developer}):", Colors.BLUE)) - else: - print(colored("All active tasks:", Colors.BLUE)) - print() - - # Display tasks hierarchically - count = 0 - - def _print_task(dir_name: str, indent: int = 0) -> None: - nonlocal count - t = all_tasks[dir_name] - - # Apply --mine filter - if filter_mine and (t.assignee or "-") != developer: - return - - # Apply --status filter - if filter_status and t.status != filter_status: - return - - relative_path = f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}" - marker = "" - if relative_path == current_task: - marker = f" {colored('<- current', Colors.GREEN)}" - - # Children progress - progress = children_progress(t.children, all_statuses) - status_label = _display_status(t, all_statuses) - - # Package tag - pkg_tag = f" @{t.package}" if t.package else "" - - prefix = " " * indent + " - " - - if filter_mine: - print(f"{prefix}{dir_name}/ ({status_label}){pkg_tag}{progress}{marker}") - else: - print(f"{prefix}{dir_name}/ ({status_label}){pkg_tag}{progress} [{colored(t.assignee or '-', Colors.CYAN)}]{marker}") - count += 1 - - # Print children indented - for child_name in t.children: - if child_name in all_tasks: - _print_task(child_name, indent + 1) - - # Display only top-level tasks: those without a parent, plus orphans - # whose recorded parent is not (or no longer) in the active set — a - # dangling parent ref must still render flat instead of disappearing. - for dir_name in sorted(all_tasks.keys()): - parent = all_tasks[dir_name].parent - if not parent or parent not in all_tasks: - _print_task(dir_name) - - if count == 0: - if filter_mine: - print(" (no tasks assigned to you)") - else: - print(" (no active tasks)") - - print() - print(f"Total: {count} task(s)") - return 0 - - -# ============================================================================= -# Command: list-archive -# ============================================================================= - -def cmd_list_archive(args: argparse.Namespace) -> int: - """List archived tasks.""" - repo_root = get_repo_root() - tasks_dir = get_tasks_dir(repo_root) - archive_dir = tasks_dir / "archive" - month = args.month - - print(colored("Archived tasks:", Colors.BLUE)) - print() - - if month: - month_dir = archive_dir / month - if month_dir.is_dir(): - print(f"[{month}]") - for d in sorted(month_dir.iterdir()): - if d.is_dir(): - print(f" - {d.name}/") - else: - print(f" No archives for {month}") - else: - if archive_dir.is_dir(): - for month_dir in sorted(archive_dir.iterdir()): - if month_dir.is_dir(): - month_name = month_dir.name - count = sum(1 for d in month_dir.iterdir() if d.is_dir()) - print(f"[{month_name}] - {count} task(s)") - - return 0 - - -# ============================================================================= -# Help -# ============================================================================= - -def show_usage() -> None: - """Show usage help.""" - print("""Task Management Script - -Usage: - python3 task.py create <title> Create new task directory - python3 task.py create <title> --package <pkg> Create task for a specific package - python3 task.py create <title> --parent <dir> Create task as child of parent - python3 task.py create <title> --no-start Create without making it active in this session - python3 task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl - python3 task.py validate <dir> Validate jsonl files - python3 task.py list-context <dir> List jsonl entries - python3 task.py start <dir> Set active task - python3 task.py current [--source] Show active task - python3 task.py finish Clear active task - python3 task.py set-branch <dir> <branch> Set git branch - python3 task.py set-base-branch <dir> <branch> Set PR target branch - python3 task.py set-scope <dir> <scope> Set scope for PR title - python3 task.py set-meta <dir> <key> <value> Set/overwrite a task metadata key - python3 task.py archive <task-dir> Archive completed task - python3 task.py add-subtask <parent> <child> Link child task to parent - python3 task.py remove-subtask <parent> <child> Unlink child from parent - python3 task.py list [--mine] [--status <status>] [--json] List tasks - python3 task.py list-archive [YYYY-MM] List archived tasks - -Monorepo options: - --package <pkg> Package name (validated against config.yaml packages) - -List options: - --mine, -m Show only tasks assigned to current developer - --status, -s <s> Filter by status (planning, in_progress, review, completed) - --json Output machine-readable JSON (also available on `current`) - -Examples: - python3 task.py create "Add login feature" --slug add-login - python3 task.py create "Add login feature" --slug add-login --package cli - python3 task.py create "Add login feature" --meta linear=ENG-123 --meta epic=auth - python3 task.py create "Child task" --slug child --parent .trellis/tasks/01-21-parent - python3 task.py add-context <dir> implement .trellis/spec/cli/backend/auth.md "Auth guidelines" - python3 task.py set-branch <dir> task/add-login - python3 task.py start .trellis/tasks/01-21-add-login - python3 task.py current --source - python3 task.py finish - python3 task.py archive add-login - python3 task.py add-subtask parent-task child-task # Link existing tasks - python3 task.py remove-subtask parent-task child-task - python3 task.py list # List all active tasks - python3 task.py list --mine # List my tasks only - python3 task.py list --mine --status in_progress # List my in-progress tasks -""") - - -# ============================================================================= -# Main Entry -# ============================================================================= - -def main() -> int: - """CLI entry point.""" - # Deprecation guard: `init-context` was removed in v0.5.0-beta.12. - # Detect early so argparse doesn't mask the real reason with a generic - # "invalid choice" error. - if len(sys.argv) >= 2 and sys.argv[1] == "init-context": - print( - colored( - "Error: `task.py init-context` was removed in v0.5.0-beta.12.", - Colors.RED, - ), - file=sys.stderr, - ) - print( - "implement.jsonl / check.jsonl are now seeded on `task.py create` for", - file=sys.stderr, - ) - print( - "sub-agent-capable platforms and curated by the AI during planning when needed.", - file=sys.stderr, - ) - print("See .trellis/workflow.md planning artifact guidance or run:", file=sys.stderr) - print( - " python3 ./.trellis/scripts/get_context.py --mode phase --step 1", - file=sys.stderr, - ) - print( - "Use `task.py add-context <dir> implement|check <path> <reason>` to append entries.", - file=sys.stderr, - ) - return 2 - - parser = argparse.ArgumentParser( - description="Task Management Script", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - subparsers = parser.add_subparsers(dest="command", help="Commands") - - # create - p_create = subparsers.add_parser("create", help="Create new task") - p_create.add_argument("title", help="Task title") - p_create.add_argument("--slug", "-s", help="Task slug without the MM-DD date prefix") - p_create.add_argument("--assignee", "-a", help="Assignee developer") - p_create.add_argument("--priority", "-p", default="P2", help="Priority (P0-P3)") - p_create.add_argument("--description", "-d", help="Task description") - p_create.add_argument("--parent", help="Parent task directory (establishes subtask link)") - p_create.add_argument("--package", help="Package name for monorepo projects") - p_create.add_argument( - "--base-branch", - help="PR target branch (overrides origin/HEAD detection and the checked-out-branch fallback)", - ) - p_create.add_argument( - "--meta", - action="append", - help="Task metadata key=value (repeatable)", - ) - p_create.add_argument( - "--no-start", - action="store_true", - help="Create the task without making it active in this session", - ) - - # add-context - p_add = subparsers.add_parser("add-context", help="Add context entry") - p_add.add_argument("dir", help="Task directory") - p_add.add_argument("file", help="JSONL file (implement|check)") - p_add.add_argument("path", help="File path to add") - p_add.add_argument("reason", nargs="?", help="Reason for adding") - - # validate - p_validate = subparsers.add_parser("validate", help="Validate context files") - p_validate.add_argument("dir", help="Task directory") - - # list-context - p_listctx = subparsers.add_parser("list-context", help="List context entries") - p_listctx.add_argument("dir", help="Task directory") - - # start - p_start = subparsers.add_parser("start", help="Set active task") - p_start.add_argument("dir", help="Task directory") - - # current - p_current = subparsers.add_parser("current", help="Show active task") - p_current.add_argument("--source", action="store_true", - help="Show active task source") - p_current.add_argument("--json", action="store_true", - help="Output machine-readable JSON") - - # finish - subparsers.add_parser("finish", help="Clear active task") - - # set-branch - p_branch = subparsers.add_parser("set-branch", help="Set git branch") - p_branch.add_argument("dir", help="Task directory") - p_branch.add_argument("branch", help="Branch name") - - # set-base-branch - p_base = subparsers.add_parser("set-base-branch", help="Set PR target branch") - p_base.add_argument("dir", help="Task directory") - p_base.add_argument("base_branch", help="Base branch name (PR target)") - - # set-scope - p_scope = subparsers.add_parser("set-scope", help="Set scope") - p_scope.add_argument("dir", help="Task directory") - p_scope.add_argument("scope", help="Scope name") - - # set-meta - p_setmeta = subparsers.add_parser("set-meta", help="Set/overwrite a task metadata key") - p_setmeta.add_argument("dir", help="Task directory") - p_setmeta.add_argument("key", help="Metadata key") - p_setmeta.add_argument("value", help="Metadata value") - - # archive - p_archive = subparsers.add_parser("archive", help="Archive task") - p_archive.add_argument("name", help="Task directory or name") - p_archive.add_argument("--no-commit", action="store_true", help="Skip auto git commit after archive") - - # list - p_list = subparsers.add_parser("list", help="List tasks") - p_list.add_argument("--mine", "-m", action="store_true", help="My tasks only") - p_list.add_argument("--status", "-s", help="Filter by status") - p_list.add_argument("--json", action="store_true", help="Output machine-readable JSON") - - # add-subtask - p_addsub = subparsers.add_parser("add-subtask", help="Link child task to parent") - p_addsub.add_argument("parent_dir", help="Parent task directory") - p_addsub.add_argument("child_dir", help="Child task directory") - - # remove-subtask - p_rmsub = subparsers.add_parser("remove-subtask", help="Unlink child task from parent") - p_rmsub.add_argument("parent_dir", help="Parent task directory") - p_rmsub.add_argument("child_dir", help="Child task directory") - - # list-archive - p_listarch = subparsers.add_parser("list-archive", help="List archived tasks") - p_listarch.add_argument("month", nargs="?", help="Month (YYYY-MM)") - - args = parser.parse_args() - - if not args.command: - show_usage() - return 1 - - commands = { - "create": cmd_create, - "add-context": cmd_add_context, - "validate": cmd_validate, - "list-context": cmd_list_context, - "start": cmd_start, - "current": cmd_current, - "finish": cmd_finish, - "set-branch": cmd_set_branch, - "set-base-branch": cmd_set_base_branch, - "set-scope": cmd_set_scope, - "set-meta": cmd_set_meta, - "archive": cmd_archive, - "add-subtask": cmd_add_subtask, - "remove-subtask": cmd_remove_subtask, - "list": cmd_list, - "list-archive": cmd_list_archive, - } - - if args.command in commands: - return commands[args.command](args) - else: - show_usage() - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.trellis/spec/core/docs/documentation-ownership.md b/.trellis/spec/core/docs/documentation-ownership.md deleted file mode 100644 index ca2f582..0000000 --- a/.trellis/spec/core/docs/documentation-ownership.md +++ /dev/null @@ -1,144 +0,0 @@ -# Documentation Ownership - -## Canonical Map - -Use the owner already declared by `docs/README.md`: - -| Document | Owns | -| --- | --- | -| `docs/product.md` | Trusted-LAN product goal, supported phased capabilities, non-goals, success criteria, and universal product safety boundaries | -| `docs/architecture.md` | Current process/data flow, dependency direction, state ownership, typed hardware/network boundaries, persistence shape, and mechanical invariants | -| `docs/plans/active/mvp.md` | The sole active cross-layer execution plan, progress, next product work, and plan-level acceptance | -| `docs/handoff.zh-CN.md` | Sanitized current implementation/evidence handoff; it is a status summary, not a new source of scope or architecture | -| `docs/development.md` | Native Linux setup, validation, Simulator, container-build distinction, HIL-0 commands, and controlled HIL entry points | -| `docs/installation.md` | Compose-only production support, host preparation, lifecycle, privilege/data boundaries, evidence limits, and legacy-state boundary | -| `docs/compatibility.md` | Public capability matrix, Designed/Fixture/HIL-0/HIL/Runtime evidence, and explicit unverified boundaries | -| `docs/troubleshooting.md` | Stable public error codes, fact layers, safe diagnostic order, and permitted support material | -| `docs/privacy-and-publication.md` | Public/private record boundary and publication procedure | -| `docs/decisions/*.md` | Accepted changes to product scope, architecture, licensing, deployment, or evidence authorization | - -Root `README.md` is a public project entry point and links to this map. Keep it -brief; do not turn it, `AGENTS.md`, or the handoff into a copy of all canonical -documents. - -## Product Scope Guardrails - -`docs/product.md` defines Simplus as a single-administrator trusted-LAN Linux -tool for a small number of 4G/5G modems, stable Lines, SMS and calls, with -phased removable-eUICC installed-profile management and narrowly scoped Host -VoWiFi/Mihomo egress. It explicitly is not a public SaaS, multi-tenant system, -general cellular data gateway/hotspot, host-wide proxy, full eSIM RSP platform, -enterprise PKI/audit/update system, or general remote-control platform. - -Do not broaden those terms through an implementation or documentation edit. -New scope requires an explicit user decision and a new/superseding ADR. The -history in `docs/decisions/0001-product-scope-reset.md` and -`docs/decisions/0002-restore-vowifi-mihomo-euicc.md` shows why the current -narrow wording must be resolved through decisions rather than inferred from -old plans or existing code. - -## Architecture and Code Truth - -Update `docs/architecture.md` when ownership, dependency direction, a public -data flow, a persisted truth source, or a mechanical invariant changes. Cite -representative code/tests and describe current assembly, including limitations. -For example, the architecture correctly distinguishes persisted Line identity -from transient Agent targets, the HIL-accepted QDC507 native SMS production -transport, and the still-unavailable real call transport; that evidence is -visible in `internal/application/line/service.go`, `cmd/simplus-agent/main.go`, -`cmd/simplusd/main.go`, and `internal/modemadapter/registry_test.go`. - -When code and docs appear inconsistent: - -1. inspect the current implementation, tests, accepted decisions, and public - compatibility claim; -2. determine whether the code or documentation is stale; -3. fix the approved source of truth and its enforcing test/check; -4. update summaries/links only after the canonical owner is correct. - -Do not document an aspirational architecture as current, and do not treat dead -or fixture-only code as a production capability. - -## Decision Records - -ADRs in `docs/decisions/` are numbered, dated, and normally contain status, -background, decision, and consequences. Superseding decisions name the earlier -record they revise; examples include -`docs/decisions/0017-managed-modems-and-capability-adapters.md` and -`docs/decisions/0019-line-identity-and-communication-paths.md`. - -Create or update an ADR for a durable product-scope, architecture, deployment, -license, security boundary, or side-effect authorization decision. Do not -rewrite historical context to make the latest design look inevitable; preserve -the earlier decision and record the revision. Implementation detail that does -not alter a durable decision belongs in code/tests or the active plan, not a -new ADR. - -License statements must match `LICENSE`, `THIRD_PARTY_NOTICES.md`, and -`docs/decisions/0013-polyform-noncommercial-license.md`: the root project is -noncommercial source-available, not OSI open source, and separately marked -components/assets retain their own licenses. - -## Plans and Handoffs - -`docs/plans/README.md` requires a plan for cross-layer verticals, migrations, -or high-risk hardware work and says small single-file changes need none. A plan -records goal, non-goals, steps, acceptance, progress, and decisions. Keep -exactly one Markdown plan under `docs/plans/active/`; archive a completed plan -as that guide directs and remove its active-map reference. - -The handoff summarizes sanitized current capability and the next work. Update -it after meaningful progress changes, but keep normative scope in product, -invariants in architecture, evidence in compatibility, and execution detail in -the plan. `scripts/dev/check-docs.py` rejects retired remote-development -markers in the handoff. - -Trellis `.trellis/tasks/**` PRD/design/implement files are task-control -artifacts and may be more detailed or temporary. They do not replace the -public active plan or accepted ADR when a lasting project decision changes. - -## Operational and Evidence Docs - -- Put reproducible native development and safe command meanings in - `docs/development.md`; keep private host paths and one-off recovery steps out. -- Put production install/lifecycle facts in `docs/installation.md`; keep Docker - Compose as the sole supported production path and distinguish implemented - contract, development-VM smoke, pending clean-VM Runtime, and hardware HIL. - Legacy native state may be described as an unsupported preservation/recovery - boundary, never as a second installer or automatic migration path. -- Put only sanitized evidence summaries with explicit levels in - `docs/compatibility.md`. A fixture, endpoint, or successful build does not - imply HIL/Runtime. -- Put stable codes and general diagnostic order in `docs/troubleshooting.md`. - Raw SIP, identities, addresses, payloads, logs, and incident timelines stay - private. - -## Mechanical Checks - -`scripts/dev/check-docs.py`, invoked by `make check-docs`, currently verifies: - -- required root/canonical documents exist; -- exactly one active plan exists; -- public private-record directories are absent; -- `AGENTS.md` stays at or below 100 lines; -- retired handoff markers are absent; -- local Markdown links resolve inside the repository; -- public Markdown lacks known private addresses/paths, credential URLs/query - values, proxy share URIs, telecom identity values, and site-specific markers. - -Keep this checker small and deterministic. If a new invariant is objective and -important, add a regression to the checker; do not claim it is enforced when -it is only prose. Passing it is necessary but does not replace human privacy, -scope, evidence, and license review. - -## Avoid - -- Copying the product non-goals or full validation guide into every entry - point. -- Using a handoff, old plan, issue, chat, private record, or Trellis task as the - sole source for a durable public decision. -- Deleting an accepted ADR because a later decision revised it. -- Updating a compatibility claim without naming evidence level and remaining - limits. -- Adding external links where a stable repository-owned explanation is the - actual canonical source. diff --git a/.trellis/spec/core/docs/index.md b/.trellis/spec/core/docs/index.md deleted file mode 100644 index 830b1a9..0000000 --- a/.trellis/spec/core/docs/index.md +++ /dev/null @@ -1,64 +0,0 @@ -# Core Documentation Guidelines - -> Source-backed ownership and publication rules for Simplus's repository -> knowledge base. - -## Scope - -These guides apply to `README.md`, `docs/**`, `AGENTS.md`, license/notices, -public claims in release material, and the documentation checker. They do not -turn `.trellis/tasks/**` into product documentation: Trellis task artifacts -record the current engineering task, while `docs/**` remains the canonical -public product/architecture/operations record. - -`docs/README.md` is the public knowledge map. Its central rule is progressive -disclosure: update the canonical owner, then link to it instead of copying the -same product, architecture, safety, or status prose into several entry points. - -## Pre-Development Checklist - -- Start at `docs/README.md` and identify the one canonical document that owns - the proposed statement. -- Read [Documentation Ownership](./documentation-ownership.md) before changing - scope, architecture, plans, status, operational instructions, or decisions. -- Read [Privacy and Publication](./privacy-and-publication.md) before adding - hardware/network evidence, troubleshooting material, screenshots, examples, - release assets, identities, paths, or credentials. -- Compare the statement with code/tests and current accepted decisions. When - they conflict, investigate the implementation and update the correct source; - do not preserve stale prose by copying it elsewhere. -- Decide whether the change also requires `docs/compatibility.md`, - `docs/troubleshooting.md`, the sole active plan, an ADR, or a test/checker - update. - -## Guide Index - -| Guide | Project-specific contract | -| --- | --- | -| [Documentation Ownership](./documentation-ownership.md) | Canonical map, product scope, ADRs, active plans, status, operational docs, and mechanical checks | -| [Privacy and Publication](./privacy-and-publication.md) | Public/private evidence boundary, safe conclusions, repository/release review, and license material | - -## Quality Check - -- The change updates one canonical owner and entry-point links rather than - creating a second manual. -- Product and compatibility claims match current code, tests, accepted - decisions, and evidence levels; unverified limits remain explicit. -- Scope/architecture changes have an ADR; complex cross-layer or high-risk - work updates the active plan, while a small edit does not invent a plan. -- No private deployment, identity, credential, topology, path, raw log/HIL, - screenshot, database, packet capture, or troubleshooting timeline entered - the public tree. -- Local Markdown links resolve, exactly one active plan remains, required - knowledge-map files exist, and `AGENTS.md` stays a concise instruction map. - -## Verification - -```bash -make check-docs -``` - -For a public release/publication step, also run a redacting secret scanner over -the working tree and intended history as required by -`docs/privacy-and-publication.md`. Scanner output is sensitive and must not be -committed. diff --git a/.trellis/spec/core/docs/privacy-and-publication.md b/.trellis/spec/core/docs/privacy-and-publication.md deleted file mode 100644 index fcae12b..0000000 --- a/.trellis/spec/core/docs/privacy-and-publication.md +++ /dev/null @@ -1,127 +0,0 @@ -# Privacy and Publication - -## Public and Private Records - -`docs/privacy-and-publication.md` permits public product/architecture/decision -content, general development/install/recovery instructions, synthetic fixtures, -Simulator data, automated tests, dependency provenance/licenses, and sanitized -compatibility conclusions. - -Keep these outside the public repository and release artifacts: - -- credentials, subscription URLs/YAML, proxy node endpoints, controller - secrets, cookies, administrator passwords, private keys, and authentication - material; -- real LAN/VPN topology, host addresses/configuration, usernames, personal - absolute paths, and private endpoints; -- SIM/eUICC/IMS/device identities, serials, phone numbers, AKA material, - complete SIP headers, and other linkable telecom identifiers; -- raw HIL or per-node results, command/service logs, packet captures, - databases, screenshots, recordings, and private troubleshooting timelines; -- abandoned private planning and environment recovery notes. - -This applies to code comments, tests, fixtures, commit messages, PR/issues, -Actions output, release assets, copied terminal output, Trellis task material, -and chat-derived documentation—not only files under `docs/`. - -## Synthetic Fixtures and Logging - -Use clearly synthetic bounded values in tests. Do not paste a real identifier -and call it a fixture after masking a few digits. Current tests construct fake -instance IDs/fingerprints and temporary paths, while production code converts -sensitive identities to instance-scoped fingerprints/hints before ordinary -business use (`internal/hardwareprobe/scanner_test.go`, -`internal/application/line/service_test.go`, and -`docs/decisions/0017-managed-modems-and-capability-adapters.md`). - -Public/stable errors contain a code and safe retry semantics, not raw payloads. -`internal/api/httpapi/server.go` maps service errors to `openapi.ApiError`, and -`docs/troubleshooting.md` documents safe stages/codes. Logging should retain -the minimum request/stage identifiers needed to diagnose behavior and must not -add credentials, bodies, SIM/IMS/device identity, full SIP/RP/PDU, addresses, -nodes, or hardware paths. - -Dedicated sensitive reads must remain explicit and ephemeral. The managed -modem equipment-identity POST is authenticated, fenced to current hardware, -returns `no-store`, and does not put the raw value in the database or ordinary -list response (`docs/architecture.md` and -`internal/application/modem/agent_identity.go`). Do not turn it into a cached -list field or log value. - -## From Private Evidence to Public Conclusion - -Publish only the minimum conclusion: - -1. the capability that was implemented; -2. its evidence level from `docs/compatibility.md`; -3. the important unverified boundaries; -4. the automated fixture/test or stable error that guards regression. - -Remove dates/times tied to a private run, addresses, topology, nodes, -subscriptions, traffic counts, personal paths, identities, phone numbers, -raw commands, and transcripts. If a detail does not help a public reader -understand architecture, compatibility, or recovery, omit it instead of trying -to redact it. - -Raw evidence belongs in the external private record system. Even there, live -credentials and authentication material require appropriate encrypted storage; -a private Git repository alone is not a secret manager. - -## Runtime, Build, and Release Artifacts - -`.dev/`, `.tools/`, Compose `./data`, SQLite/WAL files, logs, recordings, -captures, configuration, and local reference material are private runtime -artifacts. `internal/containercontract/contract_test.go` protects -`.dockerignore` exclusions for `.git`, `.dev`, `.tools`, `.env`, `/data`, -local references, private docs, and packet captures. Do not widen the Docker -context or add a debug `COPY` that reintroduces them. - -Public release assets may include the fixed application images, the -allowlisted versioned Compose deployment archive and checksum, its three-image -digest manifest, the strongSwan-plugin package plus corresponding -source/manifest, and required Mihomo corresponding source when the locked -release workflow produces them. The deployment archive contains only the -literal-tag Compose file, three-key `.env.example`, reviewed host scripts, -README, version metadata, root license, and third-party notices; it never -contains `.env`, source, Git metadata, runtime data, logs, or private evidence. -The allowed set and license/provenance are defined by `Dockerfile`, -`third_party/**`, `packaging/container/**`, -`packaging/strongswan-plugins/**`, `THIRD_PARTY_NOTICES.md`, and -`.github/workflows/containers.yml`. Container data, site config, logs, or -credentials are never release assets. - -## Publication Procedure - -For an actual public publication, follow the full procedure in -`docs/privacy-and-publication.md`: - -- move necessary raw/private records to the external private system and remove - their public references; -- run `make check-docs` and a redacting secret scan over the intended tree; -- create a sanitized new Git history rather than making an existing private - history public; -- scan/review the new history and inspect workflows, issues, and release - assets manually; -- rotate any live credential that entered chat, logs, a worktree, or old - history; -- obtain repository-owner review of `LICENSE`, - `THIRD_PARTY_NOTICES.md`, and the final file/asset list before changing a - public remote. - -Scanner output and review reports can themselves contain detected secrets; -keep them redacted and out of Git. - -## Avoid - -- Assuming `.gitignore` removes tracked data or old history. -- Treating partial masking as sufficient when the detail need not be public. -- Posting raw logs/screenshots to explain a compatibility claim or support - issue. -- Publishing a generated database, Compose data directory, diagnostic bundle, - or release build context for convenience. -- Calling the root PolyForm license "open source" or erasing the separate - licenses of the in-repo strongSwan component and third-party assets - (`docs/decisions/0013-polyform-noncommercial-license.md`). -- Letting a successful HIL authorize publication of its raw evidence; approval - to execute an action and approval to disclose its artifacts are different - boundaries. diff --git a/.trellis/spec/guides/code-reuse-thinking-guide.md b/.trellis/spec/guides/code-reuse-thinking-guide.md deleted file mode 100644 index bb789e9..0000000 --- a/.trellis/spec/guides/code-reuse-thinking-guide.md +++ /dev/null @@ -1,223 +0,0 @@ -# Code Reuse Thinking Guide - -> **Purpose**: Stop and think before creating new code - does it already exist? - ---- - -## The Problem - -**Duplicated code is the #1 source of inconsistency bugs.** - -When you copy-paste or rewrite existing logic: -- Bug fixes don't propagate -- Behavior diverges over time -- Codebase becomes harder to understand - ---- - -## Before Writing New Code - -### Step 1: Search First - -```bash -# Search for similar function names -grep -r "functionName" . - -# Search for similar logic -grep -r "keyword" . -``` - -### Step 2: Ask These Questions - -| Question | If Yes... | -|----------|-----------| -| Does a similar function exist? | Use or extend it | -| Is this pattern used elsewhere? | Follow the existing pattern | -| Could this be a shared utility? | Create it in the right place | -| Am I copying code from another file? | **STOP** - extract to shared | - ---- - -## Common Duplication Patterns - -### Pattern 1: Copy-Paste Functions - -**Bad**: Copying a validation function to another file - -**Good**: Extract to shared utilities, import where needed - -### Pattern 2: Similar Components - -**Bad**: Creating a new component that's 80% similar to existing - -**Good**: Extend existing component with props/variants - -### Pattern 3: Repeated Constants - -**Bad**: Defining the same constant in multiple files - -**Good**: Single source of truth, import everywhere - -### Pattern 4: Repeated Payload Field Extraction - -**Bad**: Multiple consumers cast the same JSON/event fields locally: - -```typescript -const description = (ev as { description?: string }).description; -const context = (ev as { context?: ContextEntry[] }).context; -``` - -This is duplicated contract logic even when the code is only two lines. Each -consumer now has its own definition of what a valid payload means. - -**Good**: Put the decoder, type guard, or projection next to the data owner: - -```typescript -if (isThreadEvent(ev)) { - renderThreadEvent(ev); -} -``` - -**Rule**: If the same untyped payload field is read in 2+ places, create a -shared type guard / normalizer / projection before adding a third reader. - ---- - -## When to Abstract - -**Abstract when**: -- Same code appears 3+ times -- Logic is complex enough to have bugs -- Multiple people might need this - -**Don't abstract when**: -- Only used once -- Trivial one-liner -- Abstraction would be more complex than duplication - ---- - -## After Batch Modifications - -When you've made similar changes to multiple files: - -1. **Review**: Did you catch all instances? -2. **Search**: Run grep to find any missed -3. **Consider**: Should this be abstracted? - -### Reducers Should Use Exhaustive Structure - -When state is derived from action-like values (`action`, `kind`, `status`, -`phase`), prefer a reducer with one `switch` over scattered `if/else` updates. - -```typescript -// BAD - action-specific state transitions are hard to audit -if (action === "opened") { ... } -else if (action === "comment") { ... } -else if (action === "status") { ... } - -// GOOD - one reducer owns the transition table -switch (event.action) { - case "opened": - ... - return; - case "comment": - ... - return; -} -``` - -This matters when the event log is the source of truth. A reducer is the -documented replay model; display code and commands should not duplicate pieces -of that replay model. - ---- - -## Checklist Before Commit - -- [ ] Searched for existing similar code -- [ ] No copy-pasted logic that should be shared -- [ ] No repeated untyped payload field extraction outside a shared decoder -- [ ] Constants defined in one place -- [ ] Similar patterns follow same structure -- [ ] Reducer/action transitions live in one reducer or command dispatcher - ---- - -## Gotcha: Python if/elif/else Exhaustive Check - -**Problem**: Python's if/elif/else chains have no compile-time exhaustive check. When you add a new value to a `Literal` type (e.g., `Platform`), existing if/elif/else chains silently fall through to `else` with wrong defaults. - -**Symptom**: New platform works partially — some methods return Claude defaults instead of platform-specific values. No error is raised. - -**Example** (`cli_adapter.py`): -```python -# BAD: "gemini" falls through to else, returns "claude" -@property -def cli_name(self) -> str: - if self.platform == "opencode": - return "opencode" - else: - return "claude" # gemini silently gets "claude"! - -# GOOD: explicit branch for every platform -@property -def cli_name(self) -> str: - if self.platform == "opencode": - return "opencode" - elif self.platform == "gemini": - return "gemini" - else: - return "claude" -``` - -**Prevention**: When adding a new value to a Python `Literal` type, search for ALL if/elif/else chains that switch on that type and add explicit branches. Don't rely on `else` being correct for new values. - ---- - -## Gotcha: Asymmetric Mechanisms Producing Same Output - -**Problem**: When two different mechanisms must produce the same file set (e.g., recursive directory copy for init vs. manual `files.set()` for update), structural changes (renaming, moving, adding subdirectories) only propagate through the automatic mechanism. The manual one silently drifts. - -**Symptom**: Init works perfectly, but update creates files at wrong paths or misses files entirely. - -**Prevention**: -- **Best**: Eliminate the asymmetry — have the manual path call the automatic one (e.g., `collectTemplateFiles()` calls `getAllScripts()` instead of maintaining its own list) -- **If asymmetry is unavoidable**: Add a regression test that compares outputs from both mechanisms -- When migrating directory structures, search for ALL code paths that reference the old structure - -**Real example**: `trellis update` had a manual `files.set()` list for 11 scripts that `getAllScripts()` already tracked. Fix: replaced the manual list with a `for..of getAllScripts()` loop. See `update.ts` refactor in v0.4.0-beta.3. - ---- - -## Template File Registration (Trellis-specific) - -When adding new files to `src/templates/trellis/scripts/`: - -**Single registration point**: `src/templates/trellis/index.ts` - -1. Add `export const xxxScript = readTemplate("scripts/path/file.py");` -2. Add to `getAllScripts()` Map - -That's it. `commands/update.ts` uses `getAllScripts()` directly — no manual sync needed. - -**Why this matters**: Without registration in `getAllScripts()`, `trellis update` won't sync the file to user projects. Bug fixes and features won't propagate. - -**History**: Before v0.4.0-beta.3, `update.ts` had its own hand-maintained file list that frequently fell out of sync with `getAllScripts()`. This caused 11 Python files to be silently skipped during `trellis update`. The fix was to eliminate the duplicate list and use `getAllScripts()` as the single source of truth. - -### Quick Checklist for New Scripts - -```bash -# After adding a new .py file, verify it's in getAllScripts(): -grep -l "newFileName" src/templates/trellis/index.ts # Should match -``` - -### Template Sync Convention - -`.trellis/scripts/` (dogfooded) and `packages/cli/src/templates/trellis/scripts/` (template) must stay identical. After editing `.trellis/scripts/`, always sync: - -```bash -rsync -av --delete --exclude='__pycache__' .trellis/scripts/ packages/cli/src/templates/trellis/scripts/ -``` - -**Gotcha**: Running rsync with wrong source/destination paths can create nested garbage directories (e.g., `.trellis/scripts/packages/cli/...`). Always double-check paths before running. diff --git a/.trellis/spec/guides/cross-layer-thinking-guide.md b/.trellis/spec/guides/cross-layer-thinking-guide.md deleted file mode 100644 index ee5cef4..0000000 --- a/.trellis/spec/guides/cross-layer-thinking-guide.md +++ /dev/null @@ -1,375 +0,0 @@ -# Cross-Layer Thinking Guide - -> **Purpose**: Think through data flow across layers before implementing. - ---- - -## The Problem - -**Most bugs happen at layer boundaries**, not within layers. - -Common cross-layer bugs: - -- API returns format A, frontend expects format B -- Database stores X, service transforms to Y, but loses data -- Multiple layers implement the same logic differently - ---- - -## Before Implementing Cross-Layer Features - -### Step 1: Map the Data Flow - -Draw out how data moves: - -``` -Source → Transform → Store → Retrieve → Transform → Display -``` - -For each arrow, ask: - -- What format is the data in? -- What could go wrong? -- Who is responsible for validation? - -### Step 2: Identify Boundaries - -| Boundary | Common Issues | -| --------------------- | --------------------------------- | -| API ↔ Service | Type mismatches, missing fields | -| Service ↔ Database | Format conversions, null handling | -| Backend ↔ Frontend | Serialization, date formats | -| Component ↔ Component | Props shape changes | - -### Step 3: Define Contracts - -For each boundary: - -- What is the exact input format? -- What is the exact output format? -- What errors can occur? - ---- - -## Common Cross-Layer Mistakes - -### Mistake 1: Implicit Format Assumptions - -**Bad**: Assuming date format without checking - -**Good**: Explicit format conversion at boundaries - -### Mistake 2: Scattered Validation - -**Bad**: Validating the same thing in multiple layers - -**Good**: Validate once at the entry point - -### Mistake 3: Leaky Abstractions - -**Bad**: Component knows about database schema - -**Good**: Each layer only knows its neighbors - -### Mistake 4: Every Consumer Parses The Same Payload - -**Bad**: A command reads JSONL events and casts fields inline: - -```typescript -const thread = (ev as { thread?: string }).thread; -const labels = (ev as { labels?: string[] }).labels; -``` - -This looks local, but it means every consumer owns a private version of the -event contract. The next field change will update one command and miss another. - -**Good**: Decode once at the event boundary, then export typed projections: - -```typescript -if (!isThreadEvent(ev)) return false; -return ev.thread === filter.thread; -``` - -**Rule**: For append-only logs, JSON streams, RPC payloads, or config files, -create one owner for: - -- event / payload type definitions -- type guards and normalization from `unknown` -- metadata projections used by UI commands -- reducers that replay state from the source of truth - -Rendering code may format fields, but it must not redefine the payload contract. - ---- - -## Checklist for Cross-Layer Features - -Before implementation: - -- [ ] Mapped the complete data flow -- [ ] Identified all layer boundaries -- [ ] Defined format at each boundary -- [ ] Decided where validation happens - -After implementation: - -- [ ] Tested with edge cases (null, empty, invalid) -- [ ] Verified error handling at each boundary -- [ ] Checked data survives round-trip -- [ ] Checked that consumers import shared decoders / projections instead of - casting payload fields locally -- [ ] Checked that derived state points back to the source event identifier - (`seq`, `id`, `version`) instead of inventing a second cursor - -### Browser Authorization and Proxy Checklist - -When a browser has public, bootstrap/setup, and authenticated API surfaces: - -- [ ] Enumerate each credential/cookie domain before adding global 401 logic; - the same status can mean rejected login, expired setup authorization, or - expired administrator session. -- [ ] Classify authorization recovery from the originating operation/path, not - from HTTP status alone. -- [ ] Trace all route-guard arrows together and add a bounded-navigation test; - a final-URL assertion alone can miss two guards redirecting in a loop. -- [ ] For a development reverse proxy, decide explicitly whether the upstream - needs target authority or browser-facing authority. -- [ ] If a backend builds redirect URLs from a validated Host, prove the proxy - preserves that Host and that a remote browser never receives a loopback - destination. -- [ ] Keep host validation at the backend boundary; do not accept forwarding - headers merely to compensate for a proxy rewrite. - -### Credential-Bearing External Fetch Checklist - -When an application fetches a URL that may embed credentials and presents the -result through API and Web layers: - -- [ ] Make compatibility headers an exact, provider-neutral contract backed by - synthetic tests and discriminating evidence; do not add per-host branches - or a browser-impersonation retry ladder. -- [ ] Treat HTTP success and boundary-parser success as intermediate evidence, - not end-to-end compatibility. Drive the negotiated representation through - its final consumer (for example, an artifact/config generator) so a - summary-only format cannot pass while dropping fields needed downstream. -- [ ] When a request header negotiates representation shape, make a synthetic - provider return different valid formats for different header values and - assert that the selected value reaches the real downstream consumer. -- [ ] Trace URL, transport, redirect, status, response-body and parser errors to - the service boundary. Reduce expected failures to a typed stable code - before any value can reach an API error or ordinary log. -- [ ] Keep failure-state persistence distinct from the external failure: decide - explicitly what happens when recording the failure also fails. -- [ ] Map application errors by type or sentinel, never mutable error text, and - verify the public status/code plus the Web message together. -- [ ] Prove failure preserves the last-known-good state and clears transient UI - busy state. Assert complete error/log strings against synthetic private - markers, not only `errors.Is` or status codes. -- [ ] Re-run the original SSRF, redirect, timeout and response-size tests after - any compatibility change. See the concrete Mihomo refresh contract in - `core/backend/application-boundaries.md`. - ---- - -## Cross-Platform Template Consistency - -In Trellis, command templates (e.g., `record-session.md`) exist in **multiple platforms** with identical or near-identical content. This is a cross-layer boundary. - -### Checklist: After Modifying Any Command Template - -- [ ] Find all platforms with the same command: `find src/templates/*/commands/trellis/ -name "<command>.*"` -- [ ] Update all platform copies (Markdown `.md` and TOML `.toml`) -- [ ] For Gemini TOML: adapt line continuations (`\\` vs `\`) and triple-quoted strings -- [ ] Run `/trellis:check-cross-layer` to verify nothing was missed - -**Real-world example**: Updated `record-session.md` in Claude to use `--mode record`, but forgot iFlow, Kilo, OpenCode, and Gemini — caught by cross-layer check. - ---- - -## Generated Runtime Template Upgrade Consistency - -Some generated files are both documentation and runtime input. In Trellis, -`.trellis/workflow.md` is parsed by `get_context.py`, `workflow_phase.py`, -SessionStart filters, and per-turn hooks. Template changes must be validated -against both fresh init and upgrade paths. - -### Checklist: After Modifying A Runtime-Parsed Template - -- [ ] Identify every runtime parser that reads the template, not just the file - writer that installs it -- [ ] Check whether relevant syntax lives outside obvious managed regions - such as tag blocks -- [ ] Verify fresh `init` output and a versioned `update` scenario that writes - the older `.trellis/.version` -- [ ] Add an upgrade regression using an older pristine template fixture, then - assert the installed file reaches the current packaged shape -- [ ] Update the backend spec that owns the runtime contract - ---- - -## Versioned Documentation Boundary - -Versioned documentation is a cross-layer boundary: source paths, `docs.json` -version routing, and the rendered version selector must all describe the same -release line. - -### Checklist: Before Editing Versioned Docs - -- [ ] Identify the target release line: stable, beta, or RC -- [ ] Verify the edited MDX path matches that line: - - stable: `docs-site/{start,advanced,...}` and `docs-site/zh/{start,advanced,...}` - - beta: `docs-site/beta/**` and `docs-site/zh/beta/**` - - RC: `docs-site/rc/**` and `docs-site/zh/rc/**` -- [ ] Verify `docs.json` navigation points the version label to the same paths -- [ ] Grep the opposite tree for release-line-specific terms before committing -- [ ] Treat beta content appearing under root release paths as a source-path bug, - not a rendering bug - -**Real-world example**: A beta-only task workflow change documented -`prd.md` + `design.md` + `implement.md`, task-creation consent, and Codex -mode banners under root `start/` and `advanced/` paths. The docs site then -served 0.6 beta behavior under the Release selector. The fix was to restore root -release docs, move the 0.6 content to `beta/` and `zh/beta/`, and add a grep -audit for beta markers against the root release tree. - -**Real-world example**: Codex inline mode changed workflow platform markers from -`[Codex]` / `[Kilo, Antigravity, Windsurf]` to `[codex-sub-agent]` / -`[codex-inline, Kilo, Antigravity, Windsurf]`. Fresh init was correct, but -`trellis update` only merged `[workflow-state:*]` blocks and preserved stale -markers outside those blocks. Result: upgraded projects got new hook scripts -but old workflow routing, so `get_context.py --mode phase --platform codex` -could return empty Phase 2.1 detail. - ---- - -## Mode-Detection Probe Checklist - -When a CLI auto-detects a mode by probing a remote resource (e.g., checking if `index.json` exists to decide marketplace vs direct download): - -### Before implementing: - -- [ ] Probe runs in **ALL** code paths that use the result (interactive, `-y`, `--flag` combos) -- [ ] 404 vs transient error are distinguished — don't treat both as "not found" -- [ ] Transient errors **abort or retry**, never silently switch modes -- [ ] Shared state (caches, prefetched data) is **reset** when context changes (e.g., user switches source) -- [ ] **Shortcut paths** (e.g., `--template` skipping picker) must have the same error-handling quality as the probed path — check that downstream functions don't call catch-all wrappers - -### After implementing: - -- [ ] Trace every path from probe result to the mode-decision branch — no fallthrough -- [ ] External format contracts (giget URI, raw URLs) are tested or at least documented as comments -- [ ] Metadata reads consume a complete response or use a streaming parser — never parse a fixed-size prefix as full JSON -- [ ] When reconstructing a composite identifier from parsed parts, verify **all** fields are included and in the **correct position** (e.g., `provider:repo/path#ref` not `provider:repo#ref/path`) -- [ ] Verify that **action functions** called after a shortcut don't internally use the old catch-all fetch — they must use the probe-quality variant when error distinction matters - -**Real-world example**: Custom registry flow had 8 bugs across 3 review rounds: (1) probe only ran in interactive mode, (2) transient errors fell through to wrong mode, (3) giget URI had `#ref` in wrong position, (4) prefetched templates leaked across source switches, (5) `--template` shortcut bypassed probe but `downloadTemplateById` internally used catch-all `fetchTemplateIndex`, turning timeouts into "Template not found". - -**Real-world example**: Agent-session update hints fetched npm `latest` metadata with `response.read(4096)` and then parsed it as complete JSON. The `@mindfoldhq/trellis` package metadata exceeded 4 KB, so the JSON was truncated, parse failed silently, and the first session injection showed no update hint. Fix: read the complete response before parsing, and add a regression where `version` is followed by an 8 KB metadata tail. - ---- - -## Cross-Platform Template Consistency - -In Trellis, command templates (e.g., `record-session.md`) exist in **multiple platforms** with identical or near-identical content. This is a cross-layer boundary. - -### Checklist: After Modifying Any Command Template - -- [ ] Find all platforms with the same command: `find src/templates/*/commands/trellis/ -name "<command>.*"` -- [ ] Update all platform copies (Markdown `.md` and TOML `.toml`) -- [ ] For Gemini TOML: adapt line continuations (`\\` vs `\`) and triple-quoted strings -- [ ] Run `/trellis:check-cross-layer` to verify nothing was missed - -**Real-world example**: Updated `record-session.md` in Claude to use `--mode record`, but forgot iFlow, Kilo, OpenCode, and Gemini — caught by cross-layer check. - ---- - -## Generated Runtime Template Upgrade Consistency - -Some generated files are both documentation and runtime input. In Trellis, -`.trellis/workflow.md` is parsed by `get_context.py`, `workflow_phase.py`, -SessionStart filters, and per-turn hooks. Template changes must be validated -against both fresh init and upgrade paths. - -### Checklist: After Modifying A Runtime-Parsed Template - -- [ ] Identify every runtime parser that reads the template, not just the file - writer that installs it -- [ ] Check whether relevant syntax lives outside obvious managed regions - such as tag blocks -- [ ] Verify fresh `init` output and a versioned `update` scenario that writes - the older `.trellis/.version` -- [ ] Add an upgrade regression using an older pristine template fixture, then - assert the installed file reaches the current packaged shape -- [ ] Update the backend spec that owns the runtime contract - -**Real-world example**: Codex inline mode changed workflow platform markers from -`[Codex]` / `[Kilo, Antigravity, Windsurf]` to `[codex-sub-agent]` / -`[codex-inline, Kilo, Antigravity, Windsurf]`. Fresh init was correct, but -`trellis update` only merged `[workflow-state:*]` blocks and preserved stale -markers outside those blocks. Result: upgraded projects got new hook scripts -but old workflow routing, so `get_context.py --mode phase --platform codex` -could return empty Phase 2.1 detail. - ---- - -## Mode-Detection Probe Checklist - -When a CLI auto-detects a mode by probing a remote resource (e.g., checking if `index.json` exists to decide marketplace vs direct download): - -### Before implementing: -- [ ] Probe runs in **ALL** code paths that use the result (interactive, `-y`, `--flag` combos) -- [ ] 404 vs transient error are distinguished — don't treat both as "not found" -- [ ] Transient errors **abort or retry**, never silently switch modes -- [ ] Shared state (caches, prefetched data) is **reset** when context changes (e.g., user switches source) -- [ ] **Shortcut paths** (e.g., `--template` skipping picker) must have the same error-handling quality as the probed path — check that downstream functions don't call catch-all wrappers - -### After implementing: -- [ ] Trace every path from probe result to the mode-decision branch — no fallthrough -- [ ] External format contracts (giget URI, raw URLs) are tested or at least documented as comments -- [ ] Metadata reads consume a complete response or use a streaming parser — never parse a fixed-size prefix as full JSON -- [ ] When reconstructing a composite identifier from parsed parts, verify **all** fields are included and in the **correct position** (e.g., `provider:repo/path#ref` not `provider:repo#ref/path`) -- [ ] Verify that **action functions** called after a shortcut don't internally use the old catch-all fetch — they must use the probe-quality variant when error distinction matters - -**Real-world example**: Custom registry flow had 8 bugs across 3 review rounds: (1) probe only ran in interactive mode, (2) transient errors fell through to wrong mode, (3) giget URI had `#ref` in wrong position, (4) prefetched templates leaked across source switches, (5) `--template` shortcut bypassed probe but `downloadTemplateById` internally used catch-all `fetchTemplateIndex`, turning timeouts into "Template not found". - -**Real-world example**: Agent-session update hints fetched npm `latest` metadata with `response.read(4096)` and then parsed it as complete JSON. The `@mindfoldhq/trellis` package metadata exceeded 4 KB, so the JSON was truncated, parse failed silently, and the first session injection showed no update hint. Fix: read the complete response before parsing, and add a regression where `version` is followed by an 8 KB metadata tail. - ---- - -## When to Create Flow Documentation - -Create detailed flow docs when: - -- Feature spans 3+ layers -- Multiple teams are involved -- Data format is complex -- Feature has caused bugs before - ---- - -## Event Log / Projection Boundary - -Append-only logs are cross-layer contracts. A single event travels through: - -``` -CLI input → event writer → events.jsonl → reader → filter → reducer → display -``` - -### Checklist: After Adding A New Event Kind Or Field - -- [ ] Add the event kind to the central event taxonomy -- [ ] Add a typed event variant or type guard at the event layer -- [ ] Add normalization helpers for array/object fields that come from - user input or JSON -- [ ] Keep `seq` / `id` assignment in the event writer only -- [ ] Make filters and reducers consume the typed event guard, not local casts -- [ ] Make display code consume reducer output or typed events, not raw JSON -- [ ] Add at least one regression that proves history replay and live filtering - use the same filter model - -**Real-world example**: Thread channels added `kind: "thread"`, `description`, -`context`, labels, and `lastSeq`. The first implementation replayed thread -state correctly, but several commands still re-parsed event payload fields with -local casts. The fix was to make the core event layer own `ThreadChannelEvent` -and `isThreadEvent`, make `reduceChannelMetadata` the only channel metadata -projection, and make `reduceThreads` the only thread replay reducer. diff --git a/.trellis/spec/guides/index.md b/.trellis/spec/guides/index.md deleted file mode 100644 index 56c6d77..0000000 --- a/.trellis/spec/guides/index.md +++ /dev/null @@ -1,97 +0,0 @@ -# Thinking Guides - -> **Purpose**: Expand your thinking to catch things you might not have considered. - ---- - -## Why Thinking Guides? - -**Most bugs and tech debt come from "didn't think of that"**, not from lack of skill: - -- Didn't think about what happens at layer boundaries → cross-layer bugs -- Didn't think about code patterns repeating → duplicated code everywhere -- Didn't think about edge cases → runtime errors -- Didn't think about future maintainers → unreadable code - -These guides help you **ask the right questions before coding**. - ---- - -## Available Guides - -| Guide | Purpose | When to Use | -|-------|---------|-------------| -| [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md) | Identify patterns and reduce duplication | When you notice repeated patterns | -| [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) | Think through data flow across layers | Features spanning multiple layers | - ---- - -## Quick Reference: Thinking Triggers - -### When to Think About Cross-Layer Issues - -- [ ] Feature touches 3+ layers (API, Service, Component, Database) -- [ ] Data format changes between layers -- [ ] Multiple consumers need the same data -- [ ] You're not sure where to put some logic -- [ ] You are adding an event kind, JSONL record, RPC payload, or config field -- [ ] UI / command code starts casting raw payload fields directly - -→ Read [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) - -### When to Think About Code Reuse - -- [ ] 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're creating a new utility/helper function** ← Search first! -- [ ] Two files read the same untyped payload field with local casts -- [ ] Multiple branches update the same derived state from `kind` / `action` - -→ Read [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md) - -### When Verifying AI Cross-Review Results - -- [ ] Reviewer claims "user input can be malicious" → Check the actual data source (internal manifest? user config? external API?) -- [ ] Reviewer flags "missing validation" → Is the data from a trusted internal source? -- [ ] Reviewer says "behavior change" → Read the code comments — is it intentional design? -- [ ] Reviewer identifies a "bug" in test → Mentally delete the feature being tested — does the test still pass? If yes → tautological test - -**Common AI reviewer false-positive patterns**: -1. **Trust boundary confusion**: Treating internal data (bundled JSON manifests) as untrusted external input -2. **Ignoring design comments**: Flagging intentional behavior documented in code comments as bugs -3. **Variable misreading**: Not tracing a variable to its actual definition (e.g., Map keyed by path vs name) - -**Verification rule**: Every CRITICAL/WARNING finding must be verified against the actual code before prioritizing. Budget ~35% false-positive rate for AI reviews. - ---- - -## Pre-Modification Rule (CRITICAL) - -> **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. - ---- - -## How to Use This Directory - -1. **Before coding**: Skim the relevant thinking guide -2. **During coding**: If something feels repetitive or complex, check the guides -3. **After bugs**: Add new insights to the relevant guide (learn from mistakes) - ---- - -## Contributing - -Found a new "didn't think of that" moment? Add it to the relevant guide. - ---- - -**Core Principle**: 30 minutes of thinking saves 3 hours of debugging. diff --git a/.trellis/tasks/archive/2026-08/00-bootstrap-guidelines/check.jsonl b/.trellis/tasks/archive/2026-08/00-bootstrap-guidelines/check.jsonl deleted file mode 100644 index f055862..0000000 --- a/.trellis/tasks/archive/2026-08/00-bootstrap-guidelines/check.jsonl +++ /dev/null @@ -1,4 +0,0 @@ -{"file":".trellis/tasks/00-bootstrap-guidelines/research/trellis-bootstrap-repair.md","reason":"Audit every repaired claim and path against the recorded repository evidence"} -{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Review package discovery through task manifests and sub-agent context injection end to end"} -{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"Check for duplicated, divergent, or unstable configuration and conventions"} -{"file":".trellis/spec/web/frontend/index.md","reason":"Ensure the repair does not regress the completed Web package and frontend specs"} diff --git a/.trellis/tasks/archive/2026-08/00-bootstrap-guidelines/design.md b/.trellis/tasks/archive/2026-08/00-bootstrap-guidelines/design.md deleted file mode 100644 index 1c65478..0000000 --- a/.trellis/tasks/archive/2026-08/00-bootstrap-guidelines/design.md +++ /dev/null @@ -1,152 +0,0 @@ -# Technical Design: Trellis Bootstrap Repair - -## 1. Boundaries - -The repair changes only local Trellis configuration/spec/task material and the -repository instruction entry point: - -- `.trellis/config.yaml` -- `.trellis/spec/core/**` -- `.trellis/tasks/00-bootstrap-guidelines/**` -- `AGENTS.md` - -Existing generated Trellis runtime/scripts, `.codex/` integration files, -application code, and the completed `.trellis/spec/web/frontend/**` tree are -read-only unless verification proves a concrete in-scope inconsistency. - -## 2. Package and Spec Model - -Use stable Trellis identifiers rather than language package display names: - -```yaml -packages: - core: - path: . - web: - path: web -default_package: core -``` - -`core` represents root-owned Go, operational, and documentation work. `web` -continues to represent the pnpm workspace. The root path physically contains -`web/`, but Trellis package selection is task metadata rather than an automatic -filesystem ownership detector. Cross-package work must therefore curate both -packages' relevant specs explicitly in task manifests. - -The spec tree becomes: - -```text -.trellis/spec/ -├── core/ -│ ├── backend/ -│ ├── infra/ -│ └── docs/ -├── web/ -│ └── frontend/ -└── guides/ -``` - -Each new layer owns an `index.md` that states applicability, pre-development -checks, quality checks, and links to focused source-backed guides. - -## 3. Root Spec Contents - -### Backend - -- `directory-structure.md`: `cmd/`, `internal/`, `api/`, package ownership, - generated boundaries, and test co-location. -- `application-boundaries.md`: typed ports, application services, agent/netd - privilege boundaries, stable business identifiers, and fail-closed rules. -- `api-contracts.md`: OpenAPI-first public HTTP types, bounded internal Unix - protocols, authentication/CSRF/error mapping, and generation checks. -- `storage-and-migrations.md`: SQLite stores, sqlc/generated files, migration - ownership, privacy-sensitive persistence, and migration tests. -- `quality-and-testing.md`: Go test style, fixture/integration evidence, - targeted-to-broad validation, formatting/lint, and generated checks. - -### Infrastructure - -- `build-and-generated-files.md`: Make targets, locked toolchain, generated - files, third-party artifacts, and safe verification paths. -- `containers-and-privileges.md`: three-container production boundary, - fixed-user/Unix-socket contracts, container validation, and rollback shape. -- `hardware-and-hil-safety.md`: typed hardware capability boundary, explicit - approval requirements, read-only HIL-0 allowance, sensitive-data handling, - and prohibited arbitrary device commands. - -### Documentation - -- `documentation-ownership.md`: `docs/README.md` map, canonical document - updates, ADR and active-plan rules, progressive disclosure, and checks. -- `privacy-and-publication.md`: public/private evidence boundary and safe - compatibility/troubleshooting publication flow. - -File names may be merged only if the implementing agent proves that separation -would duplicate the same rules. The three layer indexes are mandatory. - -## 4. Trellis-First Instruction Strategy - -`AGENTS.md` uses two ownership zones: - -1. The existing Trellis-managed block remains at the top and may be refreshed - by `trellis update`. -2. A short user-owned `Simplus Safety Boundaries` section follows the closing - marker and remains preserved across updates. - -The old repository guide is not copied back wholesale. Its documentation map, -product scope, architecture, privacy details, and validation conventions move -to the appropriate `core` specs. Only rules that must apply before task/package -selection stay in `AGENTS.md`: - -- do not publish credentials, private endpoints/topology, SIM/device identity, - raw HIL evidence, packet captures, or private troubleshooting material; -- require explicit user approval for real messaging/calls, RF changes, - modem-persistent writes, or HIL-1/2 actions; -- permit relevant read-only HIL-0 inspection, but never expose arbitrary - AT/QMI commands or device paths through Web/API boundaries; -- scope validation and repairs to the requested change; a failing check does - not authorize unrelated modifications. - -This keeps Trellis as the primary instruction system, avoids a duplicated -development guide, and preserves the small set of universal safety constraints -that must be visible even when no package spec has been selected yet. - -## 5. Context Flow - -The intended future flow is: - -```text -task create (no --package) - -> resolve_package() - -> package = core - -> planning reads get_context --mode packages - -> implement.jsonl/check.jsonl select relevant core/web specs - -> Codex SubagentStart hook injects manifests + task artifacts - -> trellis-implement writes - -> trellis-check reviews and fixes -``` - -An explicit `--package web` remains valid. Existing tasks are not migrated -automatically; the current special bootstrap task may retain `package: null` -because its curated manifests explicitly span configuration and both spec -areas. - -## 6. Compatibility and Template Updates - -- Do not rename `web`; existing paths and completed specs remain valid. -- Do not edit `.trellis/.template-hashes.json`. Local customization is expected - to be detected as user-modified by a future `trellis update`. -- Do not reinitialize. The local project files are the supported customization - surface. -- Do not add `session.spec_scope`; its current absence lets planning discover - all configured packages. Task manifests provide the precise injection scope. - -## 7. Validation and Rollback - -Validation must prove configuration behavior, content quality, hook syntax, -and repository health. The implementation plan lists exact commands. - -Rollback is file-local and does not touch application state: revert the -configuration stanza, remove the newly created `core` spec tree, and restore -the pre-repair Trellis-only `AGENTS.md`. No database, dependency, generated -product output, or runtime migration is involved. diff --git a/.trellis/tasks/archive/2026-08/00-bootstrap-guidelines/implement.jsonl b/.trellis/tasks/archive/2026-08/00-bootstrap-guidelines/implement.jsonl deleted file mode 100644 index cd8e2ad..0000000 --- a/.trellis/tasks/archive/2026-08/00-bootstrap-guidelines/implement.jsonl +++ /dev/null @@ -1,4 +0,0 @@ -{"file":".trellis/tasks/00-bootstrap-guidelines/research/trellis-bootstrap-repair.md","reason":"Verified package, repository, instruction, and hook evidence for the repair"} -{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Keep package/spec/context flow consistent across Trellis configuration, tasks, and Codex injection"} -{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"Preserve stable package keys and avoid duplicating project conventions across AGENTS and specs"} -{"file":".trellis/spec/web/frontend/index.md","reason":"Protect the completed Web package mapping and existing frontend spec contract"} diff --git a/.trellis/tasks/archive/2026-08/00-bootstrap-guidelines/implement.md b/.trellis/tasks/archive/2026-08/00-bootstrap-guidelines/implement.md deleted file mode 100644 index 75c5da5..0000000 --- a/.trellis/tasks/archive/2026-08/00-bootstrap-guidelines/implement.md +++ /dev/null @@ -1,104 +0,0 @@ -# Implementation Plan: Trellis Bootstrap Repair - -## 1. Reconfirm the baseline - -- [x] Snapshot `git status --porcelain=v1 -uall` and preserve all unrelated - user/init changes. -- [x] Re-read `prd.md`, `design.md`, the repair research note, current package - context, `AGENTS.md`, and Git `HEAD:AGENTS.md`. -- [x] Confirm `.codex/hooks.json`, `.codex/config.toml`, and hook scripts exist; - do not modify them without a demonstrated defect. - -## 2. Correct package configuration - -- [x] Add `core: {path: .}` while preserving `web: {path: web}`. -- [x] Change `default_package` from `@simplus/web` to `core`. -- [x] Do not add `session.spec_scope` or edit Trellis runtime/template hashes. - -Validation gate: - -```bash -python3 ./.trellis/scripts/get_context.py --mode packages --json -python3 -c 'from pathlib import Path; from importlib import import_module; import sys; sys.path.insert(0, ".trellis/scripts"); from common.config import resolve_package, validate_package; assert resolve_package(repo_root=Path.cwd()) == "core"; assert validate_package("web", Path.cwd())' -``` - -## 3. Keep `AGENTS.md` Trellis-first and retain only universal safety - -- [x] Preserve the current Trellis managed block exactly. -- [x] Do not restore `git show HEAD:AGENTS.md` verbatim. -- [x] Add one concise user-owned `Simplus Safety Boundaries` section outside - the managed block containing the privacy, explicit real-side-effect/HIL - approval, typed-device-boundary, and no-unrelated-repair constraints defined - in `design.md`. -- [x] Move the old guide's detailed documentation, product, architecture, - privacy, and validation knowledge into the relevant `core` specs. -- [x] Confirm there is exactly one Trellis block and one short safety section, - with no duplicated full repository guide. - -## 4. Bootstrap root specs - -- [x] Create `.trellis/spec/core/backend/` and its source-backed index/guides. -- [x] Create `.trellis/spec/core/infra/` and its source-backed index/guides. -- [x] Create `.trellis/spec/core/docs/` and its source-backed index/guides. -- [x] Use representative code/tests/config/docs from the paths catalogued in - `research/trellis-bootstrap-repair.md`. -- [x] Include actual patterns, anti-patterns, privacy/safety boundaries, and - reliable validation commands; remove all template prose. -- [x] Keep existing Web specs unchanged unless a verified cross-link defect - requires a narrow correction. - -## 5. Validate content and context wiring - -- [x] Confirm package output lists `core (default)` with `backend`, `docs`, and - `infra`, plus `web` with `frontend`. -- [x] Validate both task manifests with `task.py validate` / `list-context`. -- [x] Scan for placeholders and broken relative Markdown links. -- [x] Verify every cited repository path exists and every Markdown fence is - balanced. -- [x] Parse `.codex/hooks.json`, `.trellis/.template-hashes.json`, and relevant - task JSON; parse `.codex/config.toml` with Python's `tomllib`. -- [x] Syntax-check the Codex hook Python entry points without changing them. -- [x] Have the required `trellis-implement` and final full-scope - `trellis-check` agents report that injected task artifacts/manifests were - available. - -## 6. Run repository checks - -Run in increasing scope and stop to diagnose any attributable failure: - -```bash -make check-docs -corepack pnpm --dir web typecheck -make lint -make test -``` - -- [x] Report existing non-fatal jsdom warnings separately. -- [x] Do not repair unrelated application/test failures without new scope. - -Implementation evidence (2026-08-07): all four required commands passed. -Vitest completed 73 tests and emitted only non-fatal jsdom CSS parsing and -pseudo-element `getComputedStyle` warnings. The implement hook supplied all -four manifest entries followed by `prd.md`, `design.md`, and `implement.md`; -the final full-scope check hook likewise supplied all four check-manifest -entries in order followed by `prd.md`, `design.md`, and `implement.md`. - -## 7. Final review and handoff - -- [x] Re-read the complete diff for lost instructions, generic spec prose, - stale paths, duplicated sources of truth, or application changes. -- [x] Mark PRD acceptance boxes only after evidence supports them. -- [x] Run the required Trellis spec-sync judgment. The final review's new - knowledge is already captured in the owning specs: mixed secret/plaintext - storage in `core/backend/storage-and-migrations.md`, Compose candidate - maturity in `core/infra/containers-and-privileges.md`, and the required Web - entry-point checklists in `web/frontend/index.md`. No additional guide or - duplicate task-only rule is needed. -- [x] Present a scoped commit plan; do not stage, commit, archive, or push - without the workflow's explicit confirmation gates. - -## Rollback Point - -Before any commit, rollback consists only of restoring the four in-scope areas -listed in `design.md`; never use destructive Git commands or touch unrelated -uncommitted files. diff --git a/.trellis/tasks/archive/2026-08/00-bootstrap-guidelines/prd.md b/.trellis/tasks/archive/2026-08/00-bootstrap-guidelines/prd.md deleted file mode 100644 index 7f1ded8..0000000 --- a/.trellis/tasks/archive/2026-08/00-bootstrap-guidelines/prd.md +++ /dev/null @@ -1,125 +0,0 @@ -# Repair and Complete the Trellis Bootstrap - -## Goal - -Bring the local Trellis installation to the functional state expected from a -correct initialization: future work must resolve to a valid package, discover -the real project-specific specs for both the Go root and the Web workspace, and -retain the repository's pre-existing safety and development instructions. - -## Background and Confirmed Facts - -- The repository is a polyglot monorepo: `go.mod:1` defines the Go module at - the repository root, while `pnpm-workspace.yaml:1-2` declares `web/` as the - Node workspace. -- `.trellis/config.yaml:161-164` declares only the `web` package and sets - `default_package: @simplus/web`. Trellis validates a default against package - keys, so that npm package name does not resolve to the configured `web` key - (`.trellis/scripts/common/config.py:507-546`). -- Package context currently reports `web` with `default: false`; the bootstrap - task consequently has `package: null`. -- The seven source-backed Web frontend specs are complete and independently - checked. Web tests, type-check, docs checks, and lint passed. -- `trellis init` replaced the previous `AGENTS.md` content with the Trellis - managed block. The previous guide is recoverable from Git `HEAD`; most of - its detailed process guidance belongs in Trellis specs, while its privacy - and real-hardware authorization rules must remain visible before any - package-specific task context is selected. -- Codex project config, workflow-state hook, sub-agent context hook, and Trellis - agent definitions exist. The current session has demonstrated active-task - resolution and implement/check dispatch. - -## Requirements - -### R1. Correct package resolution - -- Keep the stable `web` package key mapped to `web/`. -- Add a stable `core` package key mapped to the repository root (`.`). -- Set `default_package: core` so an unspecified future task resolves to an - existing package key. -- Do not rename or move the existing Web spec tree. - -### R2. Bootstrap the root package specs from repository evidence - -Create project-specific, placeholder-free specs beneath `.trellis/spec/core/` -for these actual ownership layers: - -- `backend`: Go package layout, application/port boundaries, HTTP/OpenAPI - contracts, SQLite migrations/generated code, error handling, and testing. -- `infra`: build/release commands, containers and privilege separation, - hardware/HIL authorization boundaries, generated artifacts, and validation. -- `docs`: canonical documentation ownership, decision records, public/private - information boundaries, and documentation checks. - -Every material rule must cite representative source, test, config, or canonical -documentation paths. The specs must describe current behavior, including -known limitations, rather than inventing aspirational architecture. - -### R3. Keep a Trellis-first instruction entry point without losing safety - -- Preserve the current `<!-- TRELLIS:START -->` / `<!-- TRELLIS:END -->` - managed block exactly as the Trellis-owned section. -- Do not restore the prior Git `HEAD` guide verbatim. Replace its scattered - process and architecture guidance with the new package/layer specs. -- Add only a concise user-owned safety section outside the managed block so - future `trellis update` operations preserve it. It must keep these always-on - constraints: never publish sensitive hardware/network/SIM evidence; require - explicit approval for real SMS/calls/RF changes/device writes/HIL-1/2; allow - only relevant read-only HIL-0 inspection; never expose arbitrary AT/QMI or - device paths; and do not expand into unrelated fixes after a failed check. -- Put product scope, documentation ownership, detailed privacy rules, hardware - architecture, and executable validation conventions in `.trellis/spec/`. -- Keep `AGENTS.md` concise and Trellis-first rather than maintaining a second - complete development guide. - -### R4. Preserve and verify context injection - -- Keep the Codex hook and agent wiring aligned with the current Trellis - workflow; do not modify it unless a concrete defect is demonstrated. -- Curate this task's `implement.jsonl` and `check.jsonl` with real spec/research - entries so the repair itself exercises the normal sub-agent context path. -- Confirm future task/package resolution and spec discovery using the local - Trellis scripts, not by relying on visual inspection alone. - -### R5. Preserve unrelated work - -- Do not modify product source, tests, generated API/SQL code, dependencies, or - runtime data as part of this repair. -- Do not hand-edit `.trellis/.template-hashes.json` or `.trellis/.runtime/`. -- Do not re-run `trellis init` and do not modify the global Trellis package. -- Keep all pre-existing uncommitted initialization files unless a verified - repair explicitly targets them. - -## Acceptance Criteria - -- [x] All seven `.trellis/spec/web/frontend/` guides contain source-backed - conventions and real examples with no template placeholders. -- [x] The Web spec pass has passed independent review, 73 tests, type-check, - `make check-docs`, and `make lint`. -- [x] `get_context.py --mode packages --json` reports both `core` and `web`, - with `core` marked as the sole default. -- [x] Trellis package resolution returns `core` when no explicit package is - supplied and accepts `web` when explicitly selected. -- [x] `.trellis/spec/core/{backend,infra,docs}/index.md` exist, match their - final linked file sets, and all linked specs contain verified project paths, - examples, anti-patterns, and checks without placeholder prose. -- [x] `AGENTS.md` keeps the Trellis managed block as its primary content and - adds only the concise always-on Simplus safety section outside it; the old - repository guide is not restored verbatim and its detailed rules are covered - by the new root specs. -- [x] Codex hook JSON/TOML and Python entry points remain syntactically valid; - an implement and check sub-agent receive this task's artifacts/manifests. -- [x] Spec links, code fences, cited repository paths, and task manifests all - validate. -- [x] `make check-docs`, `make lint`, `make test`, and the Web type-check pass; - any pre-existing non-fatal warnings are reported rather than hidden. -- [x] No application source, dependency, generated product file, runtime state, - or template hash is changed by the repair. - -## Out of Scope - -- Fixing Trellis CLI upstream auto-detection or opening an upstream issue/PR. -- Product features, application refactors, dependency upgrades, database - migrations, hardware actions, HIL execution, deployment, or remote pushes. -- Reorganizing the existing Web source or rewriting the already reviewed Web - specs except for a narrowly required cross-link correction. diff --git a/.trellis/tasks/archive/2026-08/00-bootstrap-guidelines/research/trellis-bootstrap-repair.md b/.trellis/tasks/archive/2026-08/00-bootstrap-guidelines/research/trellis-bootstrap-repair.md deleted file mode 100644 index 73a8bb0..0000000 --- a/.trellis/tasks/archive/2026-08/00-bootstrap-guidelines/research/trellis-bootstrap-repair.md +++ /dev/null @@ -1,115 +0,0 @@ -# Trellis Bootstrap Repair Audit - -## Summary - -The Trellis runtime and Codex integration are present and operational, but the -auto-detected package configuration is internally inconsistent and covers only -the pnpm child workspace. The repair should preserve the working integration, -correct package metadata, add source-backed root specs, and recover the -repository rules displaced from `AGENTS.md`. - -## Verified Configuration Findings - -- `.trellis/config.yaml:161-164` declares package key `web` at path `web`, but - sets `default_package: @simplus/web`. -- `.trellis/scripts/common/config.py:495-504` validates a package by exact key. -- `.trellis/scripts/common/config.py:507-546` resolves the default only when it - exactly matches a configured key; there is no npm-name translation. -- `.trellis/scripts/common/packages_context.py:106-120` likewise marks a - package default only when `pkg_name == default_package`. -- Observed `get_context.py --mode packages --json` output reports monorepo mode, - one package named `web`, `default: false`, and - `defaultPackage: "@simplus/web"`. -- `.trellis/tasks/00-bootstrap-guidelines/task.json:9` records `package: null`. - -## Verified Repository Boundaries - -- `go.mod:1-3` defines `github.com/leonfox28/simplus` with Go 1.26 at the - repository root. -- `pnpm-workspace.yaml:1-2` defines `web` as the sole Node workspace. -- `web/package.json` owns the React/Umi package, TypeScript check, build, and - Vitest suite. Its Trellis frontend specs are already populated. -- `cmd/` owns executable assembly (`simplusd`, `simplus-agent`, - `simplus-netd`, control/HIL helpers). -- `internal/application/` contains typed application services and ports; - `internal/api/httpapi/` owns public HTTP/auth/timeout/error boundaries; - `internal/agentapi/` owns the bounded Unix hardware protocol. -- `internal/modemadapter/`, `internal/hardwareprobe/`, and - `internal/attransport/` implement the typed/fail-closed hardware boundary. -- `internal/storage/sqlite/`, its migrations, `sqlc.yaml`, and generated - packages define persistence ownership. -- `Makefile`, `Dockerfile`, `compose.yaml`, `containers/`, `packaging/`, and - `scripts/` define build, deployment, and HIL/release operations. -- `docs/README.md`, `docs/product.md`, `docs/architecture.md`, - `docs/development.md`, `docs/privacy-and-publication.md`, and - `docs/decisions/` are the canonical project knowledge sources. - -## Representative Evidence for Specs - -### Backend and contracts - -- `internal/application/messaging/service.go`: narrow repository/transport - ports, typed commands/results, serial gates, validation, and stable errors. -- `internal/api/httpapi/server.go`: OpenAPI server interface, narrow manager - ports, auth/CSRF, trusted-LAN authority checks, timeouts, panic recovery, and - JSON error mapping. -- `api/openapi.yaml`, `internal/api/openapi/generate.go`, and - `internal/api/openapi/spec_test.go`: API source-of-truth and generated drift - checks. -- `internal/agentapi/server.go` and protocol tests: bounded typed hardware - operations and peer/error handling. -- `internal/modemadapter/registry.go` plus registry/model tests: model adapters, - capability evidence, explicit endpoint roles, ambiguous-match rejection, - and fail-closed business capability advertisement. - -### Storage - -- `internal/storage/sqlite/store.go`, domain store files, and co-located tests: - transaction/store conventions and persistence behavior. -- `internal/storage/sqlite/migrations/**`: domain-scoped migration sequences. -- `internal/storage/sqlite/generated/**` and `sqlc.yaml`: generated ownership; - generated files must not be hand-edited. -- Privacy-sensitive identity and state behavior is defined in - `docs/architecture.md` and enforced by storage/modem tests. - -### Infrastructure and safety - -- `Makefile:45-231`: locked development, generation, docs, formatting, lint, - test, security, build, container, simulator, and hardware targets. -- `compose.yaml`, `Dockerfile`, `containers/*`, and - `internal/containercontract/contract_test.go`: three-image production and - privilege boundary contracts. -- `docs/development.md`: ordinary native development versus privileged/hardware - and release/HIL workflows. -- `docs/architecture.md`: control/agent/netd responsibility split, typed device - boundary, stable Line identity, and fail-closed rules. -- `docs/privacy-and-publication.md`: prohibited public artifacts and safe - publication flow. - -## Instruction and Hook Findings - -- Git `HEAD:AGENTS.md` contains the repository guide, product-scope exclusions, - sensitive-data restrictions, explicit approval requirements for real side - effects/HIL, and risk-proportional verification guidance. The user does not - want that scattered guide restored verbatim and delegated the retention - decision. Detailed guidance belongs in specs; privacy and real-side-effect - authorization remain universal safety constraints. -- Current `AGENTS.md` contains only the Trellis-managed block, even though that - block states content outside it is preserved on future updates. -- `.codex/hooks.json` registers `UserPromptSubmit` workflow-state injection and - `SubagentStart` context injection for the three Trellis agents. -- `.codex/config.toml` keeps `AGENTS.md` as the project instruction file and - caps agent recursion at one level. The current session demonstrates that the - workflow-state and active-task mechanisms are functioning. -- No evidence currently justifies changing `.codex/` hook or agent files. - -## Repair Constraints - -- Do not re-run initialization or edit Trellis upstream/global packages. -- Do not edit `.trellis/.template-hashes.json` or runtime session state. -- Do not modify application code while producing specs. -- Keep `web` stable; add `core` and default to it. -- Keep `AGENTS.md` Trellis-first; retain only a concise user-owned safety - section outside the generated block and migrate detailed knowledge to specs. -- Populate specs from the evidence above, then independently verify every - important claim against the underlying source. diff --git a/.trellis/tasks/archive/2026-08/00-bootstrap-guidelines/task.json b/.trellis/tasks/archive/2026-08/00-bootstrap-guidelines/task.json deleted file mode 100644 index 9b106e9..0000000 --- a/.trellis/tasks/archive/2026-08/00-bootstrap-guidelines/task.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "id": "00-bootstrap-guidelines", - "name": "00-bootstrap-guidelines", - "title": "Bootstrap Guidelines", - "description": "Fill in project development guidelines for AI agents", - "status": "completed", - "dev_type": "docs", - "scope": null, - "package": null, - "priority": "P1", - "creator": "Leon", - "assignee": "Leon", - "createdAt": "2026-08-07", - "completedAt": "2026-08-07", - "branch": null, - "base_branch": null, - "worktree_path": null, - "commit": null, - "pr_url": null, - "subtasks": [], - "children": [], - "parent": null, - "relatedFiles": [ - ".trellis/spec/web/" - ], - "notes": "First-time setup task created by trellis init (fullstack project)", - "meta": {} -} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-08/08-07-frontend-communication-refactor/check.jsonl b/.trellis/tasks/archive/2026-08/08-07-frontend-communication-refactor/check.jsonl deleted file mode 100644 index 830cff8..0000000 --- a/.trellis/tasks/archive/2026-08/08-07-frontend-communication-refactor/check.jsonl +++ /dev/null @@ -1,15 +0,0 @@ -{"_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."} -{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "复核 event->query、OpenAPI->generated、cursor->store->UI 全链路"} -{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "检查是否出现重复 transport、query key、错误映射和页面壳"} -{"file": ".trellis/spec/web/frontend/index.md", "reason": "核对完成后的 Web 栈、目录、状态与验证规范一致"} -{"file": ".trellis/spec/web/frontend/state-management.md", "reason": "核对后端权威、active-only invalidation、mutation 同步和敏感状态生命周期"} -{"file": ".trellis/spec/web/frontend/quality-guidelines.md", "reason": "核对页面、边界、响应式与 Playwright 测试质量"} -{"file": ".trellis/spec/web/frontend/type-safety.md", "reason": "核对 generated/Zod/handwritten cross-field validation 分工"} -{"file": ".trellis/spec/core/backend/api-contracts.md", "reason": "复核 SSE auth/timeout/headers、pagination errors 与 CSRF"} -{"file": ".trellis/spec/core/backend/storage-and-migrations.md", "reason": "复核 keyset 顺序、索引、Up/Down 和业务数据不丢失"} -{"file": ".trellis/spec/core/backend/quality-and-testing.md", "reason": "复核慢订阅者、并发插入、隐私和失败路径测试"} -{"file": ".trellis/spec/core/infra/build-and-generated-files.md", "reason": "复核 generated registry、Vite output、CI/audit 与 lockfile"} -{"file": ".trellis/spec/core/docs/documentation-ownership.md", "reason": "复核 ADR/architecture/active plan/spec 更新归属"} -{"file": ".trellis/spec/core/docs/privacy-and-publication.md", "reason": "复核没有身份、路径、正文、真实日志或 HIL 材料进入输出"} -{"file": "docs/product.md", "reason": "复核未扩大公网、多租户、远控或真实硬件副作用范围"} -{"file": "docs/architecture.md", "reason": "复核 typed boundary、state ownership 与静态 Web/API 交付仍成立"} diff --git a/.trellis/tasks/archive/2026-08/08-07-frontend-communication-refactor/design.md b/.trellis/tasks/archive/2026-08/08-07-frontend-communication-refactor/design.md deleted file mode 100644 index 8d9c101..0000000 --- a/.trellis/tasks/archive/2026-08/08-07-frontend-communication-refactor/design.md +++ /dev/null @@ -1,393 +0,0 @@ -# 前端与通信重构设计 - -## 1. 设计状态 - -- 状态:实现与全量验证已完成,等待归档。 -- 交付形态:一个同时包含 Web 静态资源和 `simplusd` API 的原子版本,没有新旧双栈兼容版本。 -- 已实现栈:Vite + React Router 8.3 Declarative Mode + 直接使用 `antd` 包 + TanStack Query;Umi Max 与全部 Pro Components 已移除。 - -## 2. 问题重述与设计原则 - -需要解决的不是“把 Umi 配置改写成另一份配置”,而是让一个单管理员局域网控制台拥有可读、显式、可升级的页面壳和通信边界。 - -基本事实: - -1. Go API 是持久状态唯一权威,浏览器只持有快照与交互状态。 -2. 需要实时性的内容是变化提示,不需要在浏览器中复制第二套事件真相源。 -3. Simplus 页面数量有限,不需要另一个后台元框架替代 Umi。 -4. API、Web、SQLite 和文档会同时变化,必须保留类型、安全和隐私边界。 - -因此采用以下原则: - -- 路由、provider、权限门禁、构建和代理均使用显式源码配置。 -- Ant Design 提供基础交互;项目薄组件只抽取至少有多个真实消费者的页面模式。 -- HTTP 继续承载查询和 mutation;SSE 只通知资源失效与少量需要关注的事件。 -- OpenAPI 生成 SDK、类型、Zod schema 和 TanStack Query options/keys;手写代码只保留 Simplus 特有的 transport、错误和跨字段验证。 -- mutation 不自动重试;有外部副作用的短信、电话和硬件动作继续依赖服务端幂等与明确用户意图。 - -术语说明:“直接使用 Ant Design”和“使用 Ant Design 基础组件”在本设计中完全等价,都是指从 `antd` 显式导入 `Layout`、`Menu`、`Drawer`、`Table`、`Form`、`Card` 等所需组件。不存在另一个“Ant Design 基础版”依赖。Ant Design Pro 是建立在 Ant Design 之上的后台脚手架/方案,Pro Components 则提供 ProTable、ProForm、ProLayout 等更高层封装;本任务移除后两者。 - -## 3. 总体架构 - -```text -Browser - └─ Vite-built React SPA - ├─ React Router (declarative routes + protected layout) - ├─ Ant Design App / ConfigProvider - ├─ TanStack QueryClient - ├─ generated OpenAPI SDK + Zod validation - └─ EventSource /api/v1/events - │ │ - │ JSON HTTP └─ invalidation/attention hints only - ▼ - simplusd httpapi - ├─ authenticated OpenAPI handlers - ├─ bounded in-memory realtime hub - ├─ application services - └─ SQLite / typed Agent / typed netd boundaries -``` - -SSE 不携带短信正文、电话号码、SIM/设备身份、硬件路径、诊断材料或命令。收到事件后,前端只让对应的 TanStack Query 失效,再通过现有鉴权 HTTP API 获取权威快照。 - -## 4. 前端工程结构 - -完成后的结构以实际边界为单位,没有泛化 store 或设计系统: - -```text -web/ -├── index.html -├── vite.config.ts -├── openapi-ts.config.ts -├── playwright.config.ts -├── src/ -│ ├── main.tsx -│ ├── app/ -│ │ ├── AppProviders.tsx -│ │ ├── AppRouter.tsx -│ │ ├── AppShell.tsx -│ │ ├── BootstrapGate.tsx -│ │ ├── RealtimeBridge.tsx -│ │ ├── auth.ts -│ │ └── navigation.tsx -│ ├── api/ -│ │ ├── generated/ # OpenAPI 生成;禁止手改 -│ │ ├── runtime.ts # fetch、cookie、CSRF、timeout、错误归一化 -│ │ ├── errors.ts -│ │ ├── events.ts # 事件 schema 解码与 query tag 映射 -│ │ ├── queryClient.ts -│ │ ├── session.ts -│ │ ├── setupClient.ts -│ │ └── hardwareSchema.ts # 继续拥有拓扑跨引用验证 -│ ├── components/Page.tsx # PageHeader/AsyncState/响应式集合薄组件 -│ ├── calls|messages|mihomo/ # 已有可复用非视觉领域逻辑 -│ ├── pages/ -│ ├── test/ -│ └── global.css -├── e2e/ -└── dist/ -``` - -约束: - -- 不保留 `web/config/`、`.umi/` 类型或 Umi runtime hooks。 -- 路由在 `AppRouter.tsx` 显式列出;没有文件系统路由或 React Router Framework Mode。 -- Vite 保持 `dist/` 输出、hash 静态资源和 `/api` 开发代理;代理连接 loopback API 但不改写浏览器的 trusted-LAN Host,使 setup completion 返回远端浏览器实际可达的管理地址。生产继续由 `cmd/simplusd/web.go` 承载 SPA fallback。 -- 使用现代 evergreen 浏览器基线,不增加 legacy/polyfill 插件;现有 React 19 + Ant Design 6 已经是现代浏览器栈。 - -## 5. 路由、认证与应用壳 - -### 5.1 路由 - -- 公共页面:`/login`、`/setup`,使用独立全屏壳。 -- 受保护页面:`/dashboard`、`/modems`、`/lines`、`/messages`、`/calls`、`/mihomo`、`/notifications`、`/settings`。 -- 未知路径重定向到 `/dashboard`;导航使用 `Link/NavLink/useNavigate`,不再手工 `pushState` 或派发 `popstate`。 - -### 5.2 BootstrapGate - -`BootstrapGate` 明确编排两类状态: - -1. 读取 setup status;需要初始化时允许 `/login` 与 direct `/setup`,普通根路径/受保护路径先 replace 到 `/login`。管理员登录成功后由后端签发受限 setup session,页面按缓存中的 setup status replace 到 `/setup`。 -2. 已初始化时,受保护路由读取管理员 session;无会话时 replace 到 `/login`。 - -全局 transport 只对受保护管理员请求的 401 发布内存内 `session-expired` 信号。`/api/v1/setup/*` 使用独立 setup cookie,其 401 保留为 setup 授权错误;`/api/v1/auth/login` 的 401 保留为凭据错误。Provider 收到真正的管理员 session 过期后取消并清空私有 query cache,再 replace 到登录页。403 CSRF 错误保留为操作错误,不误判为会话过期。 - -除 HTTPS/setup 完成后确实可能改变 origin 的流程外,普通登录、登出和导航不再使用 `window.location.replace`。 - -### 5.3 响应式 AppShell - -- 桌面:Ant Design `Layout.Sider + Header + Content`,固定扁平左侧导航。 -- 手机:Header + `Drawer` 导航;切页后自动关闭 Drawer,但不自动聚焦表单。 -- 已进入产品界面的不可用能力仍显示入口和禁用原因,不能因为后端未装配就让功能从导航消失。eUICC 是用户批准的暂缓例外:模组页在功能完整实现前不渲染其标签、提示、Profile 或操作,也不挂载 eUICC query;生成契约、后端能力与 realtime topic 保留,供后续一次性恢复完整功能。 -- `PageHeader`、统一空/错/加载状态和响应式集合是项目薄组件;业务字段、动作和表单保持在 feature/page 内。 -- 桌面数据密集页使用 `Table`;手机使用 Card/List 形态。确需宽表时只在表格容器内滚动,页面根节点不得横向溢出。 -- 继续使用当前 Ant Design token 和中文优先文案;本任务不更换品牌或视觉组件库。 - -## 6. OpenAPI 生成与请求边界 - -### 6.1 生成链 - -`api/openapi.yaml` 仍是唯一公共契约源。`@hey-api/openapi-ts` 配置生成: - -- Fetch client; -- SDK 与 TypeScript 类型; -- Zod v4 request/response/definition schemas; -- TanStack Query v5 query keys、query options、mutation options 和游标接口的 infinite-query options。 - -生成目录加入 `Makefile` 的 `GENERATED_PATHS`,`make generate` 和 `make verify-generated` 同时覆盖 Go 与 Web 输出。旧的 `openapi-typescript -> schema.d.ts` 路径和 1,072 行手写 operation client 在迁移完成后删除。 - -官方依据: - -- Hey API Fetch client 支持 runtime config、interceptor、response validation 和原始 Response 访问:<https://heyapi.dev/docs/openapi/typescript/clients/fetch> -- Zod 插件生成 request、response 与 reusable definition schema:<https://heyapi.dev/docs/openapi/typescript/plugins/zod> -- TanStack Query 插件生成 query keys/options,并可从分页参数生成 infinite options:<https://heyapi.dev/docs/openapi/typescript/plugins/tanstack-query> - -### 6.2 手写 runtime 的唯一职责 - -`src/api/runtime.ts` 负责: - -- 相对同源 `/api/v1` 请求和 `credentials: same-origin`; -- mutation 从 `simplus_csrf` cookie 附加 `X-Simplus-CSRF`; -- 合并 TanStack Query 的 `AbortSignal` 与操作超时; -- 把 transport/HTTP/invalid-response/timeout/abort 统一为 `ApiClientError`; -- 按请求 path 区分授权域,只在受保护管理员请求 401 时通知 session boundary; -- 保留原始 HTTP status、稳定 `code`、`retryable` 和可选 `reference`。 - -页面不得调用 `fetch`,不得读取未经生成 schema/专用 guard 验证的网络 JSON。生成 Zod 负责结构与 OpenAPI 约束;`hardwareSchema.ts` 继续负责唯一 ID、引用关系、generation 和 capability subset 等 OpenAPI 无法表达的拓扑不变量。 - -### 6.3 错误显示 - -`ApiClientError` 至少包含: - -```text -kind: transport | http | invalid-response | timeout | aborted -code: stable uppercase code -retryable: boolean -status?: number -reference?: string -``` - -共享中文映射只翻译稳定 code;未知错误显示通用说明和可选 reference,不显示 `err.Error()`、响应正文或浏览器网络异常文本。 - -## 7. TanStack Query 状态模型 - -### 7.1 全局策略 - -- Query 最多对网络错误或 `retryable=true` 做两次指数退避;401、403、4xx、invalid-response 不重试。 -- Mutation 默认 `retry: false`。短信、拨号、RF、VoWiFi、eUICC 和通知测试均不得自动重放。 -- Query function 必须把 `signal` 传入生成 SDK;卸载、换筛选和登出会取消请求。 -- SSE 到达后只 `invalidateQueries({refetchType: 'active'})`;未挂载页面只标记 stale,不产生隐藏页流量。 -- 不使用 localStorage/sessionStorage 持久化 query cache、会话、IMEI 或任何业务数据。 -- 操作进度、Modal/Drawer、草稿和已揭示 IMEI 等临时状态留在页面本地。 - -### 7.2 查询与失效矩阵 - -| 资源 | 权威查询 | Query 身份 | 主动刷新/失效 | -| --- | --- | --- | --- | -| Setup | `getSetupStatus` | operation key | setup mutation 后;应用启动 | -| Session | `getAuthSession` | operation key | 登录/登出/改密;401 清除 | -| Health | `getSystemHealth` | operation key | 页面进入、窗口重新聚焦、`system` | -| Managed Modems | `listManagedModems` | operation key | add/RF 后窄更新或失效;`inventory`,`modems` | -| Modem candidates | `listModemCandidates` | operation key | 打开添加对话框;`inventory` | -| Managed Lines | `listManagedLines` | operation key | add/update 后;`inventory`,`lines` | -| Line candidates | `listLineCandidates` | operation key | 打开添加对话框;`inventory`,`lines` | -| Egress/VoWiFi | 各自 operation key | 按 Line 或列表 | mutation、10 秒 reconcile 变化、`vowifi`,`mihomo` | -| Messages | infinite key + `lineId/remoteAddress` filter | 筛选和游标参数 | send/delete/background sync、`messages` | -| Calls | infinite key | 游标参数 | dial/action/incoming、`calls` | -| Contacts | list operation key | operation key | create/update/delete、`contacts` | -| Mihomo | core/config/runtime/subscription 各自 key | operation + path/query | 相应 mutation、`mihomo`;运行态页面可有有界 fallback poll | -| Notifications | list operation key | operation key | CRUD/test、`notifications` | - -生成 query key 是具体请求身份;SSE topic 到 query tag/prefix 的映射集中在 `api/events.ts`,页面不得各自维护一套事件解释。 - -## 8. 游标分页契约 - -### 8.1 Messages - -`GET /api/v1/messages` 增加: - -- `limit`:默认 20,范围 1–50; -- `cursor`:可选、版本化的有界 opaque base64url 值; -- `lineId` 与 `remoteAddress`:必须同时出现或同时省略,用于一条会话的历史。 - -响应保留 `messages/totalCount/capacity/nearCapacity`,增加可选 `nextCursor`。列表固定以 `(created_at_unix_ms DESC, message_id DESC)` 排序。读取 `limit + 1` 条决定是否产生下一游标,不使用 offset。 - -messages 数据库追加迁移,为全局历史与 `(line_id, remote_address)` 会话历史建立与排序完全一致的索引;Down 只删除新索引,不改业务记录。 - -### 8.2 Calls - -`GET /api/v1/calls` 同样接受 `limit/cursor`,响应 `calls` 加可选 `nextCursor`,固定以 `(created_at_unix_ms DESC, call_id DESC)` 排序。calls 数据库追加对应索引迁移。 - -### 8.3 Cursor 规则 - -- 游标只编码版本、时间与稳定业务 ID,不包含电话号码、正文或硬件身份。 -- 解码、版本、长度或 ID 不合法返回 `PAGE_CURSOR_INVALID` / 400。 -- 相同时间戳由稳定 ID 决定唯一顺序;并发插入不会让翻页重复或跳过游标之前的记录。 -- 前端用生成的 infinite-query options 实现“加载更多”,SSE 失效时重新校准第一页;不把游标持久化到浏览器存储。 - -## 9. SSE 实时失效通道 - -### 9.0 方案比较 - -| 方案 | 适合点 | 本项目中的取舍 | -| --- | --- | --- | -| 定时轮询 | 实现最少 | 来电要低延迟时必须缩短周期,会让 Messages/Calls/VoWiFi 等页面持续重复请求;仅保留为窄运行态故障降级 | -| SSE / EventSource | 标准单向 server push、文本事件、浏览器自动重连、沿用同源 HTTP/cookie | **采用**;当前需求正是后端通知浏览器“资源变了”,所有写操作仍走 HTTP | -| WebSocket | 双向、高频、可传二进制 | 当前浏览器没有必须经长连接上行的动作;引入后会重复 HTTP 已有的鉴权、错误、幂等和 mutation 协议 | -| Fetch streaming/自定义长轮询 | 可完全自定义 status/header/帧 | 能力有用但需要自建更多客户端协议与重连逻辑;当前 EventSource 足够 | - -因此 SSE 是当前约束下复杂度、延迟与可靠性的最佳折中,不是所有未来场景的绝对答案。若以后出现高频双向控制,才重新评估 WebSocket;浏览器通话音频也应走专用媒体机制,而不是 SSE。 - -### 9.1 公共契约 - -新增鉴权 `GET /api/v1/events`,Content-Type 为 `text/event-stream`。OpenAPI 声明流 endpoint,并定义可生成类型与 Zod schema 的 `RealtimeEvent`: - -```text -topics: 1..N of system | inventory | modems | lines | vowifi | - messages | calls | contacts | mihomo | notifications | euicc -attention?: sms.received | call.incoming -``` - -Payload 不含业务正文。SSE frame 可以有进程内递增 `id`,但不提供 durable replay;断线重连后服务端先发一个 ready/resync frame,前端使所有当前活跃查询失效,因此漏掉中间 hint 不会造成持久错误状态。 - -### 9.2 Hub 与背压 - -- `internal/application/realtime` 提供有界进程内 publisher/subscriber。 -- 发布操作不得阻塞 HTTP mutation、SMS 同步或 runtime reconcile。 -- 相邻 topic 可合并;subscriber 落后时退化为一次全量 `resync`,而不是无限堆积事件。 -- `attention` 只控制当前浏览器的提示;即使被合并/丢弃,资源 topic 仍保证重新查询,业务记录不会丢失。 - -### 9.3 连接生命周期 - -- EventSource 使用同源管理员 cookie;GET 不要求 CSRF。 -- handler 在建立连接时通过 `requireBusinessAPI`,并在 heartbeat 周期重新验证 session;失效后关闭流。 -- 每 15 秒发送注释 heartbeat,并建议客户端 3 秒重连。 -- `apiTimeout` 对 `/api/v1/events` 明确 bypass,因为现有 `bufferedResponse` 无法 flush;其他 JSON endpoint 继续保留原超时。 -- `simplusd` HTTP server 不再使用会截断长连接的全局 WriteTimeout;JSON endpoint 仍由 `apiTimeout` 控制,SSE handler 自己控制 heartbeat、认证和 request context。 -- 断开、页面隐藏、登出或 provider 卸载必须关闭 EventSource;重连错误触发一次 session probe,不进行高频重连风暴。 - -### 9.4 事件来源 - -| 来源 | 发布 topic/attention | -| --- | --- | -| Modem/Line/RF/egress/VoWiFi HTTP mutation 成功 | 对应 `modems`,`lines`,`inventory`,`vowifi` | -| Agent `/v1/changes` 长轮询观察 generation 变化 | `inventory`,`modems`,`lines` | -| SMS 后台 sync 有持久状态变化 | `messages`;新入站另带 `sms.received` | -| Message send/delete | `messages` | -| Call dial/incoming/action | `calls`;新入站另带 `call.incoming` | -| Host VoWiFi reconcile 周期或显式动作 | `vowifi` | -| Mihomo/notification/contact/eUICC mutation | 对应 topic | - -只有“新短信”和“来电”触发前端明显提示;普通配置/状态变化静默刷新。未来真实电话 backend 接入时必须从同一 typed publisher 发布,不能另造 Web 协议。 - -## 10. 页面迁移 - -### 10.1 首个核心切片 - -首批按 `Modems -> Lines -> Messages` 迁移,因为三者共同验证: - -- 共享 Managed Line/Modem query; -- mutation 后的缓存同步; -- 动态 inventory 与 VoWiFi 状态; -- 消息游标与 SSE 后台同步; -- 桌面表格和手机卡片两种布局; -- capability-driven、IMEI 临时展示和不可用原因等安全边界。 - -### 10.2 后续页面 - -在核心切片稳定后迁移 Calls、Dashboard、Mihomo、Notifications、Settings、Setup 和 Login。每个页面必须显式覆盖 loading、empty、error、disabled、mutation busy 和会话失效状态;不把旧的一行式页面机械搬入新壳。 - -## 11. 迁移与发布策略 - -本任务保留一个父任务和一个原子发布,不拆成可独立发布的子任务:OpenAPI、生成器、lockfile、AppShell 和共享 query keys 会被多个交付项同时修改,平行子任务会制造冲突和临时兼容层。实现可按下面检查点顺序执行,但不在 production 同时运行两套前端或通信层: - -1. 记录依赖/产物基线,新增 superseding ADR 与契约测试骨架。 -2. 建立 Hey API 生成、runtime、错误模型和 QueryClient;保持旧页面暂可构建。 -3. 增加分页、realtime hub/SSE 与后端测试;在同一检查点刷新生成物。 -4. 用 Vite/React Router/Ant Design 建立 AppShell、门禁和响应式基础组件。 -5. 迁移核心切片并删除其旧手工 load/poll;验证 HTTP+SSE 全链路。 -6. 迁移剩余页面,删除所有 Pro/Umi import、旧 client 与旧配置。 -7. 增加浏览器回归、更新构建/CI/开发脚本、规范与公共文档,完成依赖审计和 before/after 记录。 - -每个检查点均保持 typecheck/test/build 可运行。最终镜像仍把 `web/dist` 与匹配的 `simplusd` 一起发布;不承诺旧浏览器静态资源连接新 API。 - -## 12. 回滚 - -- 数据库只新增分页索引,Down migration 不删除业务数据。 -- Vite 仍输出同一路径,Go 静态文件承载契约不变。 -- 发布回滚使用上一完整镜像/提交,并在需要时执行索引 Down migration;不在运行时保留 Umi fallback。 -- SSE 连接失败时,页面仍可通过首次查询、窗口聚焦、显式刷新以及 Mihomo/VoWiFi 的有界 active-page fallback poll 工作;这只是故障降级,不是取消 SSE 验收。 -- mutation 返回不确定结果时不自动重发;短信和电话继续依赖 operation ID、历史查询和用户明确重试。 - -## 13. 验证设计 - -### Backend - -- realtime hub:topic 合并、慢 subscriber、取消、无阻塞和 resync。 -- SSE handler:setup/auth gate、cookie、headers、flush、heartbeat、session 失效、disconnect、payload privacy。 -- pagination:空页、边界、相同时间戳、非法 cursor、filter 组合、并发插入、数据库 reopen 与 Up/Down migration。 -- handler:OpenAPI query/response、stable errors、mutation 发布和 background SMS 发布。 - -### Frontend - -- runtime:CSRF、cookie、timeout、abort、401、retryable 与 malformed success response。 -- event bridge:schema rejection、topic->query mapping、active-only invalidation、reconnect resync、attention 去重和 cleanup。 -- Query/mutation:取消、无 mutation auto-retry、返回对象窄更新、跨页面共享 cache。 -- 页面:现有 Login/Modems/Lines 测试继续覆盖原安全行为;补齐 Messages、Calls、Dashboard、Mihomo、Notifications、Settings、Setup。 -- Playwright Chromium:桌面与手机 viewport 的登录后导航、Drawer、核心 Modem/Line/Message 流、局部表格滚动、无全局横向溢出和无意外 autofocus。 - -### Gates - -```bash -corepack pnpm --dir web test -corepack pnpm --dir web typecheck -corepack pnpm --dir web build -corepack pnpm --dir web e2e -go test ./internal/application/realtime ./internal/api/httpapi ./internal/storage/sqlite -make verify-generated -make check-format -make lint -make test -make security -make build -make check-docs -``` - -普通验证不接触真实设备,不发送短信/电话,不修改 RF 或模组持久状态。 - -## 14. 依赖与安全验收 - -- 移除 `@umijs/max`、`@umijs/plugins`、所有 `@ant-design/pro-*` 和其引入的第二套 Ant Design/React Router/SWR。 -- Vite、React Router、Hey API、Zod、TanStack Query、Ant Design 和 Playwright 均作为用途明确的直接依赖,并由 lockfile 固定。 -- `make security` 继续以 moderate 为 CI 门槛;完整树和 production tree 均不得有 high/critical。 -- audit ignore 必须包含 advisory、适用范围和复查条件;能通过升级消除的项不得长期 ignore。 -- 同口径结果:完整依赖树从约 1,578 项降到 268,production tree 从约 1,486 项降到 73;`web/dist` 保持 29 个文件并从 3,004,106 bytes 降到 1,387,882 bytes(约减少 53.8%)。production/full audit 均为零 advisory,且没有 audit ignore。 - -## 15. 文档与规范 - -实现必须: - -- 新增 `docs/decisions/0022-...md`,明确 supersede ADR 0009 中的 Umi/Pro 决定,保留 React/Ant Design、单栈和 API 安全决定。 -- 更新 `docs/architecture.md` 的运行图、Web 层和 HTTP/SSE 数据流。 -- 在唯一 active plan 中增加本次里程碑,并更新审计技术债状态。 -- 更新 `docs/development.md` 中 Umi 名称与 Vite 命令,但不复制架构全文。 -- 刷新 `.trellis/spec/web/frontend/**` 以及受影响 backend/build/docs spec,使其描述完成后的真实代码而不是旧 Umi 现状。 - -## 16. 主要风险 - -| 风险 | 缓解 | -| --- | --- | -| 一次换壳与换数据层导致回归面大 | 核心切片优先、每阶段 green、保留已有页面测试并新增浏览器测试 | -| SSE 被 timeout/代理缓冲截断 | endpoint bypass buffered timeout、heartbeat、真实 flush 测试、同源 Vite 代理 smoke | -| 事件漏失或 subscriber 堵塞业务 | 事件仅为 hint、ready/resync、active query 重取、hub 有界且不阻塞 | -| 生成 schema 不能表达拓扑引用约束 | 保留并收敛 `hardwareSchema.ts` 的跨字段验证 | -| mutation 重试造成真实副作用 | 全局 mutation retry=false、operation ID、未知结果专用提示 | -| 分页在相同时间戳下重复/漏项 | `(timestamp,id)` 稳定排序、limit+1、边界/并发测试、匹配索引 | -| 新工具重新扩大依赖树 | 只加入有明确验收价值的直接依赖,记录 dependency/build before-after,并维持审计门禁 | - -## 17. 明确延期 - -- WebSocket、GraphQL、gRPC-Web、离线写队列和 service worker。 -- SSR/SSG、React Router Framework Mode 和面向公网的跨域访问。 -- 多管理员、多租户、角色权限与跨浏览器通话租约。 -- 浏览器通知权限、系统级推送和后台常驻提醒;本轮只做当前页面内提示。 -- 真实硬件短信/电话/eUICC/RF/HIL;本任务只使用 deterministic fixture、Simulator 和只读代码检查。 diff --git a/.trellis/tasks/archive/2026-08/08-07-frontend-communication-refactor/implement.jsonl b/.trellis/tasks/archive/2026-08/08-07-frontend-communication-refactor/implement.jsonl deleted file mode 100644 index 1b6c6a0..0000000 --- a/.trellis/tasks/archive/2026-08/08-07-frontend-communication-refactor/implement.jsonl +++ /dev/null @@ -1,20 +0,0 @@ -{"_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."} -{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "约束 OpenAPI、SSE、Query、SQLite 与 UI 的跨层数据流和单一解码边界"} -{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "防止各页面重复定义 query key、错误映射、事件解码与响应式壳"} -{"file": ".trellis/spec/web/frontend/index.md", "reason": "Web 包总规范与迁移后需要同步刷新的索引"} -{"file": ".trellis/spec/web/frontend/directory-structure.md", "reason": "指导 Vite/Router/app/api/features/pages 的真实目录归属"} -{"file": ".trellis/spec/web/frontend/component-guidelines.md", "reason": "保留响应式、可访问性、敏感字段和 capability-driven UI 约束"} -{"file": ".trellis/spec/web/frontend/hook-guidelines.md", "reason": "将手写 load/poll 迁移到 Query/SSE 时保护取消与隐藏页行为"} -{"file": ".trellis/spec/web/frontend/state-management.md", "reason": "保持后端权威、本地交互状态和敏感数据不持久化"} -{"file": ".trellis/spec/web/frontend/quality-guidelines.md", "reason": "指导 Vitest、Testing Library、浏览器回归与现有安全行为覆盖"} -{"file": ".trellis/spec/web/frontend/type-safety.md", "reason": "保持 OpenAPI-first、runtime validation 与跨字段 topology guard"} -{"file": ".trellis/spec/core/backend/api-contracts.md", "reason": "新增分页和 SSE 时保持 auth、CSRF、timeout、stable error 与 OpenAPI-first"} -{"file": ".trellis/spec/core/backend/storage-and-migrations.md", "reason": "指导 messages/calls keyset index migration、Down 与 reopen 测试"} -{"file": ".trellis/spec/core/backend/quality-and-testing.md", "reason": "指导 httptest、SQLite、并发、隐私和 targeted-to-broad 验证"} -{"file": ".trellis/spec/core/infra/build-and-generated-files.md", "reason": "更新生成路径、Vite 构建、Make/CI 与可复现依赖"} -{"file": ".trellis/spec/core/docs/documentation-ownership.md", "reason": "确保 superseding ADR、architecture、active plan 与开发文档各归其主"} -{"file": ".trellis/spec/core/docs/privacy-and-publication.md", "reason": "防止 SSE、测试 fixture、文档或浏览器材料泄露私有通信和硬件信息"} -{"file": "docs/product.md", "reason": "确认可信 LAN、单管理员、业务术语、显式用户意图和非目标"} -{"file": "docs/architecture.md", "reason": "保持 Web/API 到 typed hardware/network boundary 的依赖方向与机械不变量"} -{"file": "docs/decisions/0009-ant-design-pro-web.md", "reason": "新 ADR 必须准确 supersede 旧 Umi/Pro 决定而不改写历史"} -{"file": "docs/plans/active/mvp.md", "reason": "把原子迁移纳入唯一 active cross-layer plan 并更新审计技术债"} diff --git a/.trellis/tasks/archive/2026-08/08-07-frontend-communication-refactor/implement.md b/.trellis/tasks/archive/2026-08/08-07-frontend-communication-refactor/implement.md deleted file mode 100644 index 3daf1f5..0000000 --- a/.trellis/tasks/archive/2026-08/08-07-frontend-communication-refactor/implement.md +++ /dev/null @@ -1,316 +0,0 @@ -# 前端与通信重构实施计划 - -## 1. 执行约束 - -- 本文件是规划,不代表已获准实现。只有用户审批最终规划后才运行 `task.py start`。 -- 最终交付是一个原子 Web/API 版本;工作包是可验证检查点,不是独立 production 发布物。 -- 不运行真实短信、电话、RF、eUICC、Host VoWiFi 或任何 HIL-1/HIL-2;本任务的验证限于单元测试、httptest、SQLite 临时库、浏览器 fixture 和 Simulator。 -- `api/openapi.yaml`、迁移和生成器配置是源文件;禁止手改 generated Go/TypeScript。 -- 开始每个工作包前检查工作树并保留用户已有修改。修改依赖版本、常量、route、生成路径或 Make target 前先全仓搜索。 -- 子代理只能在满足依赖后领取不重叠的文件所有权;lockfile、OpenAPI、生成目录、AppShell 和共享 runtime 由单一 owner 串行修改。 - -## 2. 工作包与依赖 - -### WP0 — 建立基线与 durable decision - -依赖:无。 - -- [x] 记录 `pnpm list/audit/why`、`web/dist` bytes/files、现有测试和构建结果。 -- [x] 新增 ADR 0022,supersede ADR 0009 的 Umi Max/Pro Components 决定,但保留 React 19、Ant Design、单一前端栈、cookie/CSRF 和同源静态承载。 -- [x] 在 active MVP plan 中登记新里程碑和原子迁移边界。 -- [x] 在 `api/openapi.yaml` 与 `Makefile` 修改前确认当前生成物无 drift。 - -验证: - -```bash -git status --short -make verify-generated -corepack pnpm --dir web test -corepack pnpm --dir web typecheck -corepack pnpm --dir web build -``` - -回滚点:纯文档/基线;不改变运行行为。 - -### WP1 — 定义分页与 realtime OpenAPI 契约 - -依赖:WP0。 - -- [x] 为 Messages/Calls 添加 `limit`、`cursor` 和 `nextCursor`;Messages 添加成对的 `lineId/remoteAddress` filter。 -- [x] 定义统一 cursor 参数边界和 `PAGE_CURSOR_INVALID` 错误响应。 -- [x] 新增 `RealtimeTopic`、`RealtimeAttention`、`RealtimeEvent` 和 `GET /api/v1/events` 的 `text/event-stream` 契约。 -- [x] 确认所有新增 schema `additionalProperties: false`、数组/字符串有界,且不包含通信正文、号码、硬件身份或路径。 -- [x] 生成 Go/Web 契约并检查 diff;生成 Web client 的目录/文件一次性登记到 `GENERATED_PATHS`。 - -验证: - -```bash -make generate -make verify-generated -go test ./internal/api/openapi -``` - -回滚点:OpenAPI 与生成物一起回退;不得只回退一侧。 - -### WP2 — 实现 SQLite keyset pagination - -依赖:WP1 的最终 cursor/response contract。 - -- [x] 在 messages 追加 migration,建立全局与 `(line_id, remote_address)` 的 `(created_at_unix_ms DESC, message_id DESC)` 索引。 -- [x] 在 calls 追加 migration,建立 `(created_at_unix_ms DESC, call_id DESC)` 索引。 -- [x] 实现版本化、长度受限的 opaque cursor encode/decode;共享算法放在最窄的可复用层,不让 HTTP、service 和 store 各自解析。 -- [x] Repository/Service 接口接收 typed page request 并返回 items/next cursor;删除内部固定 50/100 假分页。 -- [x] HTTP handler 校验 Messages filter 必须成对出现,并映射 stable 400 error。 -- [x] 覆盖同毫秒 ID tie-break、第二页、末页、非法/旧版本 cursor、filter、并发前插、reopen、Up/Down migration。 - -验证: - -```bash -go test ./internal/storage/sqlite -go test ./internal/application/messaging ./internal/application/calls -go test ./internal/api/httpapi -make verify-generated -``` - -风险文件/边界: - -- `internal/storage/sqlite/messages.go`, `calls.go`, 两个 dataset migrations; -- application repository interface 的所有 fake; -- `api/openapi.yaml` 与 generated server interface。 - -回滚点:Down migration 只删除索引,不删除业务数据。 - -### WP3 — 实现 realtime hub、SSE 与发布源 - -依赖:WP1;可在 WP2 完成后合并验证,但不得与同一 OpenAPI/generated owner 并行。 - -- [x] 新建有界 realtime hub:typed topics、合并、慢 subscriber resync、取消和无阻塞 publish。 -- [x] 实现 SSE handler:业务 API gate、管理员 cookie、headers、initial resync、JSON schema、heartbeat、flush、session 周期复验和 disconnect cleanup。 -- [x] 让 `apiTimeout` 仅对 events endpoint bypass;调整 `simplusd` 全局 WriteTimeout,使 JSON 仍由 endpoint timeout 保护而 SSE 不被截断。 -- [x] HTTP mutation 成功后发布对应 topic;发布发生在权威操作成功后、写响应前后语义保持一致。 -- [x] `runSMSSync` 在持久状态变化时发布 `messages`,新入站附 `sms.received`。 -- [x] hardware backend 使用 Agent `/v1/changes` 有界长轮询发布 `inventory/modems/lines`;异常退避且不改变 Agent 权限或 API。 -- [x] Host VoWiFi reconcile 和现有 Mihomo/Call/Contact/Notification/eUICC mutation 接入相同 publisher。 -- [x] 测试 payload 无正文、号码、身份、路径或 raw error;测试 slow client 不阻塞业务操作。 - -验证: - -```bash -go test ./internal/application/realtime -go test ./internal/api/httpapi -go test ./cmd/simplusd -make check-format -make lint -``` - -风险文件/边界:`internal/api/httpapi/server.go` 的 timeout/recovery 顺序、`cmd/simplusd/main.go` 的 goroutine 生命周期、session 复验与 server shutdown。 - -回滚点:Hub/SSE 可作为一个提交整体回退;不能只恢复全局 WriteTimeout 而留下 stream endpoint。 - -### WP4 — 建立生成 Web client、runtime 与 QueryClient - -依赖:WP1;与 WP2/WP3 合并生成物后执行。 - -- [x] 添加固定版本的 Hey API、Fetch client、Zod v4 与所需 TanStack Query generator 配置;输入只使用本地 `../api/openapi.yaml`。 -- [x] 生成 types/SDK/Zod/query keys/options/infinite options,加入 drift gate。 -- [x] 实现唯一 runtime:same-origin credentials、CSRF、AbortSignal、operation timeout、ApiClientError 与 401 signal。 -- [x] 把现有 `hardwareSchema.ts` 接到生成结构 schema 之后,保留跨引用验证;删除重复的手写 API response 类型。 -- [x] 配置 QueryClient query retry 与 mutation no-retry;实现集中 error translation。 -- [x] 实现 EventSource decoder、topic-to-query mapping、initial resync、attention 去重、reconnect/session probe 和 cleanup。 -- [x] 为现有页面暂时提供最薄 compatibility facade,只允许作为本任务内迁移桥;最终 WP9 必须删除。 - -验证: - -```bash -corepack pnpm --dir web generate:api -corepack pnpm --dir web test -corepack pnpm --dir web typecheck -make verify-generated -``` - -风险文件/边界:`web/src/api/**`、生成 config、Makefile `GENERATED_PATHS`、root lockfile。该工作包由一个 owner 独占。 - -### WP5 — 用 Vite/React Router 建立应用壳 - -依赖:WP4 的 runtime/QueryClient contract。 - -- [x] 新增 Vite config:React plugin、`@` alias、`dist`、hash assets、`VITE_API_PROXY_TARGET` `/api` proxy(含 SSE streaming)。 -- [x] 新增 `main.tsx`、AppProviders、BootstrapGate、AppRouter、AppShell 与集中 navigation metadata。 -- [x] 使用 Ant Design `ConfigProvider/App/Layout/Menu/Drawer` 重建桌面与手机导航、用户菜单和 logout。 -- [x] 用 Router replace/navigation 替代 Umi initialState/layout hooks 和手写 history/popstate。 -- [x] 迁移 Login/Setup 门禁但保留 password-manager attributes、setup fragment/session 和必要 origin reload。 -- [x] 更新 Vitest config/types、`run-sim.sh`、supervisor test 文案与启动参数,使它们不再假设 Umi。 -- [x] 保持 `web/dist` 和 `cmd/simplusd/web.go` SPA fallback contract。 - -验证: - -```bash -corepack pnpm --dir web test -- app Login Setup -corepack pnpm --dir web typecheck -corepack pnpm --dir web build -make test-dev-sim -``` - -风险文件/边界:认证 redirect loop、setup 未初始化状态、Vite host/port、SSE proxy、生产 deep-link fallback。 - -回滚点:Vite shell 与启动脚本同一提交回退;不要留下 `max dev` 或 `.umi` type include。 - -### WP6 — 建立最小 Ant Design 页面原语 - -依赖:WP5。 - -- [x] 从现有多页面使用提炼 `PageHeader`、`AsyncState` 和 AppShell;没有两个以上消费者的业务 UI 不抽象。 -- [x] 定义桌面 table / 手机 card-list 的响应式约定、局部横向滚动和 action placement。 -- [x] 定义统一 loading/empty/error/partial/disabled/busy 视觉状态。 -- [x] 保持当前 token、中文文案、可访问 label、confirmation 与 sensitive value 约束。 -- [x] 用真实 DOM 测试 desktop/mobile navigation 和 Drawer cleanup。 - -验证: - -```bash -corepack pnpm --dir web test -- AppShell -corepack pnpm --dir web typecheck -``` - -### WP7 — 迁移核心切片 Modems -> Lines -> Messages - -依赖:WP2、WP3、WP4、WP5、WP6 全部完成。 - -#### Modems - -- [x] 用 Ant Design Table/Card/Modal/Form 替换 PageContainer/ProTable/ProDescriptions 等。 -- [x] Managed Modems 与 candidates 使用共享 generated queries;candidate 只在对话框打开时查询。 -- [x] RF mutation 只按返回对象窄更新/失效;IMEI 仍显式读取、默认隐藏、hide/reload/unmount 清除。 -- [x] 保留 unsupported/unknown fail-closed、型号读取失败不回退和候选不自动添加测试。 - -#### Lines - -- [x] 共享 Managed Lines、egress、VoWiFi queries;配置项仍分别 mutation,不合并身份/RF/出口/VoWiFi。 -- [x] SSE/reconcile 替代手写 interval;只让 VoWiFi runtime query 在该页 active 时更新。 -- [x] 保留 candidate reason、未配置出口禁用激活、partial runtime failure、端口/身份不泄漏测试。 - -#### Messages - -- [x] 使用 infinite query 展示 recent history/会话并支持加载更多;会话查询传成对 filter。 -- [x] Contacts 和 Managed Lines 使用共享 cache,不再每 5 秒重复加载。 -- [x] send/delete/contact mutation 做精确 cache update 或失效;发送/删除失败必须保留可见状态。 -- [x] 只在 `sms.received` attention 显示页面内提示;普通状态变化静默。 -- [x] 网络结果未知时提示刷新历史且不要重复发送,不自动 retry mutation。 - -每个子切片验证: - -```bash -corepack pnpm --dir web test -- Modems -corepack pnpm --dir web test -- Lines -corepack pnpm --dir web test -- Messages -corepack pnpm --dir web typecheck -``` - -并行规则:三页 UI 在共享 API/query contract 冻结后可由不重叠 owner 处理;`api/events.ts`、generated files、global CSS 与共享 components 仍由 foundation owner 串行合并。 - -### WP8 — 迁移其余页面 - -依赖:WP7 已验证核心模式。 - -- [x] Calls:游标加载、incoming attention、action state、DTMF/媒体 fixture、安全号码错误;mutation 不自动重试。 -- [x] Dashboard:health/topology queries、partial error 与响应式 Statistic/Card。 -- [x] Mihomo:拆分 core/config/runtime/subscription queries,保留不回退 direct 和长操作进度。 -- [x] Notifications:typed Form/Table/Card、CRUD/test 错误、credential 不回显。 -- [x] Settings:密码校验、成功后 session cache 清除与 replace 登录。 -- [x] Setup/Login:补齐初始化、错误、session 与导航回归。 -- [x] 按用户批准的暂缓例外隐藏模组页 eUICC 标签、提示、Profile 和操作,并确保桌面/紧凑视图不发起 eUICC 请求;后端/OpenAPI 能力保留。 - -验证:对每个页面运行 focused Vitest,然后运行全 Web test/typecheck/build。 - -并行规则:页面文件及其 feature/test 可在 WP7 pattern 冻结后分配给不同 owner;不允许各页建立自有 fetch、错误类、query-key taxonomy 或 responsive framework。 - -### WP9 — 删除旧栈与迁移桥 - -依赖:WP8 所有页面均已使用目标边界。 - -- [x] 删除所有 `@umijs/max`、`@ant-design/pro-components` imports、`web/config/`、Umi locales/runtime hooks 和 compatibility facade。 -- [x] 删除旧手工 operation client/重复 guards,只保留 Simplus 特有跨字段 guard。 -- [x] 从 package/lock/workspace overrides 中移除仅由 Umi/Pro 需要的依赖和 reviewed exceptions;逐条用 `pnpm why` 确认,不盲删共享安全 override。 -- [x] 检查不存在 Umi、Pro Components、Ant Design 4、React Router 6、SWR 第二状态层或页面直接 fetch。 -- [x] 记录最终 `pnpm list/audit/why`、依赖总数、`web/dist` bytes/files 与基线差异。 - -验证: - -```bash -rg -n "@umijs|max |@ant-design/pro-|\.umi|ProTable|ProForm|ProLayout" web package.json pnpm-workspace.yaml pnpm-lock.yaml -rg -n "fetch\(" web/src --glob '*.{ts,tsx}' -corepack pnpm why @umijs/max @umijs/plugins @ant-design/pro-components antd react-router swr -corepack pnpm audit --prod --audit-level high -corepack pnpm audit --audit-level high -corepack pnpm --dir web test -corepack pnpm --dir web typecheck -corepack pnpm --dir web build -``` - -期望:前两类 legacy 搜索无命中(唯一 fetch owner 除外),audit 为 0 high/critical,无重复 Ant Design 主版本。 - -### WP10 — 浏览器回归、CI、文档与规范 - -依赖:WP9。 - -- [x] 添加 Playwright Chromium desktop/mobile fixtures;API 用脱敏 deterministic route fixtures,不需要真实硬件或外部通信。 -- [x] 覆盖登录后导航、Drawer、Modem/Line/Message 核心路径、load more、SSE invalidation、全局 overflow 和 autofocus。 -- [x] 为 E2E 建立明确 Make target/CI step;不让缺少浏览器的普通 targeted unit test 隐式下载工具。 -- [x] 更新 `docs/architecture.md`、active plan、`docs/development.md` 和 ADR 链接。 -- [x] 重写 `.trellis/spec/web/frontend/**` 为 Vite/Router/Antd/Query 的真实完成后规范;同步 backend API/storage/testing、infra generated/build 和 docs spec 中受影响的事实。 -- [x] 运行 docs privacy 检查,确认没有真实通信、身份、路径、拓扑、日志或截图材料。 - -验证: - -```bash -corepack pnpm --dir web e2e -make check-docs -make test-worktree-manifest -``` - -### WP11 — 全量质量门与交付检查 - -依赖:WP0–WP10 全部完成。 - -- [x] 运行 targeted checks 后再运行全量 gates。 -- [x] 检查 `git diff` 中 source/generated/lock/docs 一致,迁移有 Down,旧栈无残留。 -- [x] 核对所有 PRD acceptance criteria,并记录未运行的环境型检查及原因。 -- [x] 由独立 check agent 检查 spec compliance、跨层数据流、错误/会话/SSE、隐私、重复依赖与测试质量。 - -```bash -make verify-generated -make check-format -make lint -make test -make security -make build -make check-docs -corepack pnpm --dir web e2e -git status --short -``` - -禁止把 container/HIL 命令作为本任务普通 gate;Docker build 只有在实际修改 Docker build contract 且环境可用时才追加。 - -### Runtime follow-up — 无头开发机 Setup/Login 循环 - -- [x] 根因归类为跨层契约与集成测试缺口:setup-session 的正常 401 被全局 interceptor 当成管理员 session 过期;两个 route guard 因而互相重定向。 -- [x] 第二根因归类为迁移传播遗漏:旧登录页允许安装器预置管理员在 `uninitialized` 状态登录,并由后端签发 setup session;新门禁遗漏了这条首次登录路径。 -- [x] 按请求 path 区分 setup、rejected login 与 protected administrator 401,并恢复首次登录与 direct setup 两条 setup-required 公共路径。 -- [x] Vite `/api` proxy 保留浏览器 LAN Host,避免 setup completion 把远端浏览器重定向到 loopback API target。 -- [x] 添加 runtime、Login/真实 SetupPage route、Vite config 和实际 proxy Host 回归;无头浏览器从 1.5 秒 154 次循环导航收敛为有界启动导航,普通入口稳定停在 `/login`,direct setup 稳定停在 `/setup`。 -- [x] 将授权域、route-loop 与 dev proxy authority 约束写入 Web/infra code-spec 和 cross-layer checklist。 - -### Runtime follow-up — 桌面 Header 用户区错位 - -- [x] 根因归类为隐式布局假设与测试缺口:desktop 保留空 leading Flex,`space-between` 未把唯一可见的账号动作推到右侧;原测试只覆盖点击导航。 -- [x] 只在 compact 模式渲染 leading group,并用独立 action group 的 `margin-inline-start: auto` 明确右对齐。 -- [x] 桌面/手机 Playwright 分别验证 24px/12px 右边距、动作不越界和无全局 overflow;组件测试验证可访问账号入口及 Drawer 清理。 -- [x] Brand 按用户确认统一为单行 `Simplus`,删除 `LAN Control Center` 副标题。 - -## 3. 完成定义 - -- [x] 所有 PRD acceptance criteria 可指向测试、生成检查、审计输出或可观察 UI 行为。 -- [x] `design.md` 的 query/event/cursor/auth 数据流与实现一致。 -- [x] 旧 Umi/Pro/手写通信层已删除,没有长期双栈。 -- [x] 生产静态 Web 与 API 仍由同一 Simplus release 原子交付。 -- [x] 没有执行未经授权的真实通信或硬件副作用。 diff --git a/.trellis/tasks/archive/2026-08/08-07-frontend-communication-refactor/prd.md b/.trellis/tasks/archive/2026-08/08-07-frontend-communication-refactor/prd.md deleted file mode 100644 index 5562086..0000000 --- a/.trellis/tasks/archive/2026-08/08-07-frontend-communication-refactor/prd.md +++ /dev/null @@ -1,138 +0,0 @@ -# 重构前端与前后端通信 - -## Goal - -把 Simplus Web 重构为一个依赖更少、边界显式、易升级的单一管理后台,并让页面通过统一、类型安全且可测试的数据层获得及时的后端状态。 - -用户价值:管理员在桌面和手机上都能稳定完成模组、线路、短信、电话及其他日常管理;状态变化无需依靠粗粒度整页轮询;依赖升级不再被 Umi/Pro Components 的重复框架链和旧路由版本阻塞。 - -## Background and Evidence - -### Product boundary - -- 产品是可信局域网内、单管理员使用的通信控制后台,中文优先;主要页面包括概览、模组、线路、短信、电话、Mihomo、通知和系统设置(`docs/product.md:5`, `web/config/routes.ts:1`)。 -- 后端是持久业务状态的唯一权威;浏览器只保存页面快照和交互状态(`.trellis/spec/web/frontend/state-management.md:5`)。 -- Web 只能使用 Modem、SIM、Line、Message、Call 等业务术语,不能暴露任意 AT/QMI、设备路径、Agent 协议或私有身份(`docs/architecture.md:261`)。 -- 2026-08-04 的 ADR 0009 将 Web 一次性迁移到 React、Umi Max、Ant Design 和 Pro Components,并删除旧 Vue 双栈(`docs/decisions/0009-ant-design-pro-web.md:8`)。本任务会以新 ADR supersede 其中的 Umi/Pro 决定,但保留 React、Ant Design、单栈、cookie/CSRF 和 Go 静态承载边界。 - -### Current frontend and communication - -- 当前前端是 React 19 + Umi Max + Ant Design 6 + Pro Components;`@tanstack/react-query` 已安装且插件已启用,但业务源码没有 query、mutation、query key 或统一缓存(`web/package.json:13`, `web/config/config.ts:10`)。 -- Umi 的源码耦合主要位于 `web/config/config.ts`、`web/config/routes.ts` 和 `web/src/app.tsx`;业务页面没有直接导入 `@umijs/max`。Pro Components 则覆盖 10 个页面,主要使用 PageContainer、Card、Table、Form、Descriptions、LoginForm 和 StatisticCard。 -- `web/src/api/client.ts` 是 1,072 行手写边界:类型部分来自生成的 `schema.d.ts`,但 endpoint、method、错误归一化和大量成功响应 guard 由手工维护(`web/src/api/client.ts:4`, `web/src/api/client.ts:88`, `web/src/api/client.ts:369`)。 -- OpenAPI 3.0 是公共 HTTP 契约源并生成 Go server 与 TypeScript schema;当前没有生成浏览器 operation client(`api/openapi.yaml:1`, `web/package.json:8`)。 -- API 已使用同源 cookie session、double-submit CSRF、稳定 `ApiError {code,retryable,reference?}` 和 `no-store`;前端目前把服务端错误压缩成普通 `Error(code)`,没有保留 retryable、status 或 reference(`web/src/api/client.ts:72`, `internal/api/httpapi/server.go:47`, `internal/api/httpapi/server.go:485`, `api/openapi.yaml:2690`)。 -- 页面以 `useState/useEffect/useCallback` 各自持有服务端快照;Line 的 VoWiFi 运行态与短信页每 5 秒轮询(`web/src/pages/Lines.tsx:112`, `web/src/pages/Lines.tsx:150`, `web/src/pages/Messages.tsx:12`)。仓库没有浏览器 SSE/WebSocket。 -- Managed Lines 会被线路、短信和电话页重复读取;mutation 后通常整页 `load()`,没有共享失效约定(`web/src/pages/Lines.tsx:117`, `web/src/pages/Messages.tsx:12`, `web/src/pages/Calls.tsx:7`)。 -- 认证门禁只在 Umi 初始化/切页集中处理;普通业务请求的 401 没有统一恢复或跳转(`web/src/app.tsx:11`, `web/src/api/client.ts:113`)。 -- 页面质量不均:Lines、Modems、Mihomo 较完整,Calls、Notifications、Settings 和部分 Messages 仍是密集页面代码,若干异步失败缺少页面级反馈(`web/src/pages/Calls.tsx:7`, `web/src/pages/Notifications.tsx:8`, `web/src/pages/Settings.tsx:5`)。 - -### Dependency and security evidence (2026-08-07) - -- `pnpm why` 显示当前同时存在 Pro Components `3.1.14-6` 与 `2.8.10`、Ant Design `6.5.3` 与 `4.24.16`。第二套由 `@umijs/max -> @umijs/plugins` 引入;Vite 也被 Umi bundler 间接控制。 -- 直接使用的 Pro Components `3.1.14-6` 属于 npm `beta` 标签,用于 Ant Design 6,并额外依赖 `@umijs/route-utils`、SWR、Emotion、DnD Kit 等;npm `latest` 仍为只声明 Ant Design 4/5 peer 的 2.8.10。 -- 当前 production 与完整 audit 均为 0 high、0 critical,但仍有 3 low、4 moderate。production tree 约 1,486 项,完整 tree 约 1,578 项;根工作区维护 11 条安全 override 和 4 条带适用范围说明的 advisory ignore。提交 `1eb1cbc` 专门修补了这条审计依赖链。 -- Umi 4 固定 React Router 6;当前被忽略的 Router advisory 中存在要求升级新主版本或没有 6.x 修复版本的情况。直接依赖新受支持主版本可解除框架约束。 -- 迁移前 `web/dist` 有 29 个文件、约 3,004,106 bytes;这些数字作为迁移后的同口径对比基线,而不是孤立的硬性大小指标。 - -### Current verification - -- `corepack pnpm --dir web test` 通过:10 个测试文件、73 个测试;存在已知 jsdom CSS/伪元素告警。 -- `corepack pnpm --dir web typecheck` 通过。 -- 页面测试当前覆盖 Login、Modems、Lines 和布局菜单;Dashboard、Messages、Calls、Mihomo、Notifications、Settings、Setup 尚无各自完整页面测试。 -- `internal/api/httpapi/server.go:284` 把所有 API 包在 buffered timeout 中,`internal/api/httpapi/server.go:380` 的 writer 不能流式 flush;`cmd/simplusd/main.go:267` 的 server WriteTimeout 也会截断长连接。SSE 实现必须明确处理这两个边界。 -- Messages/Calls 当前固定取最近 50/100 条。两库已有 created-at + stable-ID 索引;会话筛选还需要 messages 的 `(line_id, remote_address, created_at, message_id)` 匹配索引。 - -## In Scope - -1. 将 Web 工程栈替换为 Vite + React Router Declarative Mode + 直接使用 `antd` 包 + TanStack Query,移除 Umi Max 和全部 Pro Components。这里的“直接使用 Ant Design”与此前所称“Ant Design 基础组件”是同一方案,不存在额外的基础版包。 -2. 用显式 AppProviders、BootstrapGate、AppRouter 和响应式 AppShell 重建公共/受保护路由、setup/auth 门禁、桌面侧栏和手机 Drawer。 -3. 迁移全部现有页面;首个验证切片依次为 Modems -> Lines -> Messages,随后迁移 Calls、Dashboard、Mihomo、Notifications、Settings、Setup、Login。 -4. 由 OpenAPI 生成 Fetch SDK、TypeScript types、Zod schemas 和 TanStack Query keys/options/infinite options;保留必要的 Simplus transport、错误和 topology 跨字段验证。 -5. 以 TanStack Query 统一 server snapshot、取消、retry、cache、mutation 同步和跨页面失效;不引入 Redux/Zustand 或第二套服务端状态库。 -6. 新增鉴权 SSE `/api/v1/events`,只发送资源失效 topic 和新短信/来电 attention hint;HTTP 继续承载权威查询与 mutation。 -7. 为 Messages/Calls 增加稳定 keyset cursor pagination;Messages 支持按 Line + remote address 会话筛选。 -8. 补齐 backend、API、storage、frontend boundary/page 和 Playwright desktop/mobile 自动化回归。 -9. 更新 Make/CI/开发脚本、generated drift、安全审计、superseding ADR、architecture、active plan、development docs 和 Trellis specs。 - -## Out of Scope - -- 用 Refine、React Admin、Next.js 或另一后台元框架替代 Umi;切换到另一套视觉组件库或进行品牌重设计。 -- WebSocket、GraphQL、gRPC-Web、SSR/SSG、React Router Framework Mode、service worker、离线写队列或持久化浏览器 cache。 -- 公网/CORS 产品化、多管理员、多租户、角色权限、系统级浏览器通知或后台常驻推送。 -- 新增或验证真实蜂窝短信、电话、eUICC、RF、Host VoWiFi 或其他 HIL 能力。 -- 修改 Agent/netd 为任意命令边界,暴露硬件路径、通信身份、原始日志/抓包或私有拓扑。 - -## Key Decisions - -- **Frontend stack**:已确认 Vite + React Router Declarative Mode + 直接使用 `antd` + TanStack Query;页面显式导入 `Layout/Table/Form/Card` 等所需组件,彻底移除 Umi/Pro Components,不以另一个元框架替代。 -- **Visual/UX continuity**:继续使用 Ant Design 视觉语言、中文优先、常见扁平左侧导航;桌面表格与手机 Card/List 双形态,宽表只在自身容器滚动。 -- **Capability visibility**:已进入产品界面的未装配/不可用功能通常保持可见,并显示禁用原因;eUICC 是用户明确批准的暂缓例外,在功能完整实现前不在模组页显示标签、提示、Profile 或操作,也不由该页面发起 eUICC 查询,但保留后端与 OpenAPI 能力边界。 -- **Communication**:HTTP snapshot/mutation + authenticated SSE invalidation。SSE 是 hint,不是业务数据或真相源;断线重连以 resync + HTTP refetch 收敛。 -- **Attention behavior**:只有新短信和来电产生明显页面内提示;普通配置/运行态变化静默刷新。 -- **Data client**:Hey API + Fetch + Zod + TanStack Query 生成链;生成结构验证后仍执行必要的 topology cross-reference guard。 -- **Pagination**:Messages/Calls 使用 `(createdAt, stable ID)` opaque cursor,不使用 offset;Messages 的会话 filter 必须成对提供 Line 与 remote address。 -- **API compatibility**:允许在 `/api/v1` 内调整当前契约;Web 与 `simplusd` 原子交付,不发布兼容旧 Web 的双 API 或长期双通信层。 -- **Migration order**:全面、分阶段、每阶段可验证;核心切片 Modems -> Lines -> Messages 优先,最终覆盖所有页面并删除旧栈。 -- **Safety**:mutation 默认不自动 retry;短信/电话/硬件动作继续依赖 operation ID、服务端幂等、未知结果提示和明确用户意图。 - -## Requirements - -- **R1 — Explicit frontend platform**:使用已确认目标栈并直接从 `antd` 导入所需 UI 组件;构建、路由、provider、auth gate 和 proxy 均为显式直接依赖/源码;不得保留 Umi、Pro Components、重复 Ant Design 主版本或未使用的第二状态库。 -- **R2 — Responsive usable UI**:所有主要工作流在桌面和手机均可完成;无全局横向溢出、导航错位、切页意外 autofocus/弹键盘;加载、空、错误、部分失败、禁用和 busy 状态可见。 -- **R3 — Preserve product/security boundaries**:保持后端权威、OpenAPI-first、cookie session、CSRF、稳定错误码、trusted-LAN 和 typed hardware boundaries;不把底层或私有信息暴露给 Web/SSE。 -- **R4 — Generated typed data boundary**:公共 operation、types、request/response schema 和 query identity 从 OpenAPI 生成;页面不得直接 fetch、复制 API model 或自行解析相同 payload。 -- **R5 — Consistent request semantics**:取消、超时、401、403、离线、retryable error、invalid success response 和 mutation feedback 有统一可测试行为;用户不直接看到内部 code/raw error。 -- **R6 — Backend-authoritative synchronization**:每类 server state 有唯一 query identity、刷新条件和 mutation 同步规则;SSE 只使 active queries 失效,隐藏页不因事件产生无意义请求。 -- **R7 — Realtime robustness**:SSE 鉴权、heartbeat、session 复验、disconnect cleanup、bounded backpressure 和 reconnect resync 明确;慢浏览器不能阻塞业务 mutation/background sync。 -- **R8 — Stable pagination**:Messages/Calls 的 cursor、排序、filter、索引和响应一致;相同时间戳、并发新记录、非法 cursor 和末页行为确定。 -- **R9 — Maintainable feature boundaries**:复杂页面按真实消费者拆分 page/feature/query/view;共享错误、query keys、event decode、AppShell 和响应式模式只有一个 owner,避免空洞抽象。 -- **R10 — Verifiable atomic migration**:每个实施检查点保持 test/typecheck/build;最终不长期维护新旧栈/通信层,Web/API/迁移有明确回滚点。 -- **R11 — Security and documentation gate**:完整树与 production tree 均为 0 high/critical;较低等级 ignore 有 advisory、适用范围与复查条件;durable ADR、architecture、active plan、development docs 和 Trellis specs 与完成后代码一致。 - -## Acceptance Criteria - -### Stack and build - -- [x] `rg`/`pnpm why` 证明源码、package 和 lockfile 不再包含 Umi Max、Umi plugins、Pro Components、`.umi` 或 Ant Design 4;没有页面直接调用 `fetch`。 -- [x] Vite dev/build 使用现有 host/port/proxy 环境,输出 `web/dist`;生产 `simplusd` 仍能承载根路径、hashed assets 和任意 SPA deep link。 -- [x] desktop Sider、mobile Drawer、公共 Login/Setup 和受保护 routes 均有可观察导航测试;不可用入口仍显示原因。暂缓的 eUICC 例外在桌面/手机模组页均不显示且不发起查询。 -- [x] 最终记录依赖树与构建产物 before/after;依赖显著收敛且无重复 UI/router/server-state 主栈。 - -### API, state and errors - -- [x] OpenAPI 一次生成 Go server contract、Fetch SDK、types、Zod schema 和 Query options/keys;`make verify-generated` 覆盖全部生成路径且无 drift。 -- [x] 每类 server state 在 `design.md` 的查询矩阵中有唯一 owner、identity、freshness 与 mutation/SSE 失效规则;跨页面 Managed Lines/Modems 读取复用 cache。 -- [x] query cancellation 进入 fetch AbortSignal;GET 只在 network/`retryable=true` 时有界 retry,mutation 全局不自动 retry。 -- [x] ApiClientError 保留 kind/code/retryable/status/reference;401 清除私有 cache 并 replace 登录,403 保持操作错误;invalid success、timeout、offline 均有统一中文行为。 -- [x] IMEI 仍默认隐藏、显式读取、hide/reload/unmount 清除;浏览器 storage 不保存 session、cache、身份或业务数据。 - -### SSE and background updates - -- [x] `/api/v1/events` 通过 setup/admin/trusted-LAN gate,正确设置 `text/event-stream`/`no-store`,可 flush heartbeat,并绕过 buffered JSON timeout 而不取消其他 endpoint timeout。 -- [x] Event payload 只含 bounded topic/attention,不含短信正文、号码、SIM/设备身份、路径、命令、raw error 或诊断材料。 -- [x] Modem/Line mutation、Agent generation、SMS background sync、Call/VoWiFi/Mihomo/Contact/Notification/eUICC 变化发布正确 topic;新短信/来电之外的变化不弹提示。 -- [x] slow subscriber、断线、session 失效、服务重启和丢失 hint 均通过 bounded hub + cleanup + reconnect resync 收敛;业务写入不被 stream 阻塞。 -- [x] 旧 Messages 5 秒全页轮询被移除;VoWiFi/Mihomo fallback 只作用于挂载中的窄运行态 query,hidden/unmounted 页面无周期流量。 - -### Pagination and UI flows - -- [x] Messages/Calls 支持默认/最大 limit、next cursor、加载更多、空/末页和非法 cursor stable error;同时间戳及并发前插不会造成边界重复/漏读。 -- [x] Messages 会话 filter 仅接受 Line + remote address 成对组合,并使用匹配复合索引;migration Up/Down/reopen 不丢业务记录。 -- [x] Modems -> Lines -> Messages 核心桌面/手机流程覆盖 candidate、不可用原因、mutation、部分失败、cursor 和 SSE 更新。 -- [x] Calls、Dashboard、Mihomo、Notifications、Settings、Setup、Login 均有页面级回归;Playwright 验证 desktop/mobile 无全局 overflow、Drawer 错位或意外 autofocus。 - -### Quality, security and docs - -- [x] Web test/typecheck/build/E2E、backend targeted tests、`make verify-generated/check-format/lint/test/security/build/check-docs` 通过;已知非致命 jsdom warning 单独报告。 -- [x] production 与完整 pnpm audit 均为 0 high/critical;`make security` 的 moderate gate 通过,剩余 ignore 均有可审查理由。 -- [x] 新 ADR supersede 0009 的 Umi/Pro 决定;architecture、唯一 active plan、development docs 和 `.trellis/spec/**` 描述新的真实架构。 -- [x] 所有测试仅使用 synthetic fixture、临时 SQLite、httptest、Playwright mock 或 Simulator;未执行真实短信/电话、RF、持久 modem 写或 HIL。 - -## Risks and Deferred Items - -- 换壳与换数据层回归面大:通过核心切片、阶段 green、existing security tests 和浏览器回归缓解。 -- SSE 与当前 timeout/write deadline 冲突:必须先用 handler/server 测试证明 flush、heartbeat、shutdown,再接入页面。 -- 生成 schema 不表达所有跨引用不变量:保留 `hardwareSchema.ts`,不以 Zod 结构验证替代领域 guard。 -- 新工具本身增加依赖:只引入能取代手写复杂度或提供关键回归的直接依赖,并以同口径 before/after 和 audit 验证。 -- 系统级浏览器通知、durable event replay、SSR、离线写入和真实硬件验证延期;它们不影响本次管理后台 MVP 行为。 diff --git a/.trellis/tasks/archive/2026-08/08-07-frontend-communication-refactor/task.json b/.trellis/tasks/archive/2026-08/08-07-frontend-communication-refactor/task.json deleted file mode 100644 index 2dd972a..0000000 --- a/.trellis/tasks/archive/2026-08/08-07-frontend-communication-refactor/task.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "id": "frontend-communication-refactor", - "name": "frontend-communication-refactor", - "title": "重构前端与前后端通信", - "description": "", - "status": "completed", - "dev_type": null, - "scope": null, - "package": "core", - "priority": "P2", - "creator": "Leon", - "assignee": "Leon", - "createdAt": "2026-08-07", - "completedAt": "2026-08-10", - "branch": null, - "base_branch": "main", - "worktree_path": null, - "commit": null, - "pr_url": null, - "subtasks": [], - "children": [], - "parent": null, - "relatedFiles": [], - "notes": "", - "meta": {} -} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-ordering/check.jsonl b/.trellis/tasks/archive/2026-08/08-10-sms-conversation-ordering/check.jsonl deleted file mode 100644 index cd791d1..0000000 --- a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-ordering/check.jsonl +++ /dev/null @@ -1,9 +0,0 @@ -{"file":".trellis/tasks/08-10-sms-conversation-ordering/research/repository-evidence.md","reason":"Review against the confirmed root cause and approved sequence/cursor design rather than timestamp heuristics."} -{"file":".trellis/spec/core/backend/storage-and-migrations.md","reason":"Check lossless v7/v8 migration, unread foreign keys, AUTOINCREMENT, and index/query alignment."} -{"file":".trellis/spec/core/backend/api-contracts.md","reason":"Check SMS cursor versioning, Calls isolation, OpenAPI/generated consistency, errors, and SSE privacy."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Check concurrency, rollback/reopen, failure-path, and broad backend evidence quality."} -{"file":".trellis/spec/web/frontend/hook-guidelines.md","reason":"Check server-owned ordering, infinite pages, scroll anchors, refetch, and automatic read-state behavior."} -{"file":".trellis/spec/web/frontend/quality-guidelines.md","reason":"Check Vitest/Playwright coverage across desktop/mobile without real communication."} -{"file":".trellis/spec/core/docs/privacy-and-publication.md","reason":"Audit the diff for private live data, raw troubleshooting evidence, screenshots, and identities."} -{"file":".trellis/spec/core/infra/hardware-and-hil-safety.md","reason":"Confirm no real SMS, RF, modem-persistent, or HIL action was used as a gate."} -{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Trace the record sequence end to end and detect competing timestamp ordering owners."} diff --git a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-ordering/design.md b/.trellis/tasks/archive/2026-08/08-10-sms-conversation-ordering/design.md deleted file mode 100644 index 2db2f3c..0000000 --- a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-ordering/design.md +++ /dev/null @@ -1,152 +0,0 @@ -# 修复短信会话时间乱序技术设计 - -## 1. 设计状态与边界 - -- 状态:规划完成,等待用户审批本最终规划后才可 `task.py start`。 -- 交付形态:一个原子跨层修复,包含 messages SQLite v8、SMS 专用 cursor、store/service 查询、Web 排序消费、迁移与浏览器回归、ADR/架构规范,以及用户批准后的本地容器更新。 -- 不拆父子任务:排序事实、分页边界、会话摘要与 Web 必须在同一版本切换,拆开会产生互相不兼容的中间状态。 -- 不改变公共 `SMSMessage` 字段、短信 transport、发送/入站状态机、unread token、SSE payload、Line 或会话身份。 -- 普通验证不发送真实短信,不触碰 RF、模组持久状态或 HIL。 - -## 2. 问题模型与目标事实 - -当前数据流把两个不同含义的时间放进同一个排序键: - -```text -出站 createdAt = Simplus 本机在发送副作用前创建持久记录的时间(毫秒) -入站 createdAt = 模组/运营商提供的 receivedAt(可能整秒、偏钟、延迟同步) -``` - -两者适合展示“业务声称的发生时间”,却不能比较本机实际先看到哪条记录。新的唯一排序事实是 `recordSequence`:messages SQLite 在一条短信首次成功插入时分配的全局单调序号。 - -```text -出站请求 -> 先 INSERT/分配 sequence -> transport side effect -> status update(sequence 不变) -入站同步 -> transaction 内 INSERT/分配 sequence + unread marker -> commit -> ACK -查询/SSE refetch -> 后端按 sequence newest-first -> Web 反转为 oldest-first -``` - -SQLite 单写者与 `AUTOINCREMENT` 共同保证成功提交的首次插入具有稳定顺序,删除不会复用旧序号。随机 message ID、运营商时间精度、状态更新时间和系统时钟回拨都不参与新记录先后判断。 - -## 3. Messages schema v8 - -### 3.1 `sms_messages` 表 - -v8 原子重建 `sms_messages`: - -- 新增 `record_sequence INTEGER PRIMARY KEY AUTOINCREMENT`; -- `message_id` 改为 `TEXT NOT NULL UNIQUE`,继续是所有公共 API、operation、provider 去重和 unread 外键使用的稳定业务 ID; -- 其余 direction、Line、remote、正文、状态与时间约束保持 v7; -- 表不再使用 `WITHOUT ROWID`,因为整数主键就是持久排序事实。 - -新索引与查询顺序一致: - -- 全局:主键 `record_sequence` 反向扫描; -- remote-only:`(remote_address, record_sequence DESC)`; -- Line + remote:`(line_id, remote_address, record_sequence DESC)`; -- inbound provider 去重索引保持; -- 旧 created-time page indexes 删除,避免保留第二套排序暗示。 - -### 3.2 历史回填 - -v7 没有显式序号,但可恢复首次本地持久化代理: - -```sql -CASE direction - WHEN 'inbound' THEN updated_at_unix_ms - ELSE created_at_unix_ms -END -``` - -迁移按该值升序,再按原 `created_at_unix_ms, message_id` 确定性排序并显式写入 `ROW_NUMBER()` 序号。这样: - -- 既有出站 status update 不会把消息推后; -- 既有入站使用本地持久化时间,能修复本次整秒截断导致的倒序; -- 极少数历史记录若所有可恢复字段都同毫秒,无法重建真实因果,只维持确定性 message-ID 次序;未来记录不再有此缺口。 - -### 3.3 unread 与 Down - -Up 在删除旧 `sms_messages` 前复制 `sms_message_unread`,保留每个 `unread_id`、message ID、remote address 和最大 AUTOINCREMENT 水位;重建后恢复指向新表 `message_id` UNIQUE key 的 `ON DELETE CASCADE` 外键与索引。 - -Down 对称重建 v7 `WITHOUT ROWID` 表和原 created-time indexes,同时完整复制消息与 unread ledger。`recordSequence` 在 Down 中有意移除;再次 Up 会按可恢复时间重新回填。迁移测试必须覆盖真实 v7→v8→Down→v8、外键检查、schema version、索引、未读 token 与新插入序号继续大于历史最大值。 - -## 4. Cursor 与分页契约 - -### 4.1 版本化 SMS cursor - -`internal/domain/pagination` 保留 Calls 使用的 v1 `(createdAt, stable ID)`。SMS 新响应使用带显式 kind/version 的 v2 sequence cursor: - -- 包含正整数 `recordSequence` 和稳定 message ID; -- base64url、最大 256 字符、无号码/正文/Line/设备身份; -- SMS service 只接受 SMS v2 或可迁移的 v1,Calls service 明确拒绝 SMS v2; -- v2 SQL 使用严格 `record_sequence < boundary`,不依赖 boundary 行存在,因此删除 boundary 后分页仍有效。 - -### 4.2 v1 过渡 - -部署前浏览器可能仍持有 v1 cursor。SMS store 在 boundary 行仍存在时用 `(message ID, createdAt)` 查到其 v8 sequence,并验证它属于当前过滤范围,再继续 sequence 分页。行已删除或字段/过滤不一致时返回既有 cursor-invalid 错误。所有新 `nextCursor` 都发 v2,因此兼容路径自然消失,不形成第二套长期排序。 - -### 4.3 查询 owner - -- `ListSMSPage` 的 global、remote-only、Line+remote 分支全部按 sequence DESC; -- `ListSMSConversationPage` 的 window latest、会话外层排序与 next cursor 全按 latest sequence DESC; -- `lastOutboundLineId` 按 outbound sequence DESC; -- 状态更新只改变 `updatedAt/status`,不会改变页面或摘要顺序; -- read-through token 继续使用独立 unread AUTOINCREMENT 水位,不与 record sequence 合并。 - -## 5. API 与 Web - -公共 JSON schema 不增加 `recordSequence`。`api/openapi.yaml` 只澄清消息和会话是“按首次持久化顺序 newest-first”,`PageCursor` 仍是客户端不可解析的 opaque string;按既有生成入口刷新生成物并验证无 drift。 - -Web 删除 `createdAt/message ID` 排序所有权。新的纯 helper: - -1. 按 TanStack infinite-query 给出的页面顺序拼接(第一页最新,后续页更旧); -2. 保持每页服务端 sequence DESC 原样; -3. 整体反转得到气泡 oldest-first; -4. 不修改 Query cache 原数组。 - -会话列表本来就使用服务端摘要顺序,无需客户端二次排序。显示标签继续使用 `createdAt`,因此排序与标签时间有意解耦;滚动 ownership、top pagination anchor、near-bottom follow、SSE HTTP refetch 和自动已读 gate 保持不变。 - -## 6. 兼容、部署与回滚 - -- v8 应用与 migration 必须一起上线;旧二进制不能读取 v8 schema,因此回滚应用时必须先执行 v8 Down。 -- 公共消息 JSON、HTTP route、错误码、SSE topic 和生成 query identity 不变;活动浏览器的 v1 cursor 有过渡支持。 -- 新增 ADR 0024,局部取代 ADR 0023 与 `docs/architecture.md` 中 SMS `(createdAt, stable ID)` 排序条款;Calls 条款不变。 -- 所有门禁通过且用户批准本规划后,构建三个 `dev` 镜像并用 `docker compose up -d` 原地替换当前服务,不先 `down`、不删除数据卷。检查 data-init/bootstrap 退出 0、app/agent/netd healthy、镜像 revision 和 HTTP 200。 -- 容器重建只恢复既有 desired runtime,不授权发送短信、修改 RF、持久写模组或运行 HIL。若部署需要超出当前 Compose 正常重建的权限/动作则停止并报告。 - -## 7. 测试策略 - -### Storage/migration - -- 先插入 outbound,再插入 provider `CreatedAt` 更早但本地持久化更晚的 inbound;验证 sequence query 与 summary。 -- 同时间、随机 ID 逆序、批量/并发插入、replay、status update、delete/non-reuse。 -- global/remote/Line+remote/conversation 多页 keyset,前插、boundary 删除、v1→v2 过渡和跨 cursor kind 拒绝。 -- v7→v8 回填修正、unread ID/token 保留、外键、Down/Up 与 schema/index contract。 - -### Service/HTTP - -- v2 round-trip、v1 过渡、Calls 隔离、invalid/version/length/filter error mapping。 -- JSON 数组顺序与 summary last message 使用 sequence;公共 message timestamps 原样。 - -### Web/browser - -- helper 保留服务器顺序并反转多页,不按 timestamp/ID 排序且不 mutation。 -- Messages Vitest 用反向业务时间证明 outbound bubble 在 inbound 上方,conversation preview 为 inbound。 -- Desktop/mobile Playwright 保留 scroll、load older、SSE refetch/read-token checks;不保存 screenshot/trace,不发送真实短信。 - -## 8. 风险与缓解 - -| 风险 | 缓解 | -| --- | --- | -| 重建主表时 unread 外键/水位丢失 | 同一 migration 先复制 ledger,显式保留 ID,跑 foreign_key_check 与 Down/Up 测试 | -| 通用 pagination v2 误伤 Calls | cursor 带 kind/version;Calls 明确只接受 v1,并保留原测试 | -| Web 自行排序与服务端再次漂移 | helper 只消费页面顺序;反向业务时间跨层 fixture | -| 旧 cursor 无 sequence | boundary 存在时按 ID+time 映射;新 cursor 自包含 sequence;删除旧 boundary 稳定拒绝 | -| 历史同毫秒真实顺序不可恢复 | 使用入站 updatedAt/出站 createdAt 的最佳可靠代理;剩余 tie 确定性处理并明确不做内容启发式 | -| 容器迁移失败影响现有数据 | 先完成 migration tests/全量 gates;Compose 原地更新;失败时保留数据卷并停止扩展动作 | - -## 9. 明确延期 - -- 暴露/显示本地持久化时间或 sequence、双时间 UI、按运营商时钟校准。 -- 给 cursor 加保密加密、跨 endpoint cursor 通用化或重写 Calls 顺序。 -- 会话搜索/归档、号码规范化、联系人/Line/未读/发送行为变化。 -- 真实短信回环或任何 HIL 验证。 diff --git a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-ordering/implement.jsonl b/.trellis/tasks/archive/2026-08/08-10-sms-conversation-ordering/implement.jsonl deleted file mode 100644 index cf8fbf7..0000000 --- a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-ordering/implement.jsonl +++ /dev/null @@ -1,13 +0,0 @@ -{"file":".trellis/tasks/08-10-sms-conversation-ordering/research/repository-evidence.md","reason":"Provides the sanitized root-cause trace, current cursor/storage contracts, and approved implementation shape."} -{"file":".trellis/spec/core/backend/index.md","reason":"Defines root Go package ownership, OpenAPI-first boundaries, and mandatory backend pre-development checks."} -{"file":".trellis/spec/core/backend/storage-and-migrations.md","reason":"Owns messages dataset migrations, keyset indexes, transactions, foreign keys, and sensitive persistence rules."} -{"file":".trellis/spec/core/backend/api-contracts.md","reason":"Owns opaque cursor compatibility, OpenAPI generation, HTTP errors, and SSE authority boundaries."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Defines focused-to-broad Go validation and migration/restart test expectations."} -{"file":".trellis/spec/web/frontend/index.md","reason":"Defines generated Query ownership and the React page boundary."} -{"file":".trellis/spec/web/frontend/hook-guidelines.md","reason":"Owns infinite-query pagination, server ordering, scroll anchoring, and SSE invalidation behavior."} -{"file":".trellis/spec/web/frontend/quality-guidelines.md","reason":"Defines Vitest, Chromium E2E, generated drift, and responsive regression checks."} -{"file":".trellis/spec/core/docs/documentation-ownership.md","reason":"Defines canonical ADR/architecture/active-plan ownership for the ordering correction."} -{"file":".trellis/spec/core/docs/privacy-and-publication.md","reason":"Prevents live message, Line, identity, screenshot, and troubleshooting evidence from entering tracked artifacts."} -{"file":".trellis/spec/core/infra/containers-and-privileges.md","reason":"Constrains the approved local Compose rebuild and health verification without widening privileges."} -{"file":".trellis/spec/core/infra/hardware-and-hil-safety.md","reason":"Keeps all validation synthetic and prevents real SMS/RF/HIL actions during deployment checks."} -{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Ensures one ordering fact flows consistently from SQLite through cursor/service/API to Web."} diff --git a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-ordering/implement.md b/.trellis/tasks/archive/2026-08/08-10-sms-conversation-ordering/implement.md deleted file mode 100644 index de5b7a1..0000000 --- a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-ordering/implement.md +++ /dev/null @@ -1,141 +0,0 @@ -# 修复短信会话时间乱序实施计划 - -## 1. 执行前置与约束 - -- 本计划不授权实现。只有用户审批最终规划摘要后,主会话才运行 `task.py start`。 -- 这是一个原子跨层交付;messages v8、cursor、store/service、Web 和文档必须作为同一兼容版本完成。 -- Phase 2 按 Trellis sub-agent 流程执行;实现与检查 agent 都从 active task 的 curated context 开始,且不得回退其他人的改动。 -- 修改 schema version、cursor version、查询顺序、索引或前端 helper 前先全仓搜索;不手改生成 Go/TypeScript 文件。 -- 不执行真实短信、电话、RF、模组持久写、Host VoWiFi 操作或 HIL。测试只用合成数据,现场数据只做必要的只读、脱敏核对。 - -## 2. WP0 — 激活、基线与 durable decision - -依赖:用户审批最终规划。 - -- [x] 运行 `task.py start` 并加载 Phase 2.1/`trellis-before-dev` 上下文。 -- [x] 确认工作树与当前 Compose 基线,保留用户改动;运行 focused storage/messaging/Web ordering tests。 -- [x] 新增 ADR 0024,局部 supersede ADR 0023 的 SMS created-time ordering,冻结 record sequence、cursor compatibility 和 display-time 边界。 - -验证: - -```bash -git status --short -go test ./internal/storage/sqlite ./internal/application/messaging -corepack pnpm --dir web test -- messages Messages -``` - -## 3. WP1 — Messages v8 与 sequence cursor - -所有权:messages migration/store、pagination domain、co-located Go tests。 - -- [x] 增加 `00008_sms_record_sequence.sql`,重建 messages 表、回填 sequence、复制 unread ledger、更新 indexes/schema version,并实现对称 Down。 -- [x] 扩展 cursor codec:Calls v1 不变;新增有 kind/version 的 SMS v2 sequence cursor及 v1 compatibility decode。 -- [x] 将 global/remote/Line+remote history、conversation latest/order、last outbound Line 和 next cursor 改为 sequence。 -- [x] 保证 new outbound/inbound 首次插入自动分配 sequence,replay/status/delete 行为不改变或复用序号。 -- [x] 增加 migration、foreign key、unread watermark、历史缺陷回填、同毫秒/时钟偏差、分页/删除/concurrency 测试。 - -验证: - -```bash -go test ./internal/domain/pagination ./internal/storage/sqlite -go test -race ./internal/storage/sqlite -``` - -回滚点:migration、cursor/store 必须一起回滚;Down 保留业务消息与 unread marker。 - -## 4. WP2 — Messaging service、HTTP/OpenAPI 契约 - -依赖:WP1 sequence/cursor 稳定。 - -- [x] 让 messaging service 为 SMS encode/decode v2,并对 v1 做过渡;Calls 明确拒绝 SMS cursor kind。 -- [x] 更新 OpenAPI 的 newest-first 描述,不公开 sequence;必要时通过既有 generator 更新产物。 -- [x] 覆盖 handlers 的消息/会话顺序、旧/新 cursor、非法 kind/version/length、稳定分页错误和公共时间字段不变。 -- [x] 确认 unread read-through token 与 SSE publish/invalidation 无改动。 - -验证: - -```bash -make generate -go test ./internal/application/messaging ./internal/application/calls -go test ./internal/api/openapi ./internal/api/httpapi -make verify-generated -``` - -## 5. WP3 — Web 服务端顺序消费 - -依赖:WP2 response/cursor 契约。 - -- [x] 用“拼接服务器页面后整体反转”的纯 helper 替换 `createdAt/message ID` 排序,保持数组不可变。 -- [x] 更新 Messages Vitest:反向业务时间、同时间随机 ID、多页、conversation last/preview、SSE refetch 与 scroll anchor。 -- [x] 更新 synthetic Playwright desktop/mobile fixture,证明 outbound 先显示、later-persisted inbound 后显示且 preview 正确。 -- [x] 保持 bubble 元信息显示原 `createdAt`,不公开 sequence。 - -验证: - -```bash -corepack pnpm --dir web test -- messages Messages -corepack pnpm --dir web typecheck -corepack pnpm --dir web e2e -``` - -## 6. WP4 — 文档、规范与独立检查 - -- [x] 更新 ADR 0024、`docs/architecture.md`、active MVP follow-up 和相关 backend/frontend Trellis specs,删除 SMS createdAt-keyset 陈述,保留 Calls 条款。 -- [x] 使用 `trellis-update-spec` 做 source-backed 一致性检查。 -- [x] 派发 `trellis-check` 独立检查 PRD、迁移、cursor 兼容、跨层顺序、unread、Web、隐私和测试质量;修复确认的问题。 - -验证: - -```bash -make check-docs -git diff --check -``` - -## 7. WP5 — 全量门禁、提交与本地部署 - -- [x] 运行 targeted→broad gates;只修本任务归因问题。 -- [x] 确认 diff 不含号码、Line/SIM/设备身份、截图、日志、数据库或私密现场材料。 -- [x] 按逻辑边界提交代码/文档;不 push。 -- [x] 构建三个 `dev` 镜像并 `SIMPLUS_IMAGE_TAG=dev docker compose up -d` 原地更新。 -- [x] 检查 data-init/bootstrap exit 0、app/agent/netd healthy、镜像 revision、HTTP 200;不发送真实短信。 - -```bash -make check-format -make verify-generated -make lint -make test -make security -make build -make check-docs -corepack pnpm --dir web e2e -git diff --check -make container-build CONTAINER_IMAGE_TAG=dev -SIMPLUS_IMAGE_TAG=dev docker compose up -d -``` - -## 8. 完成定义 - -- [x] 所有 PRD acceptance criteria 有对应自动化测试或部署观察证据。 -- [x] 已有缺陷形态和未来写入都按首次本地持久化顺序展示;分页、摘要和最近 Line 一致。 -- [x] 迁移与 Down 保留消息/unread,Calls 与公共 JSON/SSE 行为不回归。 -- [x] 全量 gate 与独立 Trellis 检查通过,工作树无私密材料。 -- [x] 本地运行容器是本次产品提交且健康;没有真实通信副作用。 - -## 9. 实现证据(2026-08-11) - -- focused Go:`go test ./internal/domain/pagination ./internal/storage/sqlite ./internal/application/messaging ./internal/application/calls ./internal/api/openapi ./internal/api/httpapi` 通过; -- storage race:`go test -race ./internal/storage/sqlite` 通过; -- focused Web:18 个 Vitest 文件、62 个测试通过,`pnpm --dir web typecheck` 通过; -- synthetic browser:desktop/mobile Chromium 共 2 个 Playwright 测试通过; -- broad gates:`make check-format`、`make verify-generated`、`make lint`、`make test`、 - `make security`、`make build`、`make check-docs` 和 `git diff --check` 通过; -- 非失败 warning:Vitest/jsdom 报告不支持 pseudo-element `getComputedStyle`;Playwright - WebServer 报告 `FORCE_COLOR` 覆盖 `NO_COLOR`;Vite 报告既有大于 500 kB chunk; -- 独立 `trellis-check` 未发现产品代码缺陷,并补强 sequence non-reuse、Down/Up unread - 水位、service cursor continuation 与会话预览的断言;全部门禁重跑通过; -- 产品代码按 `f20cd83`、`d36c2d6`、`b815a7b` 三个逻辑边界提交,未 push; -- 三个 `dev` 镜像构建为 revision `b815a7bece69` 并原地更新 Compose;data-init/bootstrap - 退出码为 0,app/agent/netd healthy,本机 HTTP 返回 200; -- messages schema 为 v8,对报告缺陷对应既有记录的脱敏只读核对得到 - `outbound -> inbound`;未输出或保存号码、Line、消息 ID、正文、数据库或截图; -- 未执行真实短信、RF、模组写入或 HIL。 diff --git a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-ordering/prd.md b/.trellis/tasks/archive/2026-08/08-10-sms-conversation-ordering/prd.md deleted file mode 100644 index 7c18e34..0000000 --- a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-ordering/prd.md +++ /dev/null @@ -1,76 +0,0 @@ -# 修复短信会话时间乱序 - -## Goal - -短信会话必须按 Simplus 首次持久化每条短信的稳定先后顺序展示和分页,而不是用精度、时钟来源不同的业务时间推断先后。这样管理员先发出一条短信、随后收到同内容回传时,发送气泡稳定出现在收到气泡上方;刷新、分页、会话摘要和服务重启后顺序保持一致。 - -## Background - -- 当前 SQLite 历史、会话摘要和 Web 都按 `(createdAt, message ID)` 排序。 -- 出站 `createdAt` 来自本机创建发送操作的高精度时间;入站 `createdAt` 来自模组/运营商的 `receivedAt`,可能只有整秒精度,也可能与本机时钟存在偏差。 -- 已确认的脱敏现场模式是:入站业务时间落在整秒,出站本地创建时间晚约百毫秒,但入站实际在出站完成后才持久化。页面只显示到秒,因此两个标签看似相同,现有排序却把入站放在出站之前。 -- 当前入站 `updatedAt` 在首次持久化时取本机时间,之后不会因状态推进而变化;出站的首次持久化时间是 `createdAt`,而其 `updatedAt` 会随发送状态推进,不能用于排序。 - -## Requirements - -### R1 — 持久、单调的记录顺序 - -- messages 数据集必须为每条首次成功插入的短信分配一个全局单调、删除后不复用的 `recordSequence`。 -- 出站序号在发送副作用前的持久化步骤分配;入站序号与首次消息持久化、未读 marker 位于同一个事务。 -- 入站重复同步、operation replay、发送状态更新和消息删除不得创建、改变或复用既有序号。 - -### R2 — 统一使用记录顺序 - -- 全局短信历史、remote-only 历史、兼容的 Line + remote 历史、会话最后消息、会话列表顺序和最近出站 Line 都必须使用 `recordSequence`。 -- 状态从 queued 推进到 sent/failed/unconfirmed 不得让气泡或会话重排。 -- `createdAt`、`updatedAt` 和 `sentAt` 的公共含义保持不变;界面时间仍显示业务 `createdAt`,不把内部序号伪装成运营商时间。 - -### R3 — 稳定分页与兼容 - -- 新短信页和会话页 cursor 必须携带版本化、长度受限的记录顺序边界,使用严格 keyset `<`,不得退化为 offset。 -- 新 cursor 在 boundary 消息被删除后仍能继续分页;不得包含号码、正文、Line、SIM/设备身份或其他私密数据。 -- 部署前已经发出的 v1 `(createdAt, message ID)` cursor 应在 boundary 仍存在时兼容解析并映射到记录顺序;非法、跨类型或不一致 cursor 继续返回稳定分页错误。 -- Calls 分页继续使用原有 `(createdAt, stable ID)` 契约,不得被 SMS cursor 变化影响。 - -### R4 — 历史迁移 - -- messages schema v8 必须保留全部短信、状态、provider ID、未读 marker 及其水位,并为旧记录回填唯一序号。 -- 旧记录按可恢复的本地首次持久化时间回填:入站使用 `updatedAt`,出站使用 `createdAt`,再以既有 `(createdAt, message ID)` 作为确定性 tie-break。 -- 因此当前已经存在的“先发送、后入站,但入站业务时间被截到同一秒更早位置”的记录在升级后也应纠正。 -- Down migration 回到 v7 时必须保留业务短信与未读 marker;仅移除 v8 顺序元数据。再次 Up 必须成功且数据完整。 - -### R5 — Web 使用服务端顺序 - -- Web 不得再用 `createdAt/message ID` 重建服务端顺序;它只把各个 newest-first 页面按服务端顺序拼接并反转为聊天的 oldest-first 展示。 -- 顶部加载更早消息、滚动锚点、接近底部自动跟随、SSE 触发 HTTP 刷新和自动已读行为必须保持。 -- 同一秒内入站业务时间早于出站、但入站后持久化的回归场景必须证明发送气泡在上、收到气泡在下,且会话预览认定入站为最后消息。 - -### R6 — 既有边界不变 - -- exact remote-address 会话身份、跨 Line 合并、每条消息的 Line、最近 Line fail-closed、联系人、未读 ledger/read-through token、发送状态机和 SSE payload 均保持不变。 -- 不增加公开 `recordSequence` 字段;它是服务端持久化和 opaque cursor 的内部排序事实。 -- 不通过真实短信、RF、模组持久写、Host VoWiFi 或 HIL 验证;只使用合成 fixture 与对现有本地数据的只读检查。 - -## Acceptance Criteria - -- [x] AC1:新建出站记录后再持久化一个业务 `createdAt` 更早的入站记录,所有历史查询均返回入站为 newest,Web 展示为出站在上、入站在下。 -- [x] AC2:上述两条属于同一 remote address 时,会话摘要的 `lastMessage` 是入站,会话排序与最近出站 Line 同时正确。 -- [x] AC3:相同毫秒、时钟回拨、批量入站、并发写入和随机 message ID 不改变首次成功持久化顺序;replay/status update 不重排。 -- [x] AC4:多页 global、remote-only、Line + remote 和 conversation keyset 分页无重复或遗漏;新 v2 cursor 的 boundary 删除后仍可继续,合法旧 v1 cursor 可过渡使用。 -- [x] AC5:v7→v8 保留消息与 unread marker,并按入站 `updatedAt`/出站 `createdAt` 回填;当前缺陷形态升级后顺序被纠正。 -- [x] AC6:v8→v7→v8 保留业务消息与未读状态,迁移对象、schema version、索引和 AUTOINCREMENT 行为符合预期。 -- [x] AC7:Calls cursor round-trip/分页不变;非法 SMS cursor 仍映射到现有稳定 HTTP 错误,OpenAPI/generated drift 为零。 -- [x] AC8:Messages 的 Vitest 与桌面/移动 Chromium E2E 覆盖反向业务时间、分页/滚动和 SSE 刷新,均不使用真实通信。 -- [x] AC9:全量格式、生成、lint、test、security、build 与 docs gate 通过,工作树不含私密现场数据、截图或临时数据库副本。 -- [x] AC10:在用户批准本规划后,构建新的本地 `dev` 镜像并原地更新当前 Compose;app/agent/netd 均 healthy、一次性任务退出 0、HTTP 200,且不触发真实短信测试。 - -## Out of Scope - -- 修改运营商/模组提供的 SMS 时间、纠正远端时钟、在 UI 显示内部序号或新增“接收时间/运营商时间”双时间标签。 -- 猜测不同号码格式等价、改变会话身份、联系人模型、未读规则、发送线路或发送状态。 -- 对无法恢复本地持久化先后的更老同毫秒历史做内容匹配或方向启发式重排;迁移只使用现有可靠字段和确定性 tie-break。 -- 真实短信回环、RF/模组操作、HIL、保存现场截图/日志/数据库,或推送远端仓库。 - -## Blocking Open Questions - -无。用户已选择稳定本地记录顺序,并要求按推荐方案修复。 diff --git a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-ordering/research/repository-evidence.md b/.trellis/tasks/archive/2026-08/08-10-sms-conversation-ordering/research/repository-evidence.md deleted file mode 100644 index f7863d8..0000000 --- a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-ordering/research/repository-evidence.md +++ /dev/null @@ -1,36 +0,0 @@ -# Repository evidence — SMS record ordering - -## Confirmed data flow - -1. `internal/application/messaging/service.go:281-285` assigns outbound `CreatedAt` from the local clock before `CreateOutboundSMS` and before transport dispatch. -2. `internal/application/messaging/inbound.go:229-237` preserves provider/modem `receivedAt` as inbound `CreatedAt`, while assigning the local persistence clock to `UpdatedAt` in the same create request. -3. `internal/storage/sqlite/messages.go:316-459` currently orders all message pages, conversation last messages, and last outbound Line by `created_at_unix_ms` plus random stable `message_id`. -4. `web/src/messages/order.ts:3-11` independently reconstructs chronological display order from the same fields. -5. `internal/application/messaging/service.go` never advances inbound status after insertion; outbound status mutations change `UpdatedAt`. Therefore the recoverable first-persistence proxy for v7 history is `CASE direction WHEN 'inbound' THEN updated_at_unix_ms ELSE created_at_unix_ms END`. - -## Sanitized defect evidence - -A read-only local query confirmed the reported shape without recording any number, Line, message ID, provider ID, body, screenshot, or database copy: - -- inbound provider time was at the whole-second boundary; -- outbound local creation was about one tenth of a second later; -- outbound completion happened next; -- inbound local persistence happened last. - -This proves that `createdAt` describes two different clocks/precisions and is not a valid causal ordering key. The persisted local observation order is the correct product truth for chat placement. - -## Existing contracts to preserve - -- ADR 0023 owns exact remote-address conversations, cross-Line history, unread markers, read-through tokens, and the current created-time cursor decision. -- `internal/domain/pagination/cursor.go` v1 is shared by Calls and SMS. SMS must gain a distinct version/ordering mode without changing Calls semantics. -- Messages schema v7 has `sms_message_unread` referencing `sms_messages(message_id)` and relies on preserved AUTOINCREMENT unread IDs. A v8 table rebuild must copy this ledger and its IDs before removing the old table. -- The Web uses generated infinite queries; SSE only invalidates/refetches authoritative HTTP pages. No new SSE field is needed. -- Current history is capped at 2000 records, but pagination still requires matching SQLite indexes and strict keyset behavior. - -## Recommended implementation shape - -- Rebuild `sms_messages` in v8 with `record_sequence INTEGER PRIMARY KEY AUTOINCREMENT` and retain `message_id` as a unique public/business identifier. -- Backfill sequence in recoverable local-persistence order, recreate unread foreign keys and sequence-aware global/remote/Line indexes, and provide a lossless v8 Down path to v7. -- Add a versioned SMS sequence cursor while retaining v1 decode compatibility for in-flight clients; keep Calls on v1. -- Return SMS pages newest-first by sequence. The Web should preserve server order and reverse the flattened pages for oldest-first rendering instead of sorting by timestamps. -- Record the durable correction in a new ADR that narrowly supersedes ADR 0023's `(createdAt, message ID)` SMS ordering clauses. diff --git a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-ordering/task.json b/.trellis/tasks/archive/2026-08/08-10-sms-conversation-ordering/task.json deleted file mode 100644 index 13f8527..0000000 --- a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-ordering/task.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "id": "sms-conversation-ordering", - "name": "sms-conversation-ordering", - "title": "修复短信会话时间乱序", - "description": "使用稳定的本地记录顺序统一短信历史、分页和会话最后消息排序,避免运营商整秒时间使收到消息排在刚发送消息之前。", - "status": "completed", - "dev_type": null, - "scope": null, - "package": "core", - "priority": "P1", - "creator": "Leon", - "assignee": "Leon", - "createdAt": "2026-08-10", - "completedAt": "2026-08-11", - "branch": null, - "base_branch": "main", - "worktree_path": null, - "commit": null, - "pr_url": null, - "subtasks": [], - "children": [], - "parent": null, - "relatedFiles": [], - "notes": "", - "meta": {} -} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-redesign/check.jsonl b/.trellis/tasks/archive/2026-08/08-10-sms-conversation-redesign/check.jsonl deleted file mode 100644 index 53c3de2..0000000 --- a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-redesign/check.jsonl +++ /dev/null @@ -1,9 +0,0 @@ -{"file":".trellis/tasks/08-10-sms-conversation-redesign/research/repository-evidence.md","reason":"Audit implementation against the exact pre-change contracts and approved decisions."} -{"file":".trellis/spec/core/backend/api-contracts.md","reason":"Verify OpenAPI/handler/generated compatibility, filter matrix, cursor privacy, auth/CSRF and stable errors."} -{"file":".trellis/spec/core/backend/storage-and-migrations.md","reason":"Verify v6/v7/Down migration safety, matching indexes, atomic unread markers, monotonic watermark and derived counts."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Check edge, persistence, concurrency, privacy and restart evidence at the narrowest layer."} -{"file":".trellis/spec/web/frontend/state-management.md","reason":"Ensure no duplicate server state, optimistic SMS outcome or SSE-authored unread state."} -{"file":".trellis/spec/web/frontend/type-safety.md","reason":"Reject handwritten public payload copies, unsafe casts and generated schema drift."} -{"file":".trellis/spec/web/frontend/quality-guidelines.md","reason":"Verify observable desktop/mobile behavior, accessibility, overflow, E2E and no-HIL fixtures."} -{"file":".trellis/spec/core/docs/privacy-and-publication.md","reason":"Check synthetic communication data and prevent numbers, screenshots, logs or private evidence entering public artifacts."} -{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Audit end-to-end boundary ownership and race-safe round trips rather than layer-local success only."} diff --git a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-redesign/design.md b/.trellis/tasks/archive/2026-08/08-10-sms-conversation-redesign/design.md deleted file mode 100644 index e4fa590..0000000 --- a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-redesign/design.md +++ /dev/null @@ -1,213 +0,0 @@ -# 重做短信会话页面技术设计 - -## 1. 设计状态与边界 - -- 状态:规划完成,等待用户审批后才可 `task.py start`。 -- 交付形态:一个原子跨层纵切,包含 messages SQLite migration、应用服务、OpenAPI/生成客户端、React 页面、测试和架构文档。 -- 不拆父子任务:数据库、API、生成 Query 和页面必须以同一版本交付,拆成可独立发布的子任务会制造临时双契约;实施阶段可以按工作包设检查点。 -- 不改变 SMS transport、编码、发送状态机、operation 幂等、Line 身份或 SSE payload。普通验证不接触真实短信、RF、模组或 HIL。 -- 新 ADR 0023 局部取代 ADR 0022 中“会话必须由 Line + remote address 标识”的决定;ADR 0022 的 Vite/React Query、HTTP 权威、SSE 隐私和 mutation 不自动重试仍有效。 - -## 2. 总体数据流 - -```text -入站短信/出站结果 - -> messaging service - -> messages.sqlite3: sms_messages - -> messages SSE topic(仅失效提示) - -> RealtimeBridge 失效 active generated queries - -> GET /message-conversations + GET /messages?remoteAddress=... - -> React 关联 Contacts 与 Managed Lines 后渲染 - -会话实际可见且最新页加载成功 - -> 使用该 HTTP snapshot 返回的 opaque readThroughToken - -> PUT /message-conversations/read-state {remoteAddress, readThroughToken} - -> messages.sqlite3: 删除不晚于该水位的 unread markers - -> messages topic / HTTP 摘要刷新 - -> unreadCount 归零或保留并发到达的新短信 -``` - -HTTP/SQLite 始终是会话、消息和未读状态的唯一真相源。SSE 不新增字段或 topic,不携带号码、正文、未读数、Line 或 unread watermark。 - -## 3. 会话身份与兼容规则 - -- 产品会话键是消息已持久化的 `remoteAddress` 精确字符串,不包含 `lineId`。 -- 同一 remote address 的所有 Line 消息合并;每条消息仍保留原始 Line,发送也必须显式选择一个 Line。 -- 不推断本地号与国际号等价,也不做国家相关规范化。仓库没有足够上下文安全判断 `138...` 与 `+86138...` 是否为同一收件人;这种地址仍是两个会话。 -- 数字 remote address 可回复;GSM7 字母型服务地址只读,composer 显示不可回复原因。 -- 联系人只影响显示名称与新建短信选择。会话不绑定 Contact ID;联系人修改或删除不会改变历史会话身份。 -- `GET /messages` 保留无过滤和现有 Line + remote address 精确过滤,并新增 remote address 单独过滤。Line 单独出现继续返回 `MESSAGE_FILTER_INVALID`,旧客户端保持兼容。 - -## 4. 持久化设计 - -### 4.1 Messages schema v7 - -在 `messages` dataset 追加 migration 00007: - -1. 新增 `sms_messages_remote_page_idx`: - `(remote_address, created_at_unix_ms DESC, message_id DESC)`,服务跨线路历史和会话最新消息。 -2. 新增 `sms_message_unread`: - - `unread_id INTEGER PRIMARY KEY AUTOINCREMENT`,作为只增不复用的未读到达序号; - - `message_id TEXT NOT NULL UNIQUE REFERENCES sms_messages(message_id) ON DELETE CASCADE`; - - `remote_address TEXT NOT NULL`,沿用 remote address 长度约束; - - 索引 `(remote_address, unread_id)`,服务摘要计数与按水位标记已读。 -3. Up migration 创建空 unread 表;升级前历史没有 marker,因此自然初始化为已读。无需从不可恢复的旧浏览行为猜测状态。 -4. Down migration 删除 unread 表和 remote-only history index,把 dataset schema version 恢复为 6;`sms_messages` 业务记录不重建、不丢失。 - -`AUTOINCREMENT` 是并发正确性的一部分:消息的 `createdAt` 只有毫秒精度且稳定 ID 随机,同一毫秒后来入库的消息不保证 ID 字典序更大。未读水位必须按数据库提交顺序单调增长,不能复用消息分页元组。删除短信通过外键级联删除 marker;删除整个会话不会留下可见状态。 - -### 4.2 Store 契约 - -在现有 messaging consumer-owned Repository port 上增加最小能力: - -- `CreateInboundSMS` 改为一个 messages DB transaction:仅当入站消息首次插入时同时创建 unread marker,duplicate/replay 不重复计数;message + marker commit 后才允许上层 ACK。 -- `ListSMSPage` 扩展为四种合法查询:全局、remote-only、Line + remote;Line-only 非法。remote-only 最新页在同一读取 snapshot 中返回当前最大 unread marker 的 token 原料。 -- `ListSMSConversationPage` 返回按最后消息 `(createdAt, messageID)` 倒序的摘要页。每项含 remote address、完整最后消息、从 unread markers 派生的 `unreadCount` 和最近一次出站 `lineId`(如有)。 -- `MarkSMSConversationRead(remoteAddress, unreadID, boundaryMessageID)` 在 transaction 中: - 1. 验证 boundary message 属于该 remote address; - 2. 若其 unread marker 仍存在,要求 `unread_id` 与 token 一致; - 3. 删除同一 remote address 上 `unread_id <= boundary` 的 markers; - 4. marker 已被另一标签页清除时幂等 no-op;boundary message 已删除时返回 not-found 让客户端刷新。 - -`unreadCount` 不作为可漂移的数值字段写入,而是 unread marker 行数。入站首次持久化、删除短信和标记已读都在同一 messages dataset 内原子改变 marker,因此重启与删除不会造成计数漂移。 - -### 4.3 游标与并发 - -- 会话摘要复用 `internal/domain/pagination` 的 v1 opaque cursor;边界取摘要最后一项的最后消息时间/ID,cursor 不包含号码。 -- 摘要和消息页都使用严格 keyset `<` 边界并查询 `limit+1`,不使用 offset。 -- messaging service 把 `(unread_id, boundary message ID)` 编码成有版本、长度受限的 opaque `readThroughToken`;token 不含号码或正文,客户端不得解析或合成。 -- remote-only 最新页只返回该读取 snapshot 的 token。新入站即使与旧消息同毫秒、ID 字典序更小,也会得到更大的 `unread_id`,旧 token 的 delete 不会清除它。 -- 多标签页或乱序 token 只能删除各自水位之前的 marker;较旧请求不会触及更大的未读序号。 -- token/message 在请求前被删除或不属于该 remote address 时返回稳定 404/invalid error,前端刷新权威历史,不猜测水位。 - -## 5. 公共 HTTP 契约 - -所有 schema 从 `api/openapi.yaml` 生成,不手改 Go/TypeScript 输出。 - -### 5.1 `GET /api/v1/message-conversations` - -参数:标准 `limit`、opaque `cursor`。 - -响应 `SMSConversationListResponse`: - -- `conversations[]`(最多 50): - - `remoteAddress`; - - `lastMessage: SMSMessage`; - - `unreadCount`; - - 可选 `lastOutboundLineId`。 -- `conversationTotalCount`; -- `messageTotalCount`、`capacity`、`nearCapacity`,保留当前历史容量提示; -- 可选 `nextCursor`。 - -该 operation 使用 `messages` tag,因此现有 SSE topic mapping 会同时失效摘要和消息历史。 - -### 5.2 `GET /api/v1/messages` - -过滤矩阵改为: - -| `lineId` | `remoteAddress` | 结果 | -| --- | --- | --- | -| 省略 | 省略 | 全局历史,兼容旧行为 | -| 省略 | 提供 | 跨线路的收件人会话历史 | -| 提供 | 提供 | 单线路精确历史,兼容旧行为 | -| 提供 | 省略 | `400 MESSAGE_FILTER_INVALID` | - -排序、limit、cursor、错误和 stats 字段保持不变。 - -Remote-only 最新页的 `SMSMessageListResponse` 额外包含可选 `readThroughToken`。无未读 marker、全局/Line+remote 查询或较旧 cursor 页可以省略该字段;前端只消费当前会话第一页的 token。 - -### 5.3 `PUT /api/v1/message-conversations/read-state` - -请求 `MarkSMSConversationReadRequest`:`remoteAddress`、`readThroughToken`。号码放在鉴权 JSON body 中,不新增含号码的 URL path 或 cursor;token 只含版本、单调未读序号和稳定消息 ID。 - -- 成功:`204`,幂等;只有实际删除 unread markers 时才发布 `messages` topic,供其他标签页同步。 -- `400`:字段/组合非法;`404`:boundary 不存在或不属于会话;`401/403/409/421/500/504` 沿用业务 API 边界。 -- mutation 不自动 retry;失败不乐观清除角标。 - -## 6. 前端设计 - -### 6.1 Query 与本地状态 - -- generated infinite query 获取会话摘要;另一条 generated infinite query 以 `remoteAddress` 单独过滤当前历史。 -- Contacts、Managed Lines 继续使用现有共享 generated queries;用纯派生映射关联显示名,不复制到持久浏览器状态。 -- 当前 remote address、移动端 list/detail 模式、临时新会话、composer 文本、用户当前选择 Line、对话框开关和 operation error 是页面本地状态。 -- 发送、删除、联系人 CRUD、标记已读均使用 generated mutation;成功后失效最窄的生成 key,失败保留已确认快照。 -- 发送永不 optimistic append。服务端返回 `sent/unconfirmed/failed` 后刷新;网络结果未知时保留临时内容与提示,不自动重复提交,后续 SSE/focus/refetch 可发现已持久记录。 - -### 6.2 桌面与窄屏布局 - -- 桌面(`md` 及以上):一个固定最小/最大宽度的会话列表栏 + `minmax(0,1fr)` 会话栏;两栏各自约束滚动,不产生页面级横向溢出。初次加载默认选中最新会话;无会话时右栏显示引导空态。 -- 窄屏:默认只显示会话列表;点选后切换为全屏会话,顶部返回按钮回到列表。不会把两栏上下堆叠。 -- 会话列表使用可访问的 Button/List item,不实现 clickable div;支持 cursor “加载更多”。本次不新增搜索、置顶或归档。 -- 会话栏顶部显示联系人名/号码;消息区按 `(createdAt, ID)` 正序展示,较早页从顶部加载并尽量保留滚动锚点;新消息到达且用户接近底部时滚到底部,否则保留阅读位置。 -- composer 固定在会话栏底部而非 viewport 全局底部,避免覆盖历史;字数上限沿用 1600。 - -### 6.3 会话摘要与气泡 - -- 左栏:联系人名优先、号码始终可见、最新短信单行预览、时间、未读 Badge;出站预览加“我:”。排序只取最后消息 `createdAt/ID`,状态 `updatedAt` 不重排。 -- 入站气泡靠左,出站靠右。每条显示时间与解析后的 Line 名;历史 Line 不存在时显示“历史线路(已删除)”。 -- 出站气泡调用现有集中 `smsStatusPresentation`,显示正在发送、等待运营商确认、结果未知、已发送或失败。 -- 每条气泡的可访问“更多”按钮包含删除,执行前 Popconfirm/Modal 二次确认。删除最后消息后清理失效选择;移动端返回列表。 -- 只有 `failed` 出站提供“重新编辑”:回填正文与原 Line,但新发送必须再次点击并生成新 operation ID。`queued/unconfirmed` 无重发入口。 - -### 6.4 Line 默认与 fail-closed - -- 会话摘要的 `lastOutboundLineId` 是默认 Line;若从未出站,使用 Managed Lines 稳定顺序中第一条 `ready` 且具备 SMS 或 Host VoWiFi SMS 能力的 Line。 -- 最近 Line 当前不可用时仍作为选中项显示原因,发送禁用,不自动选择其他 Line。 -- 最近 Line 已删除时合成不可选的“历史线路(已删除)”选项,发送禁用。 -- 用户手动选择可发送 Line 后才恢复发送;请求只使用界面当前选择,不回退其他 Line。 -- 临时新会话没有出站历史,按第一条可发送 Line 初始化。用户离开前未发送的 remote address 与草稿不持久化。 - -### 6.5 自动已读 - -标记已读 effect 需要同时满足: - -1. 会话 detail 当前实际显示(桌面选中或移动端 detail); -2. document 为 visible; -3. 当前 remote-only 最新页成功加载且页面已渲染该 snapshot; -4. 摘要仍报告未读; -5. 使用该最新页原样返回的 `readThroughToken`,不使用 SSE 字段、摘要 ID 或客户端合成水位。 - -mutation 成功后刷新摘要。失败时角标保持,不把加载失败、后台标签页或未选中会话标已读。当前会话收到 SSE 后,摘要与历史经 HTTP 刷新;新 snapshot 渲染成功后再提交其水位。晚于 snapshot 到达的 marker 始终保留。 - -### 6.6 联系人与新建会话 - -- 左栏标题区有“新建短信”和“联系人管理”。联系人管理抽屉/弹窗复用 create/update/delete generated mutations。 -- 新建短信弹窗支持搜索现有联系人或输入合法数字号码;确认后打开临时空会话。 -- 首条发送在服务端产生持久 `SMSMessage` 后(包括 `failed/unconfirmed`)摘要接口才返回正式会话。请求在持久化前失败则不制造空会话。 -- 联系人号码修改后,旧号码历史不会迁移到新号码;旧会话回退显示原号码,符合号码作为会话身份的规则。 - -## 7. 错误、空态与可访问性 - -- 会话列表、当前历史、Contacts 与 Lines 的查询错误分别可见;当前历史失败时绝不标已读。 -- 无会话、未选会话、临时空会话、无可发送 Line、Line 不可用、字母地址不可回复、near-capacity、mutation busy/failed 都有明确状态。 -- 图标按钮有 `aria-label`;列表选择、返回、更多、发送和联系人操作可键盘访问;不可用原因不只依赖颜色。 -- 消息正文与号码只出现在鉴权 HTTP 页面,不进入 SSE、日志、公开 fixture、截图或文档示例。 - -## 8. 文档、迁移与回滚 - -- 新增 ADR 0023,局部 supersede ADR 0022 的 Line + remote 会话过滤决定。 -- 更新 `docs/architecture.md` 的 Web/分页、接收短信、存储与机械不变量;更新唯一 active MVP plan 的新里程碑。 -- 实现完成且验证通过后再更新 handoff/规范中的当前事实;不把规划描述成已实现。 -- 回滚应用版本时同时执行 messages v7 Down migration;业务短信保留,但持久 unread markers 丢弃,旧版本恢复其既有 Line + remote UI。不得只回滚生成客户端或页面的一侧。 - -## 9. 风险与缓解 - -| 风险 | 缓解 | -| --- | --- | -| remote-only 分组遗漏旧会话或分页重复 | 后端摘要 endpoint、匹配索引、稳定 last-message cursor、equal-time/并发前插测试 | -| 已读 mutation 清掉 snapshot 之后的新短信 | unread `AUTOINCREMENT` 水位、opaque token、按序号删除和同毫秒并发到信测试 | -| 删除导致 unread/摘要漂移 | unread marker 与消息同库、入站 transaction、删除级联;删除最新/未读/最后一条测试 | -| 多 Line 合并后误用发送身份 | 每条显示 Line;composer 显式选择;不可用历史 Line fail closed、不自动回退 | -| 升级产生大量虚假未读 | v7 migration 创建空 unread marker 表,旧历史自然视为已读 | -| 联系人与 messages 位于不同 dataset | 前端按精确号码关联,不建立跨库事务或持久 Contact ID 绑定 | -| 聊天页滚动/移动端键盘回归 | 独立 pane overflow、top-load anchor、desktop/mobile Playwright 无全局 overflow | -| SSE 被误当未读真相源 | topic/tag 仅失效 Query;payload schema与 RealtimeBridge 不变 | - -## 10. 明确延期 - -- 草稿持久化、会话搜索、置顶、归档、批量删除和批量已读。 -- 每管理员独立 unread ledger(产品当前只有一个管理员)。 -- 本地/国际号码等价规范化、联系人合并和历史号码迁移。 -- 输入状态、送达/已读回执、附件、表情反应和群聊。 -- 任何真实短信、RF、模组写入或 HIL 验证。 diff --git a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-redesign/implement.jsonl b/.trellis/tasks/archive/2026-08/08-10-sms-conversation-redesign/implement.jsonl deleted file mode 100644 index f3ef48b..0000000 --- a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-redesign/implement.jsonl +++ /dev/null @@ -1,15 +0,0 @@ -{"file":".trellis/tasks/08-10-sms-conversation-redesign/research/repository-evidence.md","reason":"Current Messages/API/storage evidence and the approved recipient-centric/unread design implications."} -{"file":".trellis/spec/core/backend/application-boundaries.md","reason":"Keep messaging ports narrow, preserve Line identity and uncertain-send fail-closed behavior."} -{"file":".trellis/spec/core/backend/api-contracts.md","reason":"OpenAPI-first HTTP, keyset pagination, stable errors, authenticated mutations and privacy-bounded SSE."} -{"file":".trellis/spec/core/backend/storage-and-migrations.md","reason":"Messages dataset ownership, Goose Up/Down rules, keyset indexes and real old-schema migration tests."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Required co-located store/application/httptest coverage and deterministic no-HIL validation ladder."} -{"file":".trellis/spec/web/frontend/directory-structure.md","reason":"Generated API ownership and placement of routed page, page-local components and feature transforms."} -{"file":".trellis/spec/web/frontend/component-guidelines.md","reason":"Direct Ant Design composition, responsive/accessibility rules, destructive confirmation and explicit unavailable states."} -{"file":".trellis/spec/web/frontend/hook-guidelines.md","reason":"Generated Query/infinite-query/mutation patterns, no mutation retry and SSE invalidation ownership."} -{"file":".trellis/spec/web/frontend/state-management.md","reason":"HTTP/SQLite authority, local-only drafts/selections, mutation invalidation and advisory SSE semantics."} -{"file":".trellis/spec/web/frontend/type-safety.md","reason":"Generated TypeScript/Zod contracts and typed placement for reusable message projections."} -{"file":".trellis/spec/web/frontend/quality-guidelines.md","reason":"Vitest/Playwright desktop-mobile expectations, generated drift and synthetic fixture constraints."} -{"file":".trellis/spec/core/infra/build-and-generated-files.md","reason":"Run the declared OpenAPI generation path and keep generated outputs in drift verification."} -{"file":".trellis/spec/core/docs/documentation-ownership.md","reason":"ADR, architecture and sole active-plan ownership for the durable conversation identity change."} -{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Trace unread marker/watermark and conversation summary formats across SQLite, service, OpenAPI, Query and UI."} -{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"Reuse shared pagination/status/query patterns instead of introducing parallel implementations."} diff --git a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-redesign/implement.md b/.trellis/tasks/archive/2026-08/08-10-sms-conversation-redesign/implement.md deleted file mode 100644 index 2e5945c..0000000 --- a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-redesign/implement.md +++ /dev/null @@ -1,209 +0,0 @@ -# 重做短信会话页面实施计划 - -## 1. 执行前置与约束 - -- 本计划不授权实现。只有用户审批最终规划后,主会话才运行 `task.py start` 并进入 Phase 2。 -- 这是一个原子跨层交付,不拆成可独立发布的父子任务。工作包是顺序检查点;OpenAPI、生成物、messages schema 与 Web 必须保持同一版本。 -- Phase 2 按 Trellis sub-agent 流程执行;每次 dispatch 以 active task 路径开头,并给不重叠的文件所有权。生成目录、OpenAPI、messages migration 和 `Messages.tsx/global.css` 各保持单一 owner。 -- 修改任何既有常量、schema version、route、query filter、状态标签或 CSS 值前先全仓搜索。 -- 不手改 `internal/api/openapi/generated.go` 或 `web/src/api/generated/**`;从 `api/openapi.yaml` 运行既有生成入口。 -- 不执行真实短信、电话、RF、eUICC、Host VoWiFi 或任何 HIL-1/HIL-2。所有通信数据使用明确 synthetic fixtures。 - -## 2. WP0 — 激活、基线与 durable decision - -依赖:用户审批最终规划。 - -- [x] 运行 `python3 ./.trellis/scripts/task.py start`,确认状态为 `in_progress`。 -- [x] 读取 Phase 2.1 注入上下文并记录 `git status --short`;保留任何用户新增改动。 -- [x] 运行生成 drift 与 Messages 针对性基线,确认失败不是已有环境问题。 -- [x] 新增 `docs/decisions/0023-recipient-sms-conversations.md`:记录 remote-only 会话、跨 Line 合并、持久 unread marker/watermark、升级旧历史已读,以及只局部取代 ADR 0022 第 6 条。 -- [x] 在唯一 active MVP plan 中增加新里程碑,先标记为进行中,不提前声称完成。 - -验证: - -```bash -make verify-generated -go test ./internal/application/messaging ./internal/storage/sqlite ./internal/api/httpapi -corepack pnpm --dir web test -- Messages -``` - -回滚点:仅规划/ADR/计划状态;尚无 runtime schema 或公共 API 行为变化。 - -## 3. WP1 — Messages v7 与应用服务 - -依赖:WP0 的 ADR/契约冻结。 - -所有权: - -- `internal/storage/sqlite/migrations/messages/00007_*.sql` -- `internal/storage/sqlite/messages.go` 及 storage tests -- `internal/application/messaging/service.go` 及 messaging tests -- 必要的 `internal/domain/sms/**` 小型类型 - -步骤: - -- [x] 追加 messages v7 migration:remote-only 历史索引、`AUTOINCREMENT` unread marker 表/索引、空表代表旧历史已读、schema version Up/Down。 -- [x] 扩展 `ListSMSPage` 支持 remote-only,同时保留 global 与 Line+remote;Line-only fail closed。 -- [x] 实现稳定分页的会话摘要查询:last message、unread count、last outbound Line、cursor/next page。 -- [x] 把入站消息首次插入与 unread marker 创建放进同一 transaction;duplicate/replay 不重复计数,消息删除级联 marker。 -- [x] 实现 versioned opaque unread watermark token,以及 transaction 内的 remote/boundary 校验和 `unread_id <= token` 删除;旧/重复 token 幂等 no-op。 -- [x] 在 messaging Service/Repository port 增加最小 summary/read 方法、limit/cursor/address 验证、typed not-found/invalid/persistence errors。 -- [x] 保持发送、入站 persist-before-ACK、状态推进、operation replay 和删除行为不变。 - -必须覆盖: - -- [x] v6 真实旧 schema + 旧消息升级后 unread=0,迁移后新入站 unread>0,关闭/重开保持;Down 后消息仍在、表/索引消失、再 Up 正常。 -- [x] 同号码跨 Line 合并;不同号码隔离;相同毫秒按 message ID;分页无重复/遗漏;并发前插不破坏边界。 -- [x] exact remote identity,不把带/不带 `+` 的值合并;字母型地址可以列出。 -- [x] watermark 重复/乱序不清除更晚 marker;同毫秒随机 ID 与并发新入站仍未读;删除未读/最新/最后消息后派生结果正确。 -- [x] watermark token 的空值、超长、版本、base64url、message ID、unread ID/remote 不匹配均被稳定拒绝;另一标签页已清除的同一 token 幂等成功。 -- [x] boundary 不存在、remote 不匹配、context cancellation 与数据库错误映射。 - -验证: - -```bash -go test ./internal/storage/sqlite -go test ./internal/application/messaging -``` - -风险/回滚:migration 与 store 必须一起回退;Down 只删除 read 状态和新索引,不删除/重建 `sms_messages`。 - -## 4. WP2 — OpenAPI、HTTP handler 与生成客户端 - -依赖:WP1 的服务签名和错误语义稳定。 - -所有权: - -- `api/openapi.yaml` -- `internal/api/httpapi/server.go` 与 handler tests -- OpenAPI/Web 生成物(只通过生成器) - -步骤: - -- [x] 先更新 OpenAPI:`SMSConversationSummary/ListResponse`、remote-only history 的可选 `readThroughToken`、read request、conversation list/read-state paths,以及 `GET /messages` 新过滤矩阵。 -- [x] 更新 `Messenger` consumer interface、ListMessages presence validation、conversation list handler、read-state handler和稳定错误映射。 -- [x] 会话 list 返回 conversation/message totals、capacity/nearCapacity;不得把号码放入 cursor 或 error/log。 -- [x] read-state mutation 仅在实际删除 unread markers 时发布既有 `messages` topic;不新增 SSE payload 字段或第二个真相源。 -- [x] 运行 `make generate`,检查 generated Go/TS/Zod/Query diff,没有手写后处理。 -- [x] 覆盖 auth、CSRF、trusted-LAN、limit/cursor 显式空值、remote-only/paired/Line-only、404 boundary、204 idempotency、next cursor 和 response bounds。 - -验证: - -```bash -make generate -go test ./internal/api/openapi -go test ./internal/api/httpapi -make verify-generated -``` - -风险/回滚:API source、Go handler、generated Go/Web 必须同一回滚点;禁止只回退一侧。 - -## 5. WP3 — 前端会话领域逻辑与页面骨架 - -依赖:WP2 generated Query/mutation contract。 - -所有权: - -- `web/src/pages/Messages.tsx` 及必要的 `web/src/pages/messages/**` page-local components -- `web/src/messages/**` 非视觉 transforms/tests -- `web/src/global.css` 的 Messages 专属规则 -- `web/src/pages/Messages.test.tsx` - -步骤: - -- [x] 删除旧“发送/联系人/历史表格”组合,构建桌面双栏与窄屏 list→detail 主从状态;不改变 route/AppShell。 -- [x] 接入 generated conversation infinite query、remote-only history infinite query、Contacts 与 Managed Lines joins。 -- [x] 实现会话摘要、稳定选择、cursor 加载更多、desktop 默认最新会话、mobile 初始列表和删除空会话后的选择恢复。 -- [x] 实现历史正序气泡、较早消息顶部加载/滚动锚点、Line/时间/status 元信息、字母地址只读、历史 Line 回退和气泡更多/删除。 -- [x] 实现 composer:最近 outbound Line 默认、无历史首条 eligible Line、不可用/已删除 Line fail closed、显式切换、1600 字上限和发送 busy/error。 -- [x] 实现 `failed` 重新编辑与新 operation ID;`queued/unconfirmed` 无 retry;所有发送保持 mutation retry=false、无 optimistic success。 -- [x] 实现新建短信对话框、联系人搜索/号码输入、临时会话,以及联系人管理抽屉/弹窗的新增/编辑/删除。 -- [x] 实现自动已读 gate:detail active + document visible + 最新页成功渲染 + 原样返回的 `readThroughToken`;成功后失效 summary,失败保留角标。 -- [x] 复用并按需要扩展现有 `smsStatusPresentation`、排序/映射 helpers;删除已不符合新会话身份的死 helper,不保留 Line+remote 双逻辑。 - -可观察测试: - -- [x] 两 Line 同号码只显示一个会话,气泡各自显示正确 Line;不同号码分开。 -- [x] 联系人名称/号码、预览、时间、未读 Badge、latest-created 排序;状态 update 不重排。 -- [x] desktop 双栏;mobile list→detail→back;无全局横向 overflow 的 DOM 前置断言。 -- [x] 当前 detail 成功加载才 mark read;hidden/unselected/error 不 mark;SSE 后历史显示再 mark。 -- [x] 最近 Line 可用、不可用、已删除、无 outbound、无 eligible Line 和手动切换矩阵。 -- [x] 新建临时会话不持久;首条 sent/failed/unconfirmed 形成会话;持久化前错误不制造空会话。 -- [x] failed 回填但不发送;unconfirmed 无重发;删除最新/未读/最后消息;联系人 CRUD 与名称回退。 - -验证: - -```bash -corepack pnpm --dir web test -- Messages -corepack pnpm --dir web test -- messages -corepack pnpm --dir web typecheck -corepack pnpm --dir web build -``` - -风险/回滚:`Messages.tsx`、page-local components 和 Messages CSS 作为一个 UI 回滚点;不要留下旧表格与新会话页并行的隐藏双实现。 - -## 6. WP4 — 浏览器回归与跨层场景 - -依赖:WP1–WP3 targeted tests green。 - -- [x] 扩展 synthetic Playwright fixtures,覆盖 conversation summary、remote-only history、read-state 和 contact/Line joins;所有号码和消息均为公开安全的合成值。 -- [x] Desktop:双栏、切换、发送状态、Line 选择、load older、删除与 SSE 新短信刷新/自动已读。 -- [x] Mobile:列表、进入全屏会话、返回、composer/键盘空间、触屏更多菜单、无意外 autofocus。 -- [x] 两个 viewport 都检查 page root 无横向溢出;不记录或提交 screenshot/trace。 -- [x] API/storage 集成覆盖 migration→handler→JSON round trip,确保生成 response 字段和数据库排序一致。 - -验证: - -```bash -corepack pnpm --dir web e2e -go test ./internal/storage/sqlite ./internal/application/messaging ./internal/api/httpapi -``` - -## 7. WP5 — 架构、规范与完成事实 - -依赖:实现行为与 tests 稳定。 - -- [x] 更新 `docs/architecture.md`:remote-only 会话、summary/unread watermark 数据流、消息分页索引、HTTP/SSE不变量。 -- [x] 把 active MVP plan 的本里程碑验收项更新为真实完成状态;按实际影响更新 sanitized handoff,不复制规范全文。 -- [x] 更新 `.trellis/spec/core/backend/api-contracts.md`、`storage-and-migrations.md` 与 `.trellis/spec/web/frontend/hook-guidelines.md`/相关 state/quality 事实,移除“filter 必须 Line+remote”的陈旧规则。 -- [x] 若实施没有改变批准设计,不额外发明 ADR 或产品范围;若有 material 设计变化,返回规划并重新取得审批。 -- [x] 运行 `trellis-update-spec` 对受影响规范做 source-backed 检查。 - -验证: - -```bash -make check-docs -``` - -## 8. WP6 — 全量质量门与交付检查 - -依赖:WP0–WP5 完成。 - -- [x] 先运行 targeted checks,再运行全量 gates;失败只修本任务归因问题。 -- [x] 检查 source/generated/migration/docs/spec diff 一致,manifest seed placeholder 已移除,没有真实通信或隐私材料。 -- [x] 由 `trellis-check` 独立检查 PRD、数据流、migration、generated drift、桌面/移动端、未读 race、Line fail-closed、重发安全与测试质量。 -- [x] 对照所有 Acceptance Criteria,记录任何未运行的环境检查及原因。 - -```bash -make check-format -make verify-generated -make lint -make test -make security -make build -make check-docs -corepack pnpm --dir web e2e -git status --short -``` - -禁止把 container deploy、真实短信、RF、Host VoWiFi 或 HIL 当作本任务 gate。 - -## 9. 完成与回滚定义 - -- [x] PRD 每条 acceptance criterion 都能指向测试或可观察 UI 行为。 -- [x] v6→v7→Down→v7 migration 保留全部短信;v7 unread 表初始为空,旧历史已读,新入站原子创建持久 marker。 -- [x] OpenAPI、generated Go/Web、handler、service、store 与页面只存在一套匹配契约。 -- [x] HTTP 是唯一权威;SSE payload 不变且仅触发 refetch。 -- [x] 没有自动/乐观短信重发,没有不可用 Line 的静默回退。 -- [x] desktop/mobile 主路径、分页、未读、删除和联系人均通过 synthetic tests。 -- [x] 文档与 Trellis spec 描述完成后的真实实现,无私密数据或 HIL 声明。 diff --git a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-redesign/prd.md b/.trellis/tasks/archive/2026-08/08-10-sms-conversation-redesign/prd.md deleted file mode 100644 index 0755d34..0000000 --- a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-redesign/prd.md +++ /dev/null @@ -1,141 +0,0 @@ -# 重做短信会话页面 - -## Goal - -将当前短信页面重做为接近即时通讯软件的收件人会话体验,让管理员能够快速切换会话、清楚区分收发短信、识别每条短信使用的线路与发送状态,并在页面重载或服务重启后仍看到可靠的未读数量。 - -用户价值:把当前分散的发送表单、联系人表单和历史表格收敛为一个日常可持续使用的聊天工作区,同时保留多 Line 的明确发送控制与短信结果不确定时的防重复边界。 - -## Background and Repository Evidence - -- 当前短信页由独立的“发送短信”“联系人”“短信历史”区域组成,桌面历史使用表格、窄屏使用记录卡片,并通过手工输入 Line 和号码筛选会话(`web/src/pages/Messages.tsx:30`)。 -- 前端已有未接入页面的会话分组函数,但它按 `(lineId, remoteAddress)` 分组,与本任务批准的“只按 remote address、跨 Line 合并”不一致(`web/src/messages/conversations.ts:11`)。 -- 当前消息模型支持 `queued / unconfirmed / sent / failed / received`,集中展示逻辑已区分正在发送、等待运营商确认、结果未知、已发送、发送失败和已接收(`web/src/messages/status.ts:8`)。 -- 当前历史接口使用稳定游标、单页最多 50 条,会话过滤要求 Line 与 remote address 成对提供;仓库没有会话摘要、跨 Line 号码历史或标记会话已读的 operation(`api/openapi.yaml:409`, `api/openapi.yaml:2399`)。 -- 只在浏览器中对最近一页消息分组会遗漏仅存在于更早历史的会话,因此完整左栏需要后端分页会话摘要。 -- 当前短信模型和 messages 数据库没有“管理员已查看”状态;`sms.received` SSE 只是即时 attention hint,不能在刷新或重启后还原未读数量。 -- 联系人以电话号码唯一,现有 API 支持新增、编辑、删除;联系人可用于显示名称,但不应成为消息历史的持久身份(`api/openapi.yaml:2272`, `web/src/pages/Messages.tsx:102`)。 -- Managed Lines 按创建时间、再按稳定 ID 返回;现有发送表单只允许 `ready` 且具备 SMS 或 Host VoWiFi SMS 能力的 Line(`internal/storage/sqlite/managed_lines.go:11`, `web/src/pages/Messages.tsx:99`)。 -- 入站 remote address 可为数字或 GSM7 字母型服务地址,发送请求只接受数字号码,因此字母地址会话不能用现有契约回复(`api/openapi.yaml:421`, `api/openapi.yaml:2478`)。 -- HTTP/SQLite 是权威状态;SSE `messages` topic 只让 generated Query 失效,不携带号码、正文、未读数或硬件信息(`.trellis/tasks/archive/2026-08/08-07-frontend-communication-refactor/design.md:50`)。 -- 项目要求桌面和手机均能完成主要工作流,页面不得产生全局横向溢出(`.trellis/spec/web/frontend/index.md:1`)。 - -## In Scope - -- 桌面双栏、窄屏主从模式的收件人会话页面和聊天气泡。 -- 按 remote address 跨 Line 合并的会话摘要与历史分页。 -- 单管理员持久 unread ledger/watermark、会话未读数和自动已读行为。 -- composer Line 选择、默认/不可用 Line 行为、发送状态和明确失败后的人工重新编辑。 -- 新建临时会话、联系人选择与联系人管理。 -- 单条短信删除、摘要/未读重算和空会话处理。 -- supporting messages SQLite migration、应用服务、OpenAPI、生成客户端、HTTP handler、前端 Query/UI、synthetic tests、ADR/架构/active plan/spec 更新。 - -## Requirements - -- **R1**:左栏为会话选择栏,每个 remote address 显示为一个独立会话入口。 -- **R2**:会话只按 remote address 合并;同一对端通过不同 Line 收发的短信显示在同一个会话,不因 Line 不同拆分。 -- **R3**:选择左栏会话后,右栏只展示该 remote address 跨 Line 合并后的短信内容。 -- **R4**:管理员发出的短信靠右显示,收到的短信靠左显示。 -- **R5**:每条发出短信下方显示当前状态,至少区分“已发送”“失败”“等待运营商确认”,并保留正在发送和结果未知的准确语义。 -- **R6**:会话栏底部提供短信输入区,用于编写并显式发送短信。 -- **R7**:输入区上方提供 Line 选择;默认最近一次给当前 remote address 发信所用 Line;没有出站历史时默认稳定 Line 列表中的第一条可发送 Line。 -- **R8**:管理员可在发送前手动切换 Line;一次发送只使用当时明确选中的 Line,不静默回退。 -- **R9**:保留稳定 keyset pagination、消息状态刷新、mutation 不自动重试,以及结果未知时避免误导重复发送的既有行为。 -- **R10**:匹配联系人时优先显示联系人名称,同时始终保留可见号码;联系人重命名或删除不改变历史会话身份。 -- **R11**:桌面与窄屏均能完成会话选择、历史阅读、联系人操作和短信编写,不产生页面级横向溢出。 -- **R12**:字母型服务地址会话可阅读历史,但输入区明确显示不可回复状态,不提交必然无效的发送请求。 -- **R13**:窄屏默认显示会话列表,点选后进入全屏会话,顶部可返回列表;不把两栏上下堆叠。桌面保持并列双栏。 -- **R14**:左栏显示后端持久化的未读数量/角标;页面刷新、浏览器重开和服务重启不得丢失。 -- **R15**:只有新持久化的入站短信增加未读数;出站短信及其状态变化不增加。 -- **R16**:新短信继续通过既有 `messages + sms.received` SSE 提示前端刷新;SSE 不承载号码、正文、未读数或权威会话数据。 -- **R17**:未读状态按合并后的 remote address 会话保存,同一对端从不同 Line 收到的未读短信计入同一角标。 -- **R18**:当前会话实际可见且最新一页 snapshot 成功加载后,使用该响应的服务端 unread watermark 自动标记当时已有短信为已读;未选中、后台或加载失败的会话不得被误标已读。 -- **R19**:会话保持打开时,新入站短信成功显示后自动提交新 snapshot 的 watermark;晚于旧 snapshot 到达的新短信不得被旧水位清除。 -- **R20**:左栏顶部提供“新建短信”;用户可选择已有联系人或直接输入并校验数字号码。 -- **R21**:确认收件人后打开前端临时空会话;首条短信在服务端形成持久消息记录后,该 remote address 才进入正式会话列表。 -- **R22**:临时会话和未发送草稿不持久化;首条短信提交前离开时允许丢弃,不新增草稿模型。 -- **R23**:最近一次发信 Line 当前不可发送时,选择器保留该 Line 并显示原因,不自动切换;明确改选可发送 Line 前发送禁用。 -- **R24**:最近一次发信 Line 已被删除时,以不可选的“历史线路(已删除)”表达原选择,不把另一条 Line 伪装成默认值。 -- **R25**:左栏条目优先显示联系人名称,否则显示号码;号码始终作为辅助信息可见。 -- **R26**:会话条目显示一行最新短信摘要、短信时间和未读角标;出站摘要用“我:”等方向提示区分。 -- **R27**:会话按最新短信 `(createdAt, ID)` 稳定倒序;仅有旧短信 `updatedAt`/状态变化时不重排。 -- **R28**:左栏标题区提供联系人管理抽屉/弹窗,支持新增、编辑、删除,不在会话列表旁常驻联系人表单。 -- **R29**:“新建短信”可搜索联系人;联系人变化后名称由权威联系人 Query 刷新,会话仍以消息号码为身份。 -- **R30**:`failed` 出站气泡提供“重新编辑”,回填正文和原 Line,但不自动提交;再次发送使用新的 operation ID。 -- **R31**:`queued` 和 `unconfirmed` 不提供重发;系统不得自动重发任何短信。 -- **R32**:保留单条删除;入口位于气泡“更多”菜单,桌面和触屏均可访问,并在执行前二次确认。 -- **R33**:删除后重新计算最新摘要和未读数;删除该 remote address 的最后一条短信后,从左栏移除会话并清理失效选择。 -- **R34**:每条气泡显示收发时间与实际 Line 名称;发出气泡额外显示发送状态。 -- **R35**:当前 Managed Line 目录无法解析历史 Line 时,气泡显示明确的历史 Line 回退,不展示误导性当前 Line 名称。 -- **R36**:首次部署未读功能时,把升级前每个现有会话初始化为已读;只有新 schema 生效后持久化的入站短信增加未读数。 - -## Acceptance Criteria - -### 会话、布局与历史 - -- [x] 左栏能识别、切换不同 remote address;同一 remote address 在不同 Line 上的短信只形成一个会话,不同 remote address 不误合并。 -- [x] 切换后右栏只显示当前会话;入站靠左、出站靠右,并按稳定时间/ID 正序呈现。 -- [x] 左栏每项显示联系人名(如有)、号码、最新短信单行摘要、时间和未读角标;新短信把会话移到顶部,纯状态变化不重排。 -- [x] 每条气泡显示实际 Line 和时间,出站同时显示准确状态;已删除 Line 使用明确回退。 -- [x] 历史超过单页上限时,左栏不遗漏旧会话,当前会话可继续加载更早消息且无重复/漏项。 -- [x] 桌面显示双栏;窄屏初始显示列表,点选进入全屏会话并可返回;两种 viewport 均无页面级横向溢出。 - -### Composer、Line 与发送安全 - -- [x] composer 位于会话底部;已有出站历史时默认最近 Line,无历史时默认第一条可发送 Line。 -- [x] 第一条可发送 Line 来自 Managed Line 稳定顺序,不选择离线、SIM 不可用或无短信能力的 Line。 -- [x] 最近 Line 不可用或已删除时保留并解释原选择,不自动改选;管理员明确选择可发送 Line 后才能发送。 -- [x] 当前选择的 Line 原样进入发送请求;不存在 silent fallback、optimistic success 或 mutation 自动重试。 -- [x] 字母型服务地址可查看但不可回复,并显示原因。 -- [x] `failed` 可回填重新编辑,只有再次点击发送才产生新 operation;`queued/unconfirmed` 没有重发入口。 - -### 未读、SSE 与迁移 - -- [x] 新入站持久化后,相应合并会话未读数增加;同一 remote address 跨 Line 累加,出站/状态变化不误增。 -- [x] `sms.received` SSE 让活跃摘要和历史经 HTTP 刷新,事件本身不含通信数据。 -- [x] 当前会话成功显示最新 snapshot 后自动清除该 snapshot 已包含的未读;未打开、后台、加载失败或更晚并发到达的新消息不被清除。 -- [x] 已读结果与未读数量在刷新、浏览器重开和服务重启后保持;乱序/重复 watermark 请求不能清除更晚的未读 marker。 -- [x] v6 旧数据库升级后现有会话 unread=0;升级后新入站按规则未读;Down/再 Up 保留所有业务短信。 - -### 新建、联系人与删除 - -- [x] “新建短信”可选择/搜索联系人或输入合法号码,并打开临时空会话。 -- [x] 未发送临时会话不进入刷新后的列表;首条 `sent/failed/unconfirmed` 持久记录均形成正式会话,持久化前失败不制造空会话。 -- [x] 联系人管理支持新增、编辑、删除;联系人改名、换号或删除后,既有历史仍按原始消息号码稳定显示并合理回退。 -- [x] 每条短信可从触屏友好的“更多”菜单删除并二次确认;删除最新/未读/最后一条后摘要、未读、选择和空态正确。 - -### 权威边界与验证 - -- [x] 发送、接收、状态变化、已读和删除后的页面均从权威 HTTP/SQLite 收敛,不恢复旧整页轮询或让 SSE 成为第二数据源。 -- [x] 既有无过滤与 Line+remote 历史调用保持兼容;新增 remote-only 调用跨 Line 返回,Line-only 继续稳定拒绝。 -- [x] 所有验证只使用 synthetic fixtures、临时 SQLite、httptest、Vitest 和 Playwright;不发送真实短信,不修改 RF、模组、SIM 或网络状态。 - -## Key Decisions - -- **KD1 — Recipient identity**:会话只取持久化 remote address,不取 Line;在缺少国家上下文时不猜测本地号与国际号等价。 -- **KD2 — Explicit Line**:Line 是 composer 的显式发送选择;最近 Line 不可用时 fail closed,由管理员明确改选。 -- **KD3 — Responsive navigation**:桌面双栏,窄屏 list→full-screen detail→back。 -- **KD4 — Durable unread**:未读属于 messages SQLite 的单管理员 unread marker ledger;count 从 marker 派生,SSE 只触发 HTTP 刷新。 -- **KD5 — Automatic read**:只有 detail 可见且最新页成功显示后,才提交该 HTTP snapshot 的 opaque watermark。 -- **KD6 — Temporary new conversation**:首条持久消息之前只存在前端临时状态,本次不保存草稿。 -- **KD7 — Unavailable historical Line**:保留原选择与原因,不自动改变发送身份。 -- **KD8 — Conversation summary**:联系人/号码、最新摘要、时间、未读角标,并按最新消息创建顺序排序。 -- **KD9 — Contact ownership**:联系人管理收进左栏操作区;Contact 只影响显示/选择,不拥有会话身份。 -- **KD10 — Manual resend**:明确失败只允许回填后人工重发;不确定状态不提供重发,每次重发是新 operation。 -- **KD11 — Message deletion**:单条删除位于气泡更多菜单;权威重取驱动摘要、未读和空会话变化。 -- **KD12 — Bubble metadata**:每条气泡显示时间与 Line;出站额外显示状态。 -- **KD13 — Upgrade baseline**:旧历史初始化为已读,未读从 v7 migration 完成后的新入站开始。 - -## Risks and Deferred Items - -- Remote address 使用精确存储值;带/不带国际区号的同一现实号码可能仍显示为两个会话,直到有可靠规范化产品决策。 -- 自动已读必须防止 hidden tab、加载失败、删除 token boundary、同毫秒随机 ID 和并发到信竞态;具体 monotonic unread watermark 契约见 `design.md`。 -- 会话摘要 SQL 需要匹配 remote-prefixed indexes,并在 10,000 条历史容量下验证分页与查询计划。 -- 草稿持久化、会话搜索、置顶、归档、批量删除/已读和每管理员独立 unread ledger 延期。 - -## Out of Scope - -- 修改真实 SMS transport、运营商协议、编码、发送状态机、Line 身份或硬件能力。 -- 真实短信、电话、RF、eUICC、Host VoWiFi、模组持久写或任何 HIL 操作。 -- 输入状态、运营商之外的已读回执、附件、表情反应、群聊等即时通讯专属能力。 -- 本地/国际号码自动等价、联系人合并或历史号码迁移。 diff --git a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-redesign/research/repository-evidence.md b/.trellis/tasks/archive/2026-08/08-10-sms-conversation-redesign/research/repository-evidence.md deleted file mode 100644 index bf929b2..0000000 --- a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-redesign/research/repository-evidence.md +++ /dev/null @@ -1,44 +0,0 @@ -# 短信会话页面仓库证据 - -## 当前页面与前端边界 - -- `web/src/pages/Messages.tsx:30` 当前把发送、联系人和历史拆成三个区域;桌面历史是表格,窄屏是记录卡片,并用手工输入的 Line + remote address 筛选会话。 -- `web/src/messages/conversations.ts:11` 已有未接入页面的会话分组逻辑,但按 `(lineId, remoteAddress)` 分组,与本任务已批准的“只按 remote address、跨线路合并”冲突。 -- `web/src/messages/status.ts:8` 已集中拥有 `queued / unconfirmed / sent / failed / received` 的中文状态展示,实施应扩展或复用它,不在气泡中重写状态映射。 -- `web/src/api/events.ts:4` 把 `messages` SSE topic 映射到所有带 `messages` tag 的生成 Query;新会话摘要与已读状态接口继续使用该 tag 即可沿用现有失效机制。 -- `.trellis/spec/web/frontend/**` 和 ADR 0022 要求页面使用生成的 Fetch/Zod/TanStack Query 契约,HTTP/SQLite 保持权威,SSE 只发送失效与通用 attention,不携带号码、正文或未读数。 - -## 当前公共 API - -- `api/openapi.yaml:409` 的 `GET /api/v1/messages` 使用 `(createdAt, stable ID)` opaque cursor,单页上限 50;现有会话过滤要求 `lineId` 与 `remoteAddress` 同时存在。 -- `api/openapi.yaml:2399` 的 `SMSMessage` 已包含消息 ID、方向、Line、remote address、正文、状态、错误码与时间,足以渲染聊天气泡。 -- `api/openapi.yaml:2473` 的发送请求仍必须显式携带 Line、数字 destination、正文和 operation ID;本任务不改变发送状态机或 transport。 -- `api/openapi.yaml:2272` 的 Contact 使用唯一电话号码;前端可用现有联系人 Query 按精确号码关联名称,不应在 messages 与 contacts 两个 SQLite dataset 之间建立事务或数据库 join。 -- 当前没有会话摘要、跨线路号码历史或标记会话已读的公共 operation。 - -## 当前应用与存储 - -- `internal/application/messaging/service.go:35` 的窄 Repository port 已拥有消息写入、状态推进、分页、统计和删除;会话摘要与已读推进应作为同一业务服务的最小新能力加入。 -- `internal/application/messaging/service.go:372` 当前只接受无过滤或 Line + remote address 成对过滤。新设计需兼容旧组合,同时允许 remote address 单独过滤;Line 单独过滤仍无效。 -- `internal/storage/sqlite/messages.go:241` 有四条分页 SQL 分支,但没有 remote-only 分支或会话摘要查询。 -- `internal/storage/sqlite/migrations/messages/00006_keyset_pagination.sql:1` 已有全局 `(created_at, message_id)` 与 `(line_id, remote_address, created_at, message_id)` 索引;跨线路查询需要新增以 `remote_address` 开头且排序元组一致的索引。 -- messages dataset 的当前 schema version 是 6。新未读状态属于该 dataset,不能新建第六个数据库,也不能依赖跨 dataset 事务。 -- `internal/domain/pagination/cursor.go:15` 的既有 cursor 只编码时间与稳定 ID。会话摘要可使用最后一条消息的时间/ID 复用该 cursor,不需要把号码放入 cursor。 -- `internal/storage/sqlite/managed_lines.go:11` 按 `created_at_utc, id` 返回 Managed Lines;“第一条可发送线路”可在前端从这一稳定顺序中过滤 `ready` 且具备 SMS/Host VoWiFi SMS 能力的首项。 - -## 已批准的产品决策对技术设计的影响 - -- 会话身份是存储后的 `remoteAddress` 精确值,不包含 Line。仓库没有足够国家上下文安全推断 `138...` 与 `+86138...` 等价,因此本任务不做号码猜测或重写。 -- 会话摘要必须由后端分页返回;仅对浏览器当前加载的最近 20/50 条消息分组会漏掉旧会话。 -- 摘要需要返回最后一条消息、未读数和最近一次出站 Line,才能支持列表预览、持久角标和 composer 默认线路,而不加载整个会话。 -- 消息分页的 `(createdAt, ID)` 不能兼任未读到达顺序:时间只有毫秒精度且消息 ID 随机,同一毫秒后入库的 ID 不保证更大。未读需要独立的 `AUTOINCREMENT` marker 序号。 -- 入站消息首次持久化时应在同一 transaction 创建 unread marker;duplicate/replay 不重复创建,删除短信级联删除 marker,count 从 marker 行数派生。 -- Remote-only 最新页返回基于该读取 snapshot 的 opaque unread watermark token;标记已读只删除同一 remote address 上不晚于 token 的 marker,并发新短信总有更大序号。 -- 升级 migration 创建空 unread marker 表,因此旧历史自然初始化为已读;迁移后新入站才计为未读。 -- 联系人名和 Line 名在浏览器分别从现有生成 Query 关联;历史 Line 已删除时显示通用回退,不把另一个 Line 当成原 Line。 - -## 文档与验证影响 - -- ADR 0022 第 6 条明确把短信会话过滤限定为 Line + remote address。本任务需要新增 ADR 0023,明确只按 remote address 的产品会话、持久 unread marker/watermark 和对 ADR 0022 该局部决定的取代。 -- `docs/architecture.md` 应更新 Web、Messages 分页/会话身份、接收入站短信后的未读数据流和机械不变量;唯一 active MVP plan 增加本里程碑。 -- 验证必须使用 synthetic fixtures、临时 SQLite、httptest、Vitest 和 Playwright;不得发送真实短信或执行 RF、设备写入、Host VoWiFi/HIL。 diff --git a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-redesign/task.json b/.trellis/tasks/archive/2026-08/08-10-sms-conversation-redesign/task.json deleted file mode 100644 index dce9069..0000000 --- a/.trellis/tasks/archive/2026-08/08-10-sms-conversation-redesign/task.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "id": "sms-conversation-redesign", - "name": "sms-conversation-redesign", - "title": "重做短信会话页面", - "description": "", - "status": "completed", - "dev_type": null, - "scope": null, - "package": "core", - "priority": "P2", - "creator": "Leon", - "assignee": "Leon", - "createdAt": "2026-08-10", - "completedAt": "2026-08-10", - "branch": null, - "base_branch": "main", - "worktree_path": null, - "commit": null, - "pr_url": null, - "subtasks": [], - "children": [], - "parent": null, - "relatedFiles": [], - "notes": "", - "meta": {} -} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-08/08-11-feishu-channel-binding/check.jsonl b/.trellis/tasks/archive/2026-08/08-11-feishu-channel-binding/check.jsonl deleted file mode 100644 index 3f77c0c..0000000 --- a/.trellis/tasks/archive/2026-08/08-11-feishu-channel-binding/check.jsonl +++ /dev/null @@ -1,7 +0,0 @@ -{"file":".trellis/tasks/08-11-feishu-channel-binding/research/repository-evidence.md","reason":"Review the delivered behavior against every approved product decision and the verified external-platform constraints."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Check success/failure/concurrency/privacy/restart coverage with synthetic external I/O and the targeted-to-broad validation ladder."} -{"file":".trellis/spec/core/backend/api-contracts.md","reason":"Verify generated contract drift, auth/CSRF/no-store boundaries, stable errors, response bounds, and absence of credentials/identities."} -{"file":".trellis/spec/core/backend/storage-and-migrations.md","reason":"Verify fresh/upgrade/Down/reopen behavior, legacy Webhook preservation, conditional persistence invariants, and encrypted fields."} -{"file":".trellis/spec/web/frontend/quality-guidelines.md","reason":"Verify focused Vitest, strict typecheck, production build, synthetic desktop/mobile Playwright, responsive parity, and security audit."} -{"file":".trellis/spec/web/frontend/state-management.md","reason":"Ensure background completion still converges through authoritative HTTP and transient state is neither persisted in the browser nor duplicated."} -{"file":".trellis/spec/core/docs/documentation-ownership.md","reason":"Verify ADR, architecture, plan, handoff, troubleshooting and privacy-safe public documentation are consistent and mechanically checked."} diff --git a/.trellis/tasks/archive/2026-08/08-11-feishu-channel-binding/design.md b/.trellis/tasks/archive/2026-08/08-11-feishu-channel-binding/design.md deleted file mode 100644 index 9552df1..0000000 --- a/.trellis/tasks/archive/2026-08/08-11-feishu-channel-binding/design.md +++ /dev/null @@ -1,167 +0,0 @@ -# 飞书通知渠道一键绑定:技术设计 - -## 1. 设计目标 - -在现有单向通知系统中增加飞书应用私聊渠道。管理员从通知页发起绑定,Simplus 返回飞书官方短期验证 URL;管理员授权后,Simplus 自动取得最小权限应用凭据,向授权用户发送一次测试消息,并只在测试成功后持久化可用渠道。 - -设计继续保持以下边界:单管理员可信 LAN、HTTP/SQLite 权威、无公网回调、无入站消息、无群聊、凭据不回显、现有 Webhook 渠道兼容。 - -## 2. 总体数据流 - -```text -Notifications page - -> POST /notification-channel-bindings/feishu (admin + CSRF) - -> notification binding service - -> Feishu device-flow begin (fixed accounts.feishu.cn endpoint) - <- verification URL + opaque device code + expiry - <- waiting state + verification URL (Cache-Control: no-store) - -Administrator opens Feishu URL and approves - -> background bounded poll - <- App ID + App Secret + authorizing open_id - -> validate Feishu tenant and bounded credential shapes - -> obtain tenant token + send one test message to open_id - -> encrypt App ID/App Secret/open_id with instance key - -> insert feishu_app_notification_channels row with delivery=success - -> publish notifications invalidation - <- status becomes succeeded; Web refetches channel list -``` - -The verification URL and opaque device code are transient workflow state. They never become channel data. A formal channel is created only after authorization and the test delivery both succeed. - -## 3. Application boundaries - -Keep the feature under `internal/application/notification` and extend the existing consumer-owned ports: - -- `Store` gains explicit read/write/delete/delivery operations for the Feishu-app channel variant while continuing to expose a unified channel view. -- A narrow `FeishuRegistrar` owns only device-flow begin/poll/cancel behavior. -- A narrow `FeishuMessenger` owns only sending one bounded text message to an `open_id` using App ID/App Secret. -- The notification service owns the binding state machine, default channel configuration, encryption labels, persistence ordering and stable errors. -- `httpapi.Server` maps the application state/errors to OpenAPI and continues to require administrator authentication/CSRF. -- `cmd/simplusd` injects the process lifetime context and a bounded notification-change callback; the application package does not import HTTP API or realtime packages. - -The external client is project-owned and fixed-purpose. It does not expose arbitrary Feishu paths, permissions, callbacks, request bodies or tokens to Web/API callers. - -## 4. Binding state machine - -Only one binding attempt may be active per Simplus instance. Sequential successful bindings remain allowed and produce independent channels/apps. - -```text -idle/terminal --start--> waiting --authorization--> testing --test+persist--> succeeded - | | - +-- denied/expired ------+--> failed/expired - +-- cancel -----------------> cancelled - +-- process exit ------------> ephemeral state lost -``` - -States exposed publicly are `idle`, `waiting`, `testing`, `succeeded`, `failed`, `expired`, and `cancelled`. - -- `POST` replaces an idle or terminal state. Starting while `waiting` or `testing` returns a stable conflict. -- `DELETE` is idempotent while idle/terminal and cancels only a `waiting` attempt. Once testing begins, cancellation is rejected so the service has one clear persistence outcome. -- The server holds a generation token under a mutex. A stale/cancelled goroutine must re-check ownership before testing or persisting. -- The URL is retained only while `waiting` and cleared on every terminal/testing transition. Device code and returned credentials are never included in public state. -- Process restart cancels all pending work and yields `idle`; no partial database row exists. An external app may already have been created if the process died after authorization, which is surfaced as a documented residual risk rather than guessed/recovered. - -Stable terminal error codes are bounded and credential-free, including denial, expiry, unsupported Lark tenant, invalid provider result, provider/network failure, test-delivery failure and persistence failure. Raw response bodies and provider error descriptions remain internal and are not logged when they may contain identifiers. - -## 5. Feishu protocol contract - -The registrar follows the official RFC 8628-style one-click application flow: - -- exact HTTPS account host `accounts.feishu.cn`; -- fixed registration endpoint and `PersonalAgent` archetype; -- `createOnly=true` so an existing app can never be selected or modified; -- prefilled application name/description identifying it as a Simplus notification app; -- minimal base template (`preset=false`); -- exactly one tenant permission: `im:message:send_as_bot`; -- no user permissions, events, callbacks, WebSocket or webhook configuration; -- returned verification URL must be HTTPS, have the exact approved host, contain no userinfo/fragment, and remain within the public length bound; -- poll interval, `slow_down`, denial, expiry and context cancellation follow the provider result, all within the provider expiry and process context. - -The messenger uses only: - -1. the fixed tenant-access-token endpoint at `open.feishu.cn`; -2. the fixed message-create endpoint with `receive_id_type=open_id` and `msg_type=text`. - -Both clients reject redirects, use explicit timeouts, cap response bodies, validate required fields and numeric success codes, and never cache tokens beyond a single delivery. This ensures a deleted channel has no reusable long-lived provider token retained by Simplus. - -MVP rejects `tenant_brand=lark`; Lark international endpoints and domains remain out of scope. - -## 6. Persistent model and migration - -Append core migration v23 with a separate `feishu_app_notification_channels` table. Do not rebuild or reinterpret the v12 `notification_channels` Webhook table. - -The new table contains: - -- random `channel_...` ID and display name; -- independently encrypted App ID, App Secret and recipient `open_id` ciphertexts; -- enabled flag and JSON event-kind set; -- last delivery time/status/error code; -- created/updated timestamps. - -SQLite constraints bound IDs, display name, ciphertext sizes, event JSON, states and error-code length. Provider and target kind are implicit constants of this table. Cross-table ID collision is rejected by the store/service even though the random 128-bit ID already makes it negligible. - -The domain channel gains a discriminant `DeliveryMode` (`webhook` or `feishu_app`). Existing Webhook fields remain valid only for `webhook`; encrypted application fields remain valid only for `feishu_app`. Service methods use exhaustive switches rather than placeholder Webhook values. - -List/read/delete/delivery-status store operations merge or target both tables. The public list remains one ordered channel collection. Down migration drops only the new table and restores core schema version 22; legacy Webhook data is untouched. Because old binaries cannot represent app channels, rollback across v23 requires the explicit Down migration and loses local app bindings, while the corresponding Feishu-side apps remain for manual cleanup. - -Encryption labels are versioned and field-specific, for example: - -```text -notification-channel:v2:<channel-id>:feishu-app-id -notification-channel:v2:<channel-id>:feishu-app-secret -notification-channel:v2:<channel-id>:feishu-recipient-open-id -``` - -Deletion is logical SQLite deletion under the project's existing storage contract; it stops future use and removes the active rows but does not claim forensic erasure from WAL, free pages or backups. - -## 7. Public HTTP contract - -Extend `NotificationChannel` compatibly with required discriminants: - -- `deliveryMode`: `webhook | feishu_app`; -- `targetType`: `webhook | authorized_user`. - -No App ID, App Secret, access token, `open_id`, device code or verification URL appears in ordinary channel list/read responses. For compatibility, existing Webhook fields stay present; app channels use the bounded provider endpoint hint while the Web renders `targetType` instead of labeling it a Webhook. - -Add the singular resource `/api/v1/notification-channel-bindings/feishu`: - -- `GET`: return current transient state; administrator authentication; no CSRF; -- `POST`: begin a new attempt; administrator authentication + CSRF; -- `DELETE`: cancel a waiting attempt; administrator authentication + CSRF. - -The response always has a state plus bounded `verificationUrl`, `expiresAt`, `channelId`, and `errorCode` fields, using empty values where not applicable to match current OpenAPI conventions. All responses containing or describing a binding carry `Cache-Control: no-store`. - -Existing `PUT /notification-channels/{id}` remains the settings mutation for both modes. For `feishu_app`, empty `webhookUrl`/`signingSecret` preserve credentials and non-empty credential fields are rejected; display name, enabled state and event kinds can change without reauthorization. Provider/delivery mode cannot change in place. - -## 8. Web experience - -The Notifications page gains a primary “绑定飞书私聊” section above the existing manual Webhook form. - -- One click starts the flow; no name/event form is shown. -- `waiting` renders the exact short-lived URL in an admin-only, copyable control, an external-link button, and its expiry. -- TanStack Query polls the transient status only while `waiting` or `testing`, stops in terminal states, and does not run in the background tab. This is a bounded workflow poll, not a second persistent state store. -- Success invalidates the generated notification-channel list query and shows the new default channel. -- Failure/denial/expiry/cancel display stable localized text and allow generating a new URL. -- Manual enterprise-WeChat/Feishu Webhook creation remains available as an advanced/fallback section. -- Channel rows/cards display “授权用户私聊” for app mode and the existing host hint for Webhook mode. -- Add an edit modal for display name and event kinds, reusing the generated update mutation for either mode. -- Deleting an app channel uses a mode-specific confirmation explaining that only the Simplus binding is removed and the Feishu-side app remains. - -The wide table and compact card expose the same status, target, events and actions. The page never writes the verification URL or credentials to browser persistent storage. - -## 9. Compatibility and documentation - -- Existing v12 Webhook rows, API mutations, test delivery, enable/disable and delete behavior remain unchanged. -- No existing user is migrated or forced to rebind. -- Add ADR 0025 for the durable one-click/private-DM/minimal-permission/local-only-unbind decision. -- Update `docs/product.md`, `docs/architecture.md`, the active MVP plan, handoff and stable troubleshooting codes without publishing any real account, URL, token or screenshot. -- No hardware, SMS, call, RF, modem-persistent action or HIL is involved. - -## 10. Failure and rollback considerations - -- Authorization or test failure creates no local channel. The externally created app may remain and is explicitly documented. -- A successful test followed by a database failure may deliver one test message without creating a local channel; the state reports persistence failure and never claims success. -- No automatic retry is performed for the test message or normal notification mutation. Explicit future tests create a new provider message. -- Provider outages do not affect existing Webhook channels or other Simplus functions. -- Rolling back the binary requires core v23 Down first. This removes only local app-channel rows; administrators must manually handle Feishu apps if desired. diff --git a/.trellis/tasks/archive/2026-08/08-11-feishu-channel-binding/implement.jsonl b/.trellis/tasks/archive/2026-08/08-11-feishu-channel-binding/implement.jsonl deleted file mode 100644 index 6727207..0000000 --- a/.trellis/tasks/archive/2026-08/08-11-feishu-channel-binding/implement.jsonl +++ /dev/null @@ -1,9 +0,0 @@ -{"file":".trellis/tasks/08-11-feishu-channel-binding/research/repository-evidence.md","reason":"Carries the approved product decisions, current notification anchors, official Feishu device-flow facts, Hermes comparison, and privacy conclusions."} -{"file":".trellis/spec/core/backend/application-boundaries.md","reason":"Defines narrow consumer-owned ports, context/cancellation, external side-effect ordering, stable errors, and fail-closed application behavior."} -{"file":".trellis/spec/core/backend/api-contracts.md","reason":"Requires OpenAPI-first HTTP, administrator/CSRF enforcement, bounded clients and responses, stable errors, and sensitive-field exclusion."} -{"file":".trellis/spec/core/backend/storage-and-migrations.md","reason":"Owns core SQLite migration/Down/reopen rules and the encrypted notification-secret persistence contract."} -{"file":".trellis/spec/web/frontend/component-guidelines.md","reason":"Owns responsive table/card parity, typed forms, destructive confirmations, external actions, and sensitive rendering rules."} -{"file":".trellis/spec/web/frontend/hook-guidelines.md","reason":"Requires generated TanStack Query operations, no mutation retries, scoped invalidation, and bounded transient polling cleanup."} -{"file":".trellis/spec/web/frontend/state-management.md","reason":"Keeps HTTP/SQLite authoritative and transient binding state page/server-owned without browser persistence."} -{"file":".trellis/spec/web/frontend/type-safety.md","reason":"Requires generated OpenAPI/Zod contracts and forbids duplicated or cast network payloads."} -{"file":".trellis/spec/core/docs/documentation-ownership.md","reason":"Routes the durable product, architecture, ADR, active-plan, handoff, and troubleshooting updates to their canonical owners."} diff --git a/.trellis/tasks/archive/2026-08/08-11-feishu-channel-binding/implement.md b/.trellis/tasks/archive/2026-08/08-11-feishu-channel-binding/implement.md deleted file mode 100644 index d1bdb6d..0000000 --- a/.trellis/tasks/archive/2026-08/08-11-feishu-channel-binding/implement.md +++ /dev/null @@ -1,87 +0,0 @@ -# 飞书通知渠道一键绑定:实施计划 - -## 1. 先固化持久决策 - -- [x] 新增 ADR 0025,记录一键创建应用、授权用户私聊、最小出站权限、无入站能力、仅本地解绑和既有 Webhook 兼容。 -- [x] 在 `docs/plans/active/mvp.md` 增加新的未完成里程碑,实施完成后再标记完成。 - -## 2. 扩展领域与存储 - -- [x] 为 notification domain 增加穷尽的 `DeliveryMode` 变体和飞书应用密文字段,不复用 Webhook 字段保存应用凭据。 -- [x] 新增 core v23 migration,创建独立 `feishu_app_notification_channels` 表并提供 Down;不重建 v12 Webhook 表。 -- [x] 扩展 SQLite store,使 list/read/delete/delivery status 可以安全合并两个表,upsert 根据 mode 写入唯一所有者表,并拒绝跨表 ID 冲突。 -- [x] 增加 fresh schema、v22→v23 升级、Down、旧 Webhook 保留、app channel reopen、删除与密文不回显测试。 - -## 3. 实现窄化飞书客户端与绑定状态机 - -- [x] 在 notification application 内定义 `FeishuRegistrar` 与 `FeishuMessenger` 小接口,并实现固定主机/路径、HTTPS、无重定向、超时、响应上限和严格结果校验。 -- [x] 实现 RFC 8628 begin/poll/slow-down/expiry/cancel,固定 `createOnly`、应用预设、`preset=false` 和唯一 `im:message:send_as_bot` 权限;拒绝 Lark tenant、空/非法 App ID/App Secret/open_id 和非飞书验证 URL。 -- [x] 实现 tenant token + `open_id` 文本私聊发送,不缓存 provider token,不将响应正文/凭据写入日志。 -- [x] 实现单实例绑定状态机、generation fence、进程上下文取消、终态清理和稳定错误码。 -- [x] 授权成功后先用瞬时凭据发送一次绑定测试,再以字段独立标签加密并持久化默认“飞书私聊”+全部事件+enabled+success 状态;测试/持久化失败不创建成功渠道。 -- [x] 扩展现有 Create/Update/Test/Notify/Delete,使 Webhook 与 app mode 都走穷尽分支;app mode 的设置更新不得接受替换 Webhook/签名字段。 -- [x] 用合成 RoundTripper/clock/store 覆盖成功、拒绝、过期、slow-down、Lark、非法结果、网络/超大响应、重复 start、cancel race、stale generation、测试失败、持久化失败、正常通知与删除。 - -## 4. 扩展 OpenAPI、HTTP 与装配 - -- [x] 先修改 `api/openapi.yaml`:channel discriminants、Feishu binding state schema、singular GET/POST/DELETE resource 和稳定错误响应。 -- [x] 运行 `make generate`,只通过生成器更新 Go/Web contract;不得手改 generated 文件。 -- [x] 扩展 `NotificationManager` 与 handler:管理员认证、POST/DELETE CSRF、`Cache-Control: no-store`、冲突/供应商/终态错误映射、成功后的 notifications invalidation。 -- [x] 在 `cmd/simplusd` 注入进程 context、窄化外部客户端和后台成功变更回调;shutdown 必须取消等待中的 poll。 -- [x] 增加真实 router 的 auth/CSRF/no-store/响应隐私/状态映射/后台成功可见性测试,确认 API 从不返回 App ID/App Secret/open_id/device code。 - -## 5. 完成 Web 绑定与渠道编辑体验 - -- [x] 使用生成 Query/Mutation options 增加“绑定飞书私聊”区域;无本地预填表单。 -- [x] waiting 状态展示可复制 URL、外链按钮和过期时间;仅 waiting/testing 使用前台 TanStack Query 状态轮询并在终态停止。 -- [x] 成功后失效 channel list;失败、拒绝、过期、取消和重复发起显示稳定中文状态并允许重试。 -- [x] 保留手工企业微信/飞书 Webhook 创建作为次级入口。 -- [x] 表格/移动卡片根据 `deliveryMode`/`targetType` 显示 Webhook 或“授权用户私聊”。 -- [x] 增加编辑名称/事件 modal,复用现有 update mutation;启停、测试、删除保持逐行 busy/error。 -- [x] app channel 删除确认明确“只删除 Simplus 绑定,飞书应用保留”。 -- [x] 更新 `web/src/api/errors.ts` 或窄化状态映射,不显示原始 provider 错误。 -- [x] 用 Vitest 覆盖 idle→waiting→testing→success、终态、URL 隐私、query invalidation、编辑、模式化删除提示和响应式必要字段;用 synthetic Playwright 覆盖桌面/移动主路径和无页面横向溢出。 - -## 6. 更新规范所有者与公开说明 - -- [x] 更新 `docs/product.md` 的通知渠道体验与非目标措辞。 -- [x] 更新 `docs/architecture.md` 的通知数据流、瞬时绑定状态、独立表与密文边界。 -- [x] 更新 active MVP plan、`docs/handoff.zh-CN.md` 与 `docs/troubleshooting.md` 的稳定绑定错误;不加入真实 URL、账号、open_id、截图或日志。 -- [x] 按 `trellis-update-spec` 将短期外部授权、测试后落库、凭据无回显和迁移/验证矩阵沉淀到 backend API code-spec。 - -## 7. 验证顺序 - -先运行最窄检查,再扩展: - -```bash -go test ./internal/application/notification -go test ./internal/storage/sqlite -go test ./internal/api/httpapi -make check-format -make verify-generated -corepack pnpm --dir web typecheck -corepack pnpm --dir web test -corepack pnpm --dir web build -make lint -make test -make web-e2e -make check-docs -make security -``` - -检查生成前后差异,确认 `git diff` 中没有手工 generated 修改、真实凭据/URL/身份或无关文件。 - -## 8. 风险文件与回滚点 - -- `internal/storage/sqlite/migrations/core/00023_*`:回滚必须先执行 Down;Down 只保留旧 Webhook 渠道并移除本地 app binding 表。 -- `api/openapi.yaml` 及生成树:任何 contract 变更都必须原子生成并通过 drift 检查。 -- `internal/application/notification/*`:测试必须证明 background completion、cancel/restart 和 test-before-persist 顺序。 -- `web/src/pages/Notifications.tsx`:必须同时保护桌面表格、移动卡片、旧手工渠道和新绑定状态。 -- 不运行真实飞书注册或真实投递作为自动化检查。产品内的真实测试消息只由管理员主动打开链接并授权触发;如需由开发代理执行现场验证,必须另行取得明确授权并只报告脱敏结论。 - -## 9. `task.py start` 前检查 - -- [x] `prd.md` 已完成收敛重写且无阻塞问题。 -- [x] `design.md` 与本实施清单已由用户审阅。 -- [x] `implement.jsonl` 和 `check.jsonl` 均只包含真实 spec/research 上下文条目。 -- [x] 用户在最终规划摘要之后另行明确批准实施。 diff --git a/.trellis/tasks/archive/2026-08/08-11-feishu-channel-binding/prd.md b/.trellis/tasks/archive/2026-08/08-11-feishu-channel-binding/prd.md deleted file mode 100644 index 183c25c..0000000 --- a/.trellis/tasks/archive/2026-08/08-11-feishu-channel-binding/prd.md +++ /dev/null @@ -1,77 +0,0 @@ -# 飞书通知渠道一键绑定 - -## Goal - -为飞书通知提供类似 Hermes Scan-to-Create 的零表单绑定体验:Simplus 生成短期有效的飞书验证 URL,管理员打开并授权后,系统自动创建最小权限应用、绑定授权用户私聊、验证投递并安全保存渠道,无需进入飞书开发者后台或手工粘贴 App ID/App Secret。 - -## Confirmed Background - -- Simplus 将企业微信与飞书限定为单向出站通知,不接受入站命令,且 API/Web 不回显 credential(`docs/product.md:81-87`、`docs/decisions/0005-management-completion-mihomo-notifications.md:23-26`)。 -- 当前飞书实现只接受群自定义机器人 Webhook 与可选签名,服务端限定 `open.feishu.cn/open-apis/bot/v2/hook/*`(`internal/application/notification/service.go:71-99`、`internal/application/notification/service.go:269-298`),Web 只提供手工录入(`web/src/pages/Notifications.tsx:55-66`)。 -- 现有 Webhook/签名通过实例主密钥做带标签 AEAD 加密;core v12 模型以 Webhook 为必填,不能无歧义地承载应用凭据(`internal/security/secretbox/keyring.go:63-91`、`internal/storage/sqlite/migrations/core/00012_notification_channels.sql:2-15`)。 -- 飞书官方的一键创建应用基于 RFC 8628,先返回默认约 600 秒有效的验证 URL,授权后返回 App ID、App Secret 与授权人的 `open_id`;最小模板可以只申请 `im:message:send_as_bot`:<https://github.com/larksuite/oapi-sdk-go#one-click-app-registration>。 -- 飞书发送消息 API 支持以 `open_id` 建立用户私聊;群聊则要求先把机器人加入群并选择 `chat_id`,不符合本次单 URL MVP:<https://open.feishu.cn/document/server-docs/group/chat/intro>。 -- Simplus 当前删除渠道只删除本地记录(`internal/application/notification/service.go:153-165`、`internal/storage/sqlite/notifications.go:75-81`)。飞书自建应用需在开发者后台处理,已发布应用还可能要求企业管理员先停用:<https://open.feishu.cn/document/develop-process/operations-analysis/transfer-owner-and-collaborative-members>。 -- Hermes 的公开飞书适配器使用同类设备授权创建应用;其入站 WebSocket 与 home channel 选择不属于 Simplus 的单向通知目标。 - -## Requirements - -### R1 — Zero-form binding entry - -已登录管理员可以直接发起飞书绑定,不先填写名称或事件。Simplus 返回可复制、可打开且带明确过期时间的飞书验证 URL,并提供等待、测试、成功、拒绝、过期、失败与取消状态。 - -### R2 — Least-privilege Feishu application - -每次绑定只允许创建新应用,使用最小基础模板并仅申请应用身份发送消息权限。MVP 只接受飞书中国租户;不得订阅事件、配置回调、建立 WebSocket/Webhook 或提供远程命令能力。 - -### R3 — Authorized-user private destination and defaults - -通知目标固定为完成授权的用户 `open_id` 私聊。成功渠道默认名为“飞书私聊”、立即启用,并订阅 `sms.received`、`sms.failed`、`call.incoming`、`call.missed`、`system.degraded` 全部现有事件。 - -### R4 — Test-before-persist and secret handling - -授权结果必须先通过类型/租户校验并向授权用户发送一次绑定测试消息;只有测试成功后才能创建正式渠道。App ID、App Secret 与目标 `open_id` 使用字段独立的实例密钥标签加密,不通过普通 API、Web、SSE、日志或错误文本回显。 - -### R5 — Bounded and recoverable workflow - -同一实例同一时刻最多一个绑定尝试。URL/device code 仅作为有界内存状态;拒绝、过期、取消、网络/供应商错误、非法结果、测试失败、持久化失败、重复发起或服务重启都不得留下被呈现为可用的半配置渠道。 - -### R6 — Post-binding management and local-only unbind - -绑定后可修改显示名称与事件订阅,无需重新授权;启停、显式测试和投递状态沿用现有渠道能力。删除只移除 Simplus 本地记录并停止使用本地凭据,不尝试停用或删除飞书应用;确认界面必须说明飞书侧应用仍保留。 - -### R7 — Existing Webhook compatibility - -企业微信与飞书手工 Webhook 渠道继续支持创建、凭据替换、编辑设置、启停、测试、投递状态和删除;既有记录不迁移、不重绑,新增应用渠道不能复用或改变 Webhook 密文字段语义。 - -### R8 — Authenticated and privacy-bounded surface - -绑定读取要求管理员会话,发起/取消要求 CSRF;含绑定状态的响应必须禁止缓存。浏览器不把验证 URL 或任何凭据写入持久存储,公共错误只使用稳定、长度有界且不含账号/身份/供应商正文的代码。 - -## Acceptance Criteria - -- [ ] AC1 / R1:从通知页点击“绑定飞书私聊”无需填写表单即可获得一个有过期时间的可复制/打开 URL;页面能观察到完整终态。 -- [ ] AC2 / R2:合成协议测试证明创建请求为 create-only、minimal preset,权限集合严格等于 `im:message:send_as_bot`,且没有事件、回调或入站 transport;Lark 结果被拒绝。 -- [ ] AC3 / R3:合成授权结果中的 `open_id` 是首次测试消息的唯一接收者;成功行的名称、enabled 和五类事件与默认值完全一致,流程不请求或展示群聊。 -- [ ] AC4 / R4:测试失败时数据库中没有新渠道;成功时三个应用字段均为非明文密文,渠道列表/状态响应不包含 App ID、App Secret 或 `open_id`,最后投递状态为 success。 -- [ ] AC5 / R5:拒绝、过期、取消、非法响应、网络失败、重复 start、stale background completion、测试/存储失败和进程取消均有确定结果;只有成功路径产生一条正式记录。 -- [ ] AC6 / R6:管理员可编辑名称/事件并继续测试或启停;删除 app channel 前能看到“只删除本地绑定”的提示,删除后 Simplus 不再投递,飞书侧应用不被远程修改。 -- [ ] AC7 / R7:旧 Webhook fixture/升级数据在新版本中内容与行为保持,手工企业微信/飞书的创建、更新、投递和删除回归全部通过。 -- [ ] AC8 / R8:真实 router 测试覆盖 auth、CSRF、`Cache-Control: no-store`、稳定错误和敏感字段缺失;Web 测试证明 URL 只存在于瞬时页面状态且桌面/移动端均无布局回归。 -- [ ] AC9:OpenAPI/Go/Web 生成一致性、后端/迁移测试、Web typecheck/Vitest/build、synthetic desktop/mobile E2E、文档检查和依赖安全检查通过;自动化过程不创建真实飞书应用或发送真实消息。 - -## Out of Scope - -- 飞书入站消息、事件订阅、卡片回调、审批、通讯录同步或远程控制。 -- 群聊发现、群选择、邀请机器人入群或向群聊投递。 -- Lark 国际版一键绑定。 -- 删除或强制迁移现有群自定义机器人 Webhook 渠道。 -- 飞书应用的通用管理后台、已有应用接管、任意权限申请或任意 OpenAPI 代理。 -- 自动停用、撤销发布或删除飞书侧应用。 - -## Risks and Deferred Items - -- 管理进程可能在飞书已创建应用、但本地测试或持久化完成前退出;本地不会产生可用渠道,但飞书侧可能留下需人工清理的应用。MVP 明确提示该残留,不引入入站恢复或远程删除权限。 -- 一次测试投递成功后数据库仍可能写入失败;用户可能收到测试消息但看不到渠道。系统必须报告持久化失败,不能据此声称绑定成功或自动重复发送。 -- 本地删除遵循现有 SQLite 逻辑删除语义,不承诺从 WAL、空闲页或备份中取证级擦除;所有残留值仍是实例密钥加密的密文。 -- 真实飞书注册/投递只由管理员在产品内主动打开 URL 并授权触发;自动化验收使用合成 provider,不包含现场账号、URL、open_id、截图或原始日志。 diff --git a/.trellis/tasks/archive/2026-08/08-11-feishu-channel-binding/research/repository-evidence.md b/.trellis/tasks/archive/2026-08/08-11-feishu-channel-binding/research/repository-evidence.md deleted file mode 100644 index d422dc6..0000000 --- a/.trellis/tasks/archive/2026-08/08-11-feishu-channel-binding/research/repository-evidence.md +++ /dev/null @@ -1,42 +0,0 @@ -# 飞书一键绑定:仓库与平台证据 - -## 用户已确认的产品决策 - -- 目标体验参考 Hermes:Simplus 生成一个 URL,管理员打开后完成飞书渠道配置与绑定。 -- MVP 只绑定完成授权的用户私聊,不支持群聊发现、选择或投递。 -- 绑定前零表单:默认渠道名为“飞书私聊”,默认订阅全部五类现有事件;绑定后可以编辑名称和事件。 -- 解绑只删除 Simplus 本地绑定,飞书侧自动创建的应用保留,并在 UI 中明确提示。 - -## 当前仓库事实 - -- 产品与 ADR 将企业微信/飞书限定为单向出站通知,禁止入站命令并要求 credential 不回显:`docs/product.md:81-87`、`docs/decisions/0005-management-completion-mihomo-notifications.md:23-26`。 -- 当前飞书实现只支持群自定义机器人 Webhook 与可选签名:`internal/application/notification/service.go:71-99`、`internal/application/notification/service.go:239-298`。 -- 当前 Web 只提供手工 Webhook 表单:`web/src/pages/Notifications.tsx:55-66`。 -- 当前公共契约把通知渠道建模为 Webhook:`api/openapi.yaml:367-408`、`api/openapi.yaml:2920-2954`。 -- Webhook/签名使用实例主密钥的带标签 AES-GCM 密文,列表不回显原值:`internal/security/secretbox/keyring.go:63-91`、`internal/application/notification/service.go:308-309`。 -- core v12 的 `notification_channels` 强制 `webhook_ciphertext NOT NULL`;直接把应用凭据塞入该列会破坏语义与旧逻辑:`internal/storage/sqlite/migrations/core/00012_notification_channels.sql:2-15`。 -- 当前删除通知渠道只删除本地记录:`internal/application/notification/service.go:153-165`、`internal/storage/sqlite/notifications.go:75-81`。 -- 通知 API 已使用管理员 cookie 与变更请求 CSRF 校验:`internal/api/httpapi/server.go:1754-1844`、`internal/api/httpapi/server.go:1080-1101`。 -- Web 以生成的 TanStack Query 契约为准,HTTP/SQLite 是权威状态;页面不得直接 `fetch`:`.trellis/spec/web/frontend/hook-guidelines.md`、`.trellis/spec/web/frontend/state-management.md`。 - -## 飞书官方能力 - -- 飞书官方提供基于 OAuth 2.0 Device Authorization Grant(RFC 8628)的一键创建应用流程。流程先产生约 600 秒有效的验证 URL,授权完成后返回 App ID、App Secret 与授权人的 `open_id`:<https://open.feishu.cn/document/mcp_open_tools/integrating-agents-with-feishu/create-an-app-in-one-click-java>。 -- 官方 Go SDK 的一键注册支持 `CreateOnly`、应用名称预填、最小基础模板以及增量权限;`Preset=false` 配合唯一租户权限 `im:message:send_as_bot` 可避免平台默认的更宽权限、事件与回调:<https://github.com/larksuite/oapi-sdk-go#one-click-app-registration>。 -- 官方发送消息 API 支持 `receive_id_type=open_id`;向用户首次发送会形成机器人与该用户的 p2p 会话:<https://open.feishu.cn/document/server-docs/im-v1/message/create>、<https://open.feishu.cn/document/server-docs/group/chat/intro>。 -- 群消息需要先将机器人加入群并取得 `chat_id`,因此不属于“一条 URL 即完成”的 MVP。 -- 删除飞书自建应用是开发者后台操作;已发布应用可能先需企业管理员停用,个人版也可能无法删除已发布应用:<https://open.feishu.cn/document/develop-process/operations-analysis/transfer-owner-and-collaborative-members>。 - -## Hermes 参考实现 - -- `NousResearch/hermes-agent` 在提交 `936dd7346fd7fd8107af1ce7fc019c07c001c1bd` 的飞书适配器中使用同一设备授权端点,展示验证 URL,轮询授权结果并保存 App ID/App Secret。 -- Hermes 的完整消息网关还启用 WebSocket/入站消息并另行选择 home channel;Simplus 只复用“一键创建应用”的体验,不复制入站网关或群聊能力。 - -## 技术结论 - -- 设备授权是出站轮询,不要求 Simplus 提供公网 OAuth 回调、入站 Webhook 或 WebSocket,符合可信 LAN 与暂停内建 HTTPS 的现状。 -- 待授权状态应只驻留内存;验证 URL、device code、App Secret 与 `open_id` 不进入 SQLite、SSE 或日志。只有授权结果通过校验且测试私聊发送成功后,才创建正式渠道记录。 -- 新的应用渠道使用 core v23 独立表,而不是重建/复用旧 Webhook 表。这样升级不触碰旧渠道,旧版回滚只需显式 Down 丢弃无法表示的新应用渠道。 -- App ID、App Secret 与 `open_id` 均以独立标签加密;列表只返回 `deliveryMode=feishu_app` 与 `targetType=authorized_user`,不返回凭据或用户标识。 -- 平台 I/O 使用项目自有的窄化、固定主机、无重定向、有超时与响应大小上限的客户端。官方 SDK/协议作为行为依据,但不引入通用飞书 API 或入站 Channel 模块。 -- 自动化验证全部使用合成 transport/响应;不创建真实飞书应用,也不发送真实消息。真实测试消息只会在管理员主动打开验证 URL并确认授权后由产品流程发出。 diff --git a/.trellis/tasks/archive/2026-08/08-11-feishu-channel-binding/task.json b/.trellis/tasks/archive/2026-08/08-11-feishu-channel-binding/task.json deleted file mode 100644 index 9fbaeee..0000000 --- a/.trellis/tasks/archive/2026-08/08-11-feishu-channel-binding/task.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "id": "feishu-channel-binding", - "name": "feishu-channel-binding", - "title": "飞书通知渠道一键绑定", - "description": "", - "status": "completed", - "dev_type": null, - "scope": null, - "package": "core", - "priority": "P2", - "creator": "Leon", - "assignee": "Leon", - "createdAt": "2026-08-11", - "completedAt": "2026-08-12", - "branch": null, - "base_branch": "main", - "worktree_path": null, - "commit": null, - "pr_url": null, - "subtasks": [], - "children": [], - "parent": null, - "relatedFiles": [], - "notes": "", - "meta": {} -} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-08/08-12-dji-4g-cellular/check.jsonl b/.trellis/tasks/archive/2026-08/08-12-dji-4g-cellular/check.jsonl deleted file mode 100644 index f21ddab..0000000 --- a/.trellis/tasks/archive/2026-08/08-12-dji-4g-cellular/check.jsonl +++ /dev/null @@ -1,11 +0,0 @@ -{"file":".trellis/spec/core/backend/index.md","reason":"Verify layer ownership, typed boundaries, generated sources and stable error behavior."} -{"file":".trellis/spec/core/backend/application-boundaries.md","reason":"Audit stable identity, no model/path leakage, persist-before-side-effect and fail-closed routing."} -{"file":".trellis/spec/core/backend/api-contracts.md","reason":"Audit OpenAPI and Agent client/server validation, correlation, timeouts and private fields."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Run the targeted-to-broad validation ladder and inspect hardware/privacy/concurrency cases."} -{"file":".trellis/spec/core/infra/hardware-and-hil-safety.md","reason":"Ensure ordinary checks have no hardware side effects and HIL/production evidence gates remain exact."} -{"file":".trellis/spec/core/infra/containers-and-privileges.md","reason":"Verify Agent private state without capability/network/device-boundary expansion."} -{"file":".trellis/spec/core/docs/documentation-ownership.md","reason":"Check ADR, architecture, active plan, compatibility, development and troubleshooting owners."} -{"file":".trellis/spec/core/docs/privacy-and-publication.md","reason":"Review diff/docs/tests/logs for phone, SIM, device, PDU, path, raw HIL or topology leakage."} -{"file":".trellis/spec/web/frontend/quality-guidelines.md","reason":"Verify Modems/Messages Vitest, typecheck, build, desktop/mobile E2E and generated drift."} -{"file":".trellis/spec/web/frontend/type-safety.md","reason":"Check generated union usage and absence of duplicated/cast network response types."} -{"file":".trellis/tasks/08-12-dji-4g-cellular/research/repository-evidence.md","reason":"Cross-check implementation against the researched gaps, state namespace and no-fallback design."} diff --git a/.trellis/tasks/archive/2026-08/08-12-dji-4g-cellular/design.md b/.trellis/tasks/archive/2026-08/08-12-dji-4g-cellular/design.md deleted file mode 100644 index f8dc17b..0000000 --- a/.trellis/tasks/archive/2026-08/08-12-dji-4g-cellular/design.md +++ /dev/null @@ -1,585 +0,0 @@ -# 大疆 QDC507 原生蜂窝短信:技术设计 - -## 1. Design Goals and Invariants - -### 1.1 Problem statement - -用最小、可证据化的机制把 QDC507 接入现有 `ManagedModem -> Line -> messaging` 纵切:稳定识别当前模组与 SIM,显式开启 RF 后观察蜂窝注册,通过同一 SIM 的原生 AT PDU transport 收发短信,并保证 QDC507 不会走 Host VoWiFi。 - -### 1.2 Non-negotiable invariants - -1. 只有 `simplus-agent` 的 discovery/registry/adapter/driver 接触 modem endpoint 和型号命令;Web/API、domain 与应用服务只依赖按能力拆分的统一类型化接口,不见 AT/QMI、interface、sysfs 或 `/dev`,也不按 QDC507/型号/VID/PID 做控制分支。 -2. Managed Modem 由 IMEI 的每实例 HMAC 稳定绑定,Line 由 Managed Modem + SIM ICCID 的每实例 HMAC + slot 稳定绑定。临时 USB device ID 只用于本次 Agent 定位。 -3. RF 只由管理员的显式 Managed Modem mutation 改变;SMS 永不自动开启 RF。 -4. 每条 Line 在每次操作只解析一个 SMS transport。零匹配或多匹配均失败;选定后不 fallback。 -5. outbound 先落应用库,再 dispatch;modem 接受后结果不明时持久为 outcome unknown,永不自动重发。 -6. inbound 先落应用库,再删除 SIM storage;删除前核对 PDU,部分删除进度可跨 Agent restart 恢复。 -7. capability evidence 决定可见性。Fixture 不能提升 production `sms-v1`;只有获准 HIL 通过后才启用 observed 能力和生产装配。 -8. Agent 仍无网络 attachment;真实蜂窝副作用发生在 modem/SIM,不通过宿主数据网络、netd 或 VoWiFi。 - -## 2. Architecture and Ownership - -```text -Web Modems / Messages - | - | stable ManagedModem ID / Line ID - v -simplusd - modem.Service ----------> RuntimeStatusReader ------+ - messaging.Service ------> per-Line SMS Resolver | - | | - +-----------------+------------------+ - | | - v v - AgentSMSGateway VoWiFiSMSGateway - (Line capability SMS) (HostVoWiFiAuth) - | | - | expected SIM fingerprint | existing worker - v v -simplus-agent Unix socket simplus-netd supervisor - fenced SMS service - | - per-device operation gate - | - fresh typed probe + SIM identity fence - | - QDC507 composite adapter - | - fixed AT PDU driver -> primary AT interface 2 -> SIM / cellular base station -``` - -The QDC507 path never invokes the right-hand VoWiFi branch. `simplus-netd`, Mihomo, ePDG and SIP are not QDC507 SMS dependencies. - -### 2.1 Package ownership - -| Concern | Owner | -| --- | --- | -| QDC507 USB match, fixed endpoints, identity/RF commands, evidence | `internal/modemadapter` | -| PDU/SIM-storage command flow and SMS durable recovery | `internal/modemadapter/qdc507sms` | -| device snapshot, fresh probe, endpoint ownership, per-device serialization | `internal/hardwareprobe` | -| typed/fenced Unix request, response and stable errors | `internal/agentapi` | -| stable Line resolution and per-Line transport selection | `internal/application/line`, `internal/application/messaging` | -| Managed Modem cellular projection | `internal/application/modem`, `internal/domain/modem` | -| public contract and responsive display | `api/openapi.yaml`, `internal/api/httpapi`, `web/src/pages/Modems.tsx` | -| process composition, state root, timeout, shutdown | `cmd/simplus-agent`, `cmd/simplusd`, container/native launch files | - -## 3. QDC507 Typed Capabilities - -### 3.0 Common capability boundary - -“统一接口”按能力拆分,不是要求每个模组实现一个巨型接口。上层只依赖它实际需要的最小契约;某型号缺少或尚未验收的能力就不实现、不声明,调用方 fail closed: - -| 上层用途 | 上层/Agent 统一契约 | 型号私有实现 | -| --- | --- | --- | -| 稳定模组身份 | `EquipmentIdentityReader` / typed Agent identity request | `EquipmentIdentityAdapter` | -| SIM/Profile 身份 | typed topology and Line identity | `SIMIdentityAdapter` | -| 本机号码 best effort 观测 | scanner/HIL-only `SubscriberNumberAdapter` | fixed `AT+CNUM` parser | -| 蜂窝运行状态 | `RuntimeStatusReader` returning typed `RuntimeStatus` | `ATProbeAdapter` with a fixed private `ProbePlan` | -| RF 开关 | `RFController.Set(..., bool)` / typed Agent RF request | `RFControlAdapter` | -| 短信收发 | application `Sender`/`Inbox`, typed Agent SMS requests | `SMSAdapter`, then model-private `PDUDriver` | - -QDC507 的 AT 文本、响应解析、endpoint role 和 SIM storage 选择只存在于最后一列及通用有界 tty transport。Registry 依据 discovery profile 选择实现;选择完成后,Agent 服务、Line、messaging、Managed Modem、HTTP/OpenAPI 和 Web 均不再判断型号。Composition root 可以显式构造并注册具体 adapter,展示层可以显示只读型号名称,但二者都不能把型号作为业务控制条件。 - -架构验收使用类型断言、registry/router 测试和一个合成的第二型号 adapter fixture,证明同能力实现可替换而无需修改上层业务流;不为测试增加第二个生产型号或未来空接口。 - -### 3.1 Stable equipment and SIM identity - -`modemadapter.QDC507` gains the three existing small interfaces rather than a QDC-specific upper-layer branch: - -- `EquipmentIdentityAdapter.ReadEquipmentIdentity`: fixed `AT+CGSN`, accept only a Luhn-valid 15-digit IMEI via the shared `equipmentIMEI` helper. -- `SIMIdentityAdapter.ReadSIMIdentity`: fixed `AT+QCCID`, parse `+QCCID:` with the shared ICCID pseudonymizer. -- Home operator metadata: reuse the existing SIM-file decoders and EF_AD MNC-length logic with neutral names. Read EF_SPN best effort, read IMSI only inside the adapter, and return only normalized `MCC-MNC`. Raw IMSI is discarded before leaving the function. A Chinese Unicom fixture is data, not a branch or fallback constant. - -Identity and metadata stay independent: a valid ICCID always yields the stable fingerprint/hint even when SPN/IMSI/EF_AD metadata is absent or malformed. - -### 3.1.1 Private best-effort subscriber number - -`SubscriberNumberAdapter.ReadSubscriberNumber` 是 model-neutral 的最小只读能力,只能由 scanner 与 build-tag HIL runtime 调用;QDC507 实现固定执行 `AT+CNUM`,不开放任意 AT 或 endpoint。Parser 只接受一个 `+CNUM` record、TON 145 且 number 字段本身已经是唯一规范的 `+E.164`;空响应正常返回 unavailable,重复(即使相同)、歧义、national format、缺少 `+`、malformed/control/overflow、terminal/query failure 全部 unavailable,不通过 TON、运营商或外部 destination 猜测补全。 - -HIL 只能在唯一 target、SIM identity/generation fence、RF On、蜂窝注册成功并再次 fence 后调用。返回值只在当前进程内瞬时存在,不作为 SMS readiness、注册成功、Line identity 或 inbound sender 的条件;unavailable 不阻断后续 `SM` preflight 与 SMS。该值不进入 Agent/public protocol、OpenAPI/Web、数据库、日志、错误、stdout/stderr 或 task/HIL evidence;typed `RunResult` 与 CLI 只携带去标识的 available/unavailable 状态,即使后续 SMS 阶段失败也可保留该能力结论。获批 external destination 仍是唯一预期 inbound sender。 - -### 3.2 RF control - -`QDC507.SetRFState` follows the existing typed RF contract: - -1. bounded `AT` handshake; -2. fixed `AT+CFUN=1` for On or `AT+CFUN=4` for Off; -3. immediate `AT+CFUN?` read-back; -4. success only when the typed observation equals the requested state. - -The Agent RF service already fences instance/snapshot/device generation and checks active-call count before the adapter write. No `AT&W`, boot policy, network selection or retry loop is added. The existing inconsistent `rf-control: observed` evidence is corrected so the evidence and actual interface move together; a model with no usable AT endpoint reports unavailable. - -### 3.3 Capability shape - -- Base/discovery `QDC507{}` keeps `sms-control` non-production (`documented`) until HIL. -- The final production composite adapter implements the same discovery/identity/RF interfaces plus `SMSAdapter`, and only after the evidence gate replaces `sms-control` with `observed` when the primary AT endpoint exists. -- `host-vowifi-auth`, cellular voice and digital voice media remain unverified/unsupported as today and are not exposed as business capabilities. -- `operator-selection` is not promoted by observing registration; reading `COPS?` is not permission to scan or select networks. - -## 4. Cellular Status Contract - -### 4.1 Shared classifier - -Add one pure classifier next to the typed Agent cellular observations. It accepts `DeviceProbe` and returns: - -- `state`: `registered-home`, `registered-roaming`, `searching`, `denied`, `not-registered`, `rf-off`, `sim-not-ready`, `unavailable`, or `unknown`; -- `readyForSMS`: boolean; -- `reasonCode`: empty when ready, otherwise a transport-neutral bounded code. - -Precedence is deterministic: - -1. incomplete/invalid probe -> `unavailable / CELLULAR_STATUS_UNAVAILABLE`; -2. SIM not present+ready -> `sim-not-ready / CELLULAR_SIM_NOT_READY`; -3. RF not On -> `rf-off / CELLULAR_RF_OFF`; -4. any registered-home/SMS-home/CSFB-home domain -> `registered-home`; -5. any registered-roaming/SMS-roaming/CSFB-roaming domain -> `registered-roaming`; -6. otherwise denied, searching, not-registered, emergency-only, then unknown. - -`readyForSMS` is true only for steps 4–5. Denied maps to `CELLULAR_REGISTRATION_DENIED`; all non-registered/searching/emergency/unknown terminal cases map to `CELLULAR_NOT_REGISTERED` or `CELLULAR_STATUS_UNAVAILABLE` as appropriate. Stable identities are separate fences layered on top: the public Managed Modem status does not expose them, while Agent SMS requires the current equipment and SIM fingerprints to equal the Line target. The Agent SMS preflight maps the neutral reason to `SMS_*` errors; Managed Modem presentation exposes `CELLULAR_*`. Neither reimplements the state table. - -### 4.2 Application model - -Replace the RF-only read port with a `RuntimeStatusReader` that returns one `RuntimeStatus` from one targeted Agent probe: - -```text -RuntimeStatus - RFState - SIMPresence - CellularState - CellularErrorCode - Registrations[3] { domain, state } - OperatorName - OperatorCode # normalized current PLMN, e.g. 460-01; empty if unknown - RAT - SignalState - SignalRSSIDBm # optional - ObservedAt -``` - -`AgentRFController` is split by responsibility: the status reader owns the current probe; a narrow RF setter owns only the fenced boolean mutation. `modem.Service.List` calls the status reader once for each online managed modem, so this adds no second probe beyond the existing RF read. Offline/unreadable status is explicitly unavailable and never reuses an older registered observation. `SetRFState` returns the write result, then the ordinary list invalidation obtains fresh full status. - -### 4.3 Public OpenAPI/UI - -`ManagedModem` gains a required nested `cellular` object. To keep the OpenAPI 3.0 generator contract simple, absent observations use required sentinel fields rather than nullable values: - -- `state`, `errorCode`; -- registration entries ordered `cs`, `packet`, `eps`, using the Agent registration enum; a missing domain is normalized to `unknown` so the public object always has three entries; -- `operatorName`, normalized `operatorCode`, `rat`; -- `signalState`, integer `signalRssiDbm` (`0` unless `signalState=measured`); -- string `observedAt` (RFC3339 when fresh, empty when unavailable). - -OpenAPI is the source; generated Go/TypeScript/Zod/Query files are regenerated. The Web uses generated unions and exhaustive presentation maps. Both desktop table and compact card show the cellular summary, current network/RAT, signal and observation age/error context. No raw identity, endpoint, functional-mode string, probe detail or device path is public. - -## 5. Agent Operation and SIM Fencing - -### 5.1 One per-device operation owner - -Replace `hardwareprobe.Scanner.controlMu` and `modemadapter.SMSRouter`'s private gates with one injected, context-aware keyed gate. Scanner probe, equipment identity, SIM AKA, RF and SMS acquire it by current Agent device ID. Each method: - -1. validates the target and resolves the current device before allocating/acquiring; -2. acquires with context cancellation; -3. re-resolves the current snapshot after waiting; if the device disappeared or its generation changed, return the typed stale/unavailable error; -4. performs the complete endpoint session and state persistence while holding it; -5. releases on every return. - -Different devices may proceed independently; all operations on one modem are serialized. The tty `flock` remains a defensive cross-process check, not the primary scheduler. Tests cover same-device serialization, different-device progress, cancellation and hotplug while queued. - -### 5.2 Internal SMS request fence - -The public browser request still contains only a stable Line ID. `line.Service.Topology` already carries the resolved Line's `SubscriptionProfileID`, the current internal subscription profiles and the physical device's equipment fingerprint. Messaging derives a private `SMSRuntimeLine` by requiring exactly one matching active profile and valid equipment/SIM fingerprints; it never parses a display hint and does not add either fingerprint to `inventory.Line`, OpenAPI, public response models or logs. - -The Agent SMS request structs add `deviceGeneration`, `expectedEquipmentFingerprint` and `expectedSubscriptionFingerprint` for List/Read/Send/ACK. These are internal Unix protocol fields with strict bounds. Server logs must not emit either fingerprint. The SMS backend, while holding the device gate: - -1. verifies Agent instance, current device presence and exact device generation; ordinary SMS requests do not pin the whole snapshot revision, so unrelated hotplug on another modem cannot invalidate a long send; -2. performs a fresh typed probe; -3. requires valid current equipment and SIM fingerprints and SIM READY; -4. compares both in constant time with the expected Line target; -5. applies the shared cellular readiness check for Send only and maps its neutral reason to a stable SMS error; -6. constructs `SMSRuntimeTarget{DeviceReport, SubscriptionKey}` for the adapter. - -This closes modem hotplug/replacement and SIM-swap TOCTOU between application Line resolution and modem dispatch. A change during payload dispatch is handled by the existing definite-before-dispatch versus outcome-unknown-after-dispatch rules. - -### 5.3 Stable Agent errors - -Add sentinels, HTTP error codes, `ErrorResponse.Is` mappings and gateway mappings for: - -| Agent condition | Agent code | Durable application code | -| --- | --- | --- | -| SIM absent/locked/identity missing | `SMS_SIM_NOT_READY` | `SMS_SIM_NOT_READY` | -| SIM differs from Line binding | `SMS_SIM_IDENTITY_CHANGED` | `SMS_SIM_IDENTITY_CHANGED` | -| RF not On | `SMS_RF_OFF` | `SMS_RF_OFF` | -| registration denied | `SMS_REGISTRATION_DENIED` | `SMS_REGISTRATION_DENIED` | -| no registered domain/searching | `SMS_NOT_REGISTERED` | `SMS_NOT_REGISTERED` | -| probe/control unavailable | `SMS_STATUS_UNAVAILABLE` | `SMS_STATUS_UNAVAILABLE` | -| payload may already have dispatched | `SMS_SEND_OUTCOME_UNKNOWN` | existing `SMS_SEND_OUTCOME_UNKNOWN` | - -Definite preflight failures produce the existing durable `failed` message status. Only dispatch uncertainty produces `unconfirmed`. Error strings remain locale-neutral and omit identifiers, addresses, bodies and raw modem responses. - -## 6. QDC507 SMS Runtime and Durable State - -### 6.1 Adapter contract - -Change `SMSAdapter` to receive `SMSRuntimeTarget` rather than a bare `DeviceReport`: - -```text -SMSRuntimeTarget - Device # current transient endpoint map - SubscriptionKey # validated per-install SIM fingerprint, Agent-internal -``` - -The wire response still sets `DeviceID` to `target.Device.ID` for request/response correlation. All state keys and hashes use `SubscriptionKey`. - -### 6.2 SIM-owned message storage - -Every List/Read/Send/Delete preparation executes fixed commands and validates terminal responses: - -1. `AT+CMGF=0`; -2. `AT+CPMS="SM","SM","SM"`. - -`SM` binds read/delete, write/send and future receive storage to the physical (U)SIM. It is an inbound staging area, not the product's only or long-term message store: after the complete message is durably committed to the application database, Agent ACK re-reads and verifies each PDU and removes the safely adopted segments from `SM`. Outbound `AT+CMGS` is sent directly and does not rely on a persistent sent-message copy on the SIM. The Agent owns this runtime SMS mode; it does not restore it after each call and never issues `AT&W`. HIL must prove QDC507 accepts the exact selection and stores/reads the approved inbound message there. If `SM` is unavailable, the operation fails; it does not use `ME`/`MT` as fallback. - -### 6.3 State schema v2 - -There are two distinct local persistence layers. The application message database is the authoritative user-visible history and stores the complete inbound/outbound message records. The Agent-private QDC database is a recovery ledger: it retains the minimum message candidate and operation/delete progress needed to survive restart, deduplicate delivery and avoid unsafe resend or deletion. Selecting `SM` does not eliminate either local database. - -The candidate v1 database was never production and cannot be safely attributed from transient device IDs to SIMs. The final production path uses a fixed v2 filename under the configured Agent state root (for example `qdc507-sms-v2.sqlite3`) and `PRAGMA user_version = 2`. It does not silently migrate or delete an existing v1 file. - -Schema changes: - -- replace `device_id` state namespace with `subscription_key` (64-hex check); -- make inbound uniqueness `(subscription_key, message_id)`; -- include `subscription_key` in operation records as an explicit checked column as well as the request digest; -- keep message bodies/senders only because they are required for restart-stable list/read; the private database is mode 0600 in a mode 0700 Agent directory; -- preserve `synchronous=FULL`, one connection, WAL and checkpoint-on-close. - -Message/digest rules: - -- inbound message ID = hash(subscription key, ordered storage indexes, exact PDU bytes); -- send digest = hash(subscription key, destination, body); -- ACK digest = hash(subscription key, message ID); -- hotplug to another USB port returns the same state; another SIM cannot see or acknowledge it. - -Operations accepted before a crash remain outcome unknown. No cleanup job deletes unresolved operation evidence automatically. - -### 6.4 Application inbound ordering - -The existing application ordering remains authoritative: - -```text -Agent List -> Agent durable candidate -> application message/fragment transaction - -> Agent Read -> validate -> application commit -> Agent ACK - -> modem re-read + PDU digest match -> CMGD -> Agent progress commit -``` - -For multipart, the QDC adapter exposes a complete uniquely assembled message; its Agent ACK owns all physical segments and persists each successful delete. The existing application fragment spool still supports other transports and is not removed. - -## 7. Per-Line SMS Transport Resolution - -### 7.1 Resolver contract - -Replace the global `sender/inbox + hostVoWiFiSMS` switch with registered transport bundles: - -```text -SMSTransport - Name - Eligible(line) bool - Available(ctx, line) bool - Sender - Inbox - optional SubmitReportInbox -``` - -Registered hardware bundles are: - -- `agent-native`: eligible iff `line.Capabilities.SMS`; -- `host-vowifi`: eligible iff `line.Capabilities.HostVoWiFiAuth`, available iff the existing per-Line VoWiFi service is available. - -Each bundle has both `Sender` and `Inbox`; Host VoWiFi additionally implements submit reports. Registration rejects incomplete bundles at startup, so a transport cannot be selected for Send but silently disappear from inbound sync. - -Resolution is intentionally two-step: first compute eligible bundles from Line capabilities and require exactly one; zero means unsupported and more than one means ambiguous. Only then check the selected bundle's availability. A selected-but-unavailable bundle returns a stable unavailable error and never asks another bundle to handle the operation. Current QDC507 and ML307A capabilities are mutually exclusive, so no user-facing selector is needed in this MVP. - -### 7.2 Send and inbound consistency - -`Send` resolves once before creating the queued record so unsupported/unavailable Lines do not create misleading dispatch work; it then persists before calling the selected sender. `SyncInbound` resolves each ready Line independently and invokes the inbox/reports from that same bundle under the existing modem-function gate. A failure on one Line is collected and does not prevent other Lines from synchronizing; it still never tries a second transport for the failed Line. Tests use per-Line call counters to prove both isolation and no fallback. - -Simulator registers one synthetic Agent bundle and keeps current behavior. Hardware always registers the Agent bundle after the typed Agent policy passes, and registers the VoWiFi bundle only when its supervisor exists. - -## 8. Production Composition and Lifecycle - -### 8.1 Agent startup - -Add one required private `--state-root` (with fixed environment/default paths in native and container launchers), not an enable flag. The composition root: - -1. opens the existing identity key; -2. opens QDC507 v2 SQLite state beneath the validated root; -3. constructs tty transport -> PDU driver -> composite QDC adapter; -4. constructs a runtime registry with that adapter and `ML307A{}`; -5. injects the same registry and keyed operation gate into scanner and SMS backend; -6. refreshes monitor; -7. constructs RF, identity and SMS services; -8. exposes the managed handler with `sms-v1` and uses `WriteTimeout >= SMSRequestTimeout`; -9. on shutdown, stops HTTP/monitor, closes listeners, then checkpoints/closes SMS state. - -Any failure before step 8 exits non-zero. `DefaultRegistry()` remains discovery/test-safe and continues to supply reviewed USB driver IDs; no duplicate QDC match is registered. - -### 8.2 simplusd startup - -`requireTypedHardwareAgent` is updated atomically after HIL to require `rf-control-v1`, equipment identity read and `sms-v1`, while continuing to reject retired generic command features. It constructs `AgentSMSGateway` even when a VoWiFi supervisor exists, then registers both bundles with messaging. If the Agent does not advertise the required typed feature, hardware startup fails rather than silently losing native SMS. - -Native development unit, Debian installer and container entrypoint all pass the fixed Agent state root. Existing private mounts/StateDirectory and Agent no-network/capability boundaries are sufficient; no new device cgroup rule, host network, capability or writable sysfs path is added. - -## 9. Evidence Promotion and HIL - -### 9.1 Two-stage activation - -Production activation is deliberately split: - -**Stage A — fixture-ready, production closed** - -- implement typed identity/RF/status/SMS/gating/routing and deterministic tests; -- keep base QDC SMS evidence non-observed and ordinary production product path unable to use it; -- add a `qdc507_hil` build-tag test/runner that composes the same scanner, gate, target resolver, driver and durable adapter. It selects exactly one QDC507 from discovery, accepts no device path, AT/QMI text or caller marker, and reads only the private destination from bounded stdin JSON; argv contains only bounded timeouts. The runner generates a 128-bit `crypto/rand` uppercase-hex GSM-7 marker in memory and stops if entropy fails. Its typed completion result reports only subscriber-number available/unavailable, never the value. - -**Stage B — after exact approval and successful HIL** - -- run the bounded HIL sequence; -- add only a sanitized compatibility conclusion; -- promote `sms-control` to observed in the production composite; -- inject the shared Agent SMS handler and update `simplusd` typed policy/composition; -- run the complete non-HIL gate again. - -If HIL fails, Stage B is not applied. The failure does not authorize QMI WMS, VoWiFi, broader commands, data attachment or permission expansion. - -### 9.2 Bounded HIL sequence - -Before execution, restate and obtain approval for the exact Line/number, synthetic body, number of sends, timeout, service ownership switch and RF restoration. The runner then: - -1. proves one unique QDC507, current generation and SIM READY/fingerprint using typed read-only inspection; -2. records only in memory the initial RF state; refuses ambiguity or an unexpected active call; -3. explicitly requests RF On if needed and verifies read-back; -4. polls fixed read-only status until registered or the approved deadline, then rechecks the target/SIM fence; -5. attempts the typed fixed `CNUM` subscriber-number read best effort, discards the result without output/persistence, and continues when unavailable; -6. verifies `CPMS` selection through the real adapter; -7. performs exactly the approved outbound operation with one operation ID and the internally generated marker; unknown outcome stops all sends; -8. waits for the same private destination to echo the in-memory marker, imports exactly that inbound message, and verifies persist-before-delete and empty pending state; -9. restores initial RF and service ownership; cleanup failure is reported and blocks acceptance. - -Raw output, identifiers, phone number, body, PDU, timestamps tied to the run, database, logs and screenshots stay outside the repository. Public docs record only capability, HIL evidence level, remaining boundaries and the automated regression that protects it. - -### 9.3 Outcome-unknown inbound recovery - -The observed outbound was physically delivered while the protocol result stayed -unknown; the reply arrived only after the original runner stopped, and RF cleanup -was blocked by two active data calls. This remains a partial failure, not HIL pass. -No outbound retry, hangup, RF force-off, phone/data control, or production promotion -is permitted. - -The most likely bounded protocol mismatch is submit payload echo: after Ctrl-Z the -modem may echo the exact hexadecimal PDU before `+CMGS` and `OK`, while the original -transport filtered only the `AT+CMGS` command echo. The fixed transport may suppress -at most one line that is byte-for-byte the payload just written (with or without the -terminal Ctrl-Z); a different hexadecimal line, second echo, or URC remains visible -and makes the driver fail closed. Synthetic tty and complete Driver tests lock this -behavior, but the historical unknown operation remains immutable and this diagnosis -is not HIL evidence until a separately authorized new outbound succeeds. - -That follow-up uses a distinct build-tag-only outbound-confirmation binary rather than the complete -echo-back runner. It accepts only the private destination through bounded stdin and generates a new -marker and operation ID from `crypto/rand`; it sends at most once and does not request an inbound reply. -Its concrete graph contains only `QDC507Outbound` read-only registration/equipment/QCCID observation, -the shared per-device gate, `SMSOutboundRouter`, outbound-only adapter/driver/tty transport, an -operation-only existing-v2 recovery SQLite store, and an application repository that inserts/transitions -only its new outbound row. It contains no Line/topology service, RF mutation, inbound List/Read/ACK/Delete, -subscriber-number, call/data/QMI/SIM-AKA or generic command capability. Preflight requires the historical -unknown app/ledger evidence unchanged, no prior confirmation-attempt row, no pending inbound state, RF -already On, SIM ready and no voice/fax/unknown call. Existing CLCC mode-1 data bearers may coexist -without being created, changed, exposed, or torn down by SMS. Target selection may occur while registration is still -searching so the bounded registration wait has effect; the typed dispatch performs another locked ready, -identity and generation fence immediately before sending. Definite pre-prompt failure marks only the new -row failed; any post-prompt ambiguity marks the new operation/row outcome-unknown and stops without retry. -The old unknown operation is never resent or rewritten. The separately authorized execution completed -with durable application/Agent success and modem confirmation; only the sanitized conclusion is retained. - -The tagged recovery command is a separate binary with no stdin payload and only -bounded timeout argv. Its composition retains a discovery-only scanner, an -equipment/SIM-identity-only resolver, a receive-only registry/router, -`PDUInboxDriver{List,Read,Delete}`, a command-only TTY transport with no Prompt, -application message repository, and a metadata-only unknown-send inspector. It -constructs neither the complete QDC/ML307A adapters nor the standard RF/registration -probe. Neither the runner interface nor its concrete object graph contains Send, -RF, registration, call, data, or subscriber-number capability. It requires the complete private application database to -contain exactly one outbound `unconfirmed/SMS_SEND_OUTCOME_UNKNOWN` candidate and -at most its exact persisted reply. Destination and marker are derived from that -row, validated as bounded E.164 and the exact generated GSM-7 marker grammar, and -cross-checked by operation ID plus current fenced subscription against exactly one -outcome-unknown recovery operation; raw digest, address, body and fingerprint are -never returned. - -The runner takes/restores only fixed service ownership, selects one current QDC507, -requires SIM ready and exact equipment/SIM/generation fence, and does not inspect -registration or RF. It lists fixed `SM`, requires every raw PDU to belong to the -unique durable pending message set, fails on multiple, incomplete, or unexpected inbound, -persists the unique exact reply to the application database, rechecks the fence, -then invokes existing PDU-revalidating ACK/delete semantics and verifies pending is -empty. Restart/replay may finish an already persisted reply, but never mutates or -removes the original outbound unconfirmed row. - -### 9.4 Read-only multiple-candidate classifier - -Recovery 在 `SM` 观察到多条候选并停止后,候选分类使用第三个 build-tag 隔离二进制,不能 -通过缩窄接口复用完整 recovery 对象。它的 concrete graph 只含 discovery、设备/SIM identity -resolver、generation fence、固定 service ownership、真正 `mode=ro` 的 application messages -与 Agent recovery SQLite handles,以及一个只有 unexported fixed transcript exchange 的 -EF_SMS reader。它固定执行 `CRSM` GET RESPONSE 与 READ RECORD,file ID 是 decimal `28476` -(`6F3C`),每次显式 path `7F106F3C`,READ RECORD 使用 1-based record、absolute P2=4、 -P3=176;不构造 application `Set`、`SQLiteStateStore`、SMS adapter/inbox backend、generic -`Command`/`Prompt`/APDU transport,因而不存在 caller command/path、`CMGF/CPMS/CMGL/CMGR`、 -`CSIM/UPDATE`、Create/write、Send、single Read、ACK/Delete、RF、注册、电话、数据或 -subscriber-number capability。只读 SQLite 不运行 schema/migration、写 PRAGMA 或 checkpoint, -关闭只释放 handles。 - -GET RESPONSE 只接受一条 `+CRSM: 144,0,"..."` 与最终 `OK`,且 payload 必须是确证 -linear-fixed EF_SMS、record length 176、显式 bounded count 的 documented legacy 15-byte -shape 或完整 UICC FCP;不猜 count,不接受 continuation/non-`90 00` status、retry、alternate -path 或 fallback。每条 READ 必须恰好返回 176 bytes。status byte `00` 是 free 并忽略,`01` -received-read 与 `03` received-unread 在剥离 status 后进入既有 bounded PDU/multipart assembler; -其他 used/outgoing/status-report/unrecognized status 一律使完整性 unexpected,不能被 exact match -掩盖。 - -入口从 application DB 的单一 read-only snapshot 唯一推导既有 outbound -`unconfirmed/SMS_SEND_OUTCOME_UNKNOWN` source + marker,再在当前 SIM fence 下要求 recovery -ledger 恰有一个 outcome-unknown send 且 operation ID/request digest 精确匹配。设备 generation、 -equipment 与 SIM fence 在扫描前、中、后都重查并与整个双扫描共用同一 operation gate。完整 -EF 扫描两次,只有 count 与所有 record bytes byte-for-byte 相同才继续分类;网络到达导致的 -record 变化、overflow、cancel、malformed/incomplete 或 identity 变化全部 non-actionable。 -所有 received PDU 在内存中解析和 multipart 组装; -任意 outgoing/unexpected、malformed、重复、超时跨度或 incomplete group 都阻止 exact-match 结论, -不能因为另一个完整候选匹配就忽略。输出只有候选/匹配 `zero|one|multiple|unknown` 与完整性 -`complete|incomplete|malformed|unexpected|unknown`,不含地址、marker、正文、PDU、时间、index、 -路径或身份。多条中恰一条匹配也只报告,不落库、不 ACK/delete;执行后在任何接管动作前形成 -新的精确授权硬停点。该 classifier 的 CRSM parser、双扫描、fence 与 mode=ro SQLite 对象图 -已实现和完成合成测试;标准证明 READ RECORD 与 UPDATE 分离,但没有 QDC507 firmware-specific -证据证明 response shape、modem cache 行为或 unread byte 保持。因此真实构造以明确的 CRSM -firmware blocker 保持 fail closed;必须先为当前固件取得新的精确授权、验证 shape 与 unread -preservation 并重新通过质量门,才能提出一次真实分类执行授权。该验证必须使用另行获批、 -为本次验证新建的受控 synthetic unread fixture;现有私有 inbox 候选不能隐式充当 preservation -fixture。 - -为收敛这一 blocker,另有独立 `simplus-qdc507-hil-crsm-preserve` build-tag entrypoint。其 -concrete graph 不含上述两个 read-only DB handles 或任何 candidate correlation,只含 -discovery-only scanner、QDC507Inbox identity/SIM-ready resolver、per-device gate、fixed service -ownership 与同一 fixed CRSM enumerator。总 deadline 从 runtime 构造前开始;stdin 只允许 PTY -或 `/dev/null`,argv 只有 bounded arrival/total timeout。它在同一 gate 中以每轮前后 fresh -equipment/SIM/generation fence 完成两次 byte-identical zero-unread baseline,flush -`ready-for-one-controlled-inbound` 后等待用户从未来获批私有 peer 手工发送 exactly one 新消息。 -只接受唯一 `0x00 -> 0x03` 且 payload 非空的 record transition,其他 records 必须与 baseline -逐 byte 相同;随后三次 interval-separated full scans 要求新 record 全部 176 bytes 保持一致且 -status 始终 unread。任何 existing unread、read arrival、非唯一变化、count/shape/byte/fence -变化、cancel 或 timeout 都 fail closed。raw EF 只在内存,不 log/persist/hash output;成功输出 -只有 response-shape/unread-preserved observed 与 changes=one。该实现为 Fixture、从未运行; -首次 firmware-specific read 理论上仍可能改变 unread,因此未来授权必须精确接受风险、创建全新 -controlled fixture、限制一次执行且 no retry。成功后仍需 code/spec review 才能打开 classifier, -不授权真实分类、adoption、ACK/delete 或 production promotion。 - -### 9.5 Reviewed candidate cleanup - -用户在仓库外私有记录中逐条审阅并批准清空两条 pending assembled recovery candidate 后,删除入口 -使用第五个独立 `qdc507_hil` tagged binary `simplus-qdc507-hil-clear-reviewed`。它不读取私有 TXT; -reviewed count `2`、Agent identity key、recovery DB 和 service owner 都是 compiled-in 固定值。CLI -无私密 stdin/argv,只有 bounded total timeout;结果固定为 -`reviewed=2; cleared=0|1|2; pending=unknown|zero; stage=input|service|state|target|revalidate|delete|commit|verify|cleanup|complete`。 -`stage` 只表示去标识的有界状态机阶段;成功必须是 `complete`,close 或 owner restore 失败必须是 -`cleanup`,不得输出底层错误、路径、身份或记录事实。 - -State machine 必须先取得 fixed Agent service ownership,随后才打开 identity key/DB,并在所有退出 -路径先关闭 cleanup store、再无条件恢复原 owner。专用 store 先以 `mode=ro` 核验 main/WAL/SHM/ -journal 的 private mode、owner、link count 与 identity,再核验 `user_version=2`、完整 schema manifest、 -WAL、FULL synchronization 和 integrity;合法 existing main DB 即使当前没有 WAL/SHM,也只允许 SQLite -在固定辅助路径创建经 `O_NOFOLLOW`、同 UID、regular、single-link、0600 与 inode revalidation 的 WAL/SHM, -main inode 必须不变,journal/new main/symlink/hardlink 仍拒绝。artifact 未变化后才以 `mode=rw` 打开。其 interface 只有 load exactly two current-SIM -pending inbound metadata、CAS persist delete-started then deleted for one segment、atomically mark both records acknowledged、DB-only -final verify 与 close。SQL 不更新 operations、outbound ledger、sender 或 body;任一 count!=2、already -acknowledged、malformed row/segment、subscription mismatch 或 CAS/commit failure 都 fail closed。 - -Concrete modem graph 只有 QDC507Inbox discovery/CPIN/equipment/QCCID identity resolver、一个覆盖完整 -cleanup 的 device gate,以及专用 `ReviewedCleanupDriver{Read,Delete}`。Driver transport 接收 typed -operation/index,不能接收 command/path;只生成 exact `AT+CMGR=<stored index>` 和无 flag 的 -`AT+CMGD=<same index>`,严格解析结果,不 list/prepare/storage-select/reconcile/retry。每个未完成 segment -顺序为 fresh fence -> CMGR -> constant-time digest+index validation -> durable delete-started CAS -> fresh fence -> CMGD -> post fence -> -durable deleted CAS;delete-started 遗留表示物理结果不确定,restart 必须在再次读取或删除该 index 前停止。 -两条 record 全部 segment deleted 后再 fresh fence,并在一个事务中同时 acknowledged。`CMGR` 可能在删除 -前把 `REC UNREAD` 改为 `REC READ`,该风险是这两条 reviewed deletion 授权的一部分。delete response -丢失、identity/generation/SIM/index/digest 变化或 DB update 失败立即停止;已经 commit 的 segment/record -进度保持单调,但 runner 不自动 retry uncertainty。两条 record 完成后只查询 DB 确认两者 acknowledged -且同 subscription 无 pending,不再执行 CMGL/CRSM/全 SIM list。 - -该入口和合成测试已经实现;后续 WAL auxiliary 与 bounded-stage 诊断修复只运行合成/编译检查,未再次 -执行真实 cleanup、打开真实私有 DB、停止服务或接触 modem/SIM。production/default registry 和普通 -binary 继续 closed;实现与当前精确授权不构成 HIL pass、production promotion 或更宽删除权。 - -### 9.6 Fresh normal inbound E2E - -Reviewed cleanup 完成后,正常入站证据不再复用旧 marker-bound recovery candidate,而使用第六个独立 -`qdc507_hil` tagged binary `simplus-qdc507-hil-fresh-inbound`。State machine 先取得固定 service -ownership,随后才打开 fixed identity key、existing Agent v2 recovery DB 与 application messages DB; -close state 和恢复原 owner 在所有已取得 ownership 的退出路径无条件执行。CLI stdin 只允许 PTY 或 -`/dev/null`,argv 只有 bounded `--arrival-timeout`/`--total-timeout`,新正文 `OK` compiled-in。 - -应用侧以一个 `mode=ro` transaction snapshot 唯一选择原 outbound -`unconfirmed/SMS_SEND_OUTCOME_UNKNOWN`,只把其 bounded E.164 remote address、stable Line、operation -ID 与旧 body 用于 peer/subscription ledger correlation;新入站不匹配旧 body。Narrow application store -只有 LoadPeer、idempotent CreateInbound、exact replay/final read verification 和 Close,没有 outbound -mutation/delete。当前 SIM v2 operations ledger 通过独立 `mode=ro` handle 捕获唯一 unknown-send 完整行, -核对 exact operation ID/request digest,并在最终逐字段复核未改变。Concrete modem graph 只含 QDC507Inbox identity/SIM-ready resolver、 -generation fence、一个 operation gate、无 Prompt 的 InboxTransport、`List/Read/Acknowledge` adapter 与 -durable recovery store;不含 Send/RF/registration/call/data/subscriber capability。 - -Fresh path 先要求 current subscription recovery pending zero,随后 strict fixed-`SM` List 必须返回 zero -且不能隐藏 incomplete/malformed/unexpected PDU,再次确认 recovery zero 与 fresh equipment/SIM/generation -fence 后同步 flush `ready-for-one-controlled-inbound; stage=ready`。之后只接受 expected peer 的 exactly -one complete candidate,Read body 必须 exact `OK`;multiple/unexpected/incomplete/malformed/fence change -全部 fail closed。应用 inbound/provider-id 与 unread marker 在一个 transaction 中先持久化,再 fresh fence -后 ACK;既有 adapter 对每段 stored index 做 CMGR PDU digest revalidation、single-index CMGD 与 progress -commit。Final verify 要求同 provider-id 应用行恰好一次、原 outbound byte-for-field unchanged、unknown -ledger still exact、recovery pending zero 且 strict `SM` List zero。Restart 仅允许启动 snapshot 已有唯一 -peer+`OK` exact app row、同一 SIM 恰好一个 candidate 的每个未删 segment 仍物理存在且 ACK operation 从未 -开始时续作;fresh application replay、ledger-only segment 或 accepted/uncertain ACK 一律停止,不再输出 -ready、请求第二条或重复删除。CLI 结果只有 -`ready/persisted/cleared/pending-zero` 与 bounded stage。该实现为 implemented-not-run Fixture;合成测试、 -compile target 和文档更新不构成 HIL execution/pass 或 production promotion。 - -## 10. Compatibility, Documentation, and Migration - -- Add ADR 0026 because production Agent mutation surface, native/VoWiFi transport selection and QDC evidence promotion are durable architecture decisions. It supersedes only the prior statements that production hardware Agent is SMS-free; it preserves typed boundaries and no-fallback ADRs. -- Update `docs/architecture.md`, the sole active MVP plan, `docs/compatibility.md`, `docs/troubleshooting.md`, `docs/development.md`, installation/entrypoint descriptions and sanitized handoff after Stage B. -- No core/messages database migration is required. Stable Managed Modem/Line schemas and current internal topology already hold the identity evidence required to construct the private messaging target. -- The QDC SMS state database is Agent-private v2. Candidate v1 state is neither imported nor deleted automatically; an unexpected old file blocks only if configured as the production v2 target. -- Existing ML307A Host VoWiFi behavior, Simulator SMS, message history/cursors and notification flows remain compatible. - -## 11. Rollback and Failure Semantics - -Code rollback removes production registry/handler/app composition and returns QDC SMS evidence to non-observed. It must not delete the v2 Agent state database: accepted/outcome-unknown and partially acknowledged evidence may be needed when the fixed version returns. - -Operational rollback after HIL or startup failure: - -- stop the candidate owner before restoring the previous Agent service; -- restore the recorded RF state if and only if the target identity/generation still matches; -- do not resend an unknown outbound operation; -- do not delete a modem message whose current PDU no longer matches the persisted digest; -- keep Host VoWiFi behavior independent; never use it to mask a QDC failure. - -## 12. Principal Risks and Mitigations - -| Risk | Mitigation | -| --- | --- | -| QDC firmware differs from EC25 documentation | transcript fixture + exact typed HIL; no capability promotion on mismatch | -| modem or SIM swapped between Line resolution and dispatch | device generation + expected equipment/SIM fingerprints + fresh locked probe + constant-time comparison | -| USB port change breaks replay state | durable namespace uses SIM fingerprint; wire device ID remains transient only | -| modem `ME` storage leaks old-SIM messages | require `CPMS=SM,SM,SM`; no fallback storage | -| partial send duplicates on retry | persist accepted before dispatch; partial/post-prompt failures become outcome unknown | -| partial ACK deletes a reused index | re-read exact index and compare PDU digest before every delete; persist per-segment progress | -| probe/RF/SMS contend for one tty | one context-aware per-device gate plus defensive tty flock | -| global transport silently changes QDC to VoWiFi | per-Line unique resolver; ambiguity/unavailability fail closed; call-count tests | -| registration UI disagrees with send preflight | one shared cellular classifier | -| state dependency silently missing | startup fails before advertising `sms-v1`; private state/root contract tests | -| HIL evidence leaks telecom data or marker | destination only through private stdin, internal crypto marker, `CNUM` discarded in memory, de-identified conclusion only, docs/privacy checks | diff --git a/.trellis/tasks/archive/2026-08/08-12-dji-4g-cellular/implement.jsonl b/.trellis/tasks/archive/2026-08/08-12-dji-4g-cellular/implement.jsonl deleted file mode 100644 index 479cf50..0000000 --- a/.trellis/tasks/archive/2026-08/08-12-dji-4g-cellular/implement.jsonl +++ /dev/null @@ -1,13 +0,0 @@ -{"file":".trellis/spec/core/backend/index.md","reason":"Core Go dependency direction, typed Agent/API boundaries, generated ownership and pre-development checklist."} -{"file":".trellis/spec/core/backend/application-boundaries.md","reason":"Stable ManagedModem/Line identity, narrow ports, side-effect ordering, fail-closed hardware and no fallback."} -{"file":".trellis/spec/core/backend/api-contracts.md","reason":"OpenAPI-first public API plus bounded Agent Unix protocol, timeouts, response correlation and stable errors."} -{"file":".trellis/spec/core/backend/storage-and-migrations.md","reason":"Durable SQLite/idempotency/privacy rules needed for the Agent-private SMS recovery store."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Fixture, protocol, persistence, concurrency and generated-drift test requirements."} -{"file":".trellis/spec/core/infra/hardware-and-hil-safety.md","reason":"Typed modem boundary, evidence levels, exact approval gate and fail-closed HIL rules."} -{"file":".trellis/spec/core/infra/containers-and-privileges.md","reason":"Production Agent no-network/fixed-UID/private-state boundary and container contract."} -{"file":".trellis/spec/core/docs/privacy-and-publication.md","reason":"Telecom identity, SMS/HIL evidence, logs and public compatibility redaction boundary."} -{"file":".trellis/spec/web/frontend/index.md","reason":"OpenAPI-generated React/TanStack Query boundary and required frontend checks."} -{"file":".trellis/spec/web/frontend/component-guidelines.md","reason":"Responsive Modems UI, unavailable-state and accessibility conventions."} -{"file":".trellis/spec/web/frontend/type-safety.md","reason":"Generated types/Zod contract and exhaustive status presentation rules."} -{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Trace the QDC status and SMS contracts across Agent, application, API, persistence and Web."} -{"file":".trellis/tasks/08-12-dji-4g-cellular/research/repository-evidence.md","reason":"Task-specific current-code evidence, technical unknowns and production evidence-promotion decisions."} diff --git a/.trellis/tasks/archive/2026-08/08-12-dji-4g-cellular/implement.md b/.trellis/tasks/archive/2026-08/08-12-dji-4g-cellular/implement.md deleted file mode 100644 index 763fc0d..0000000 --- a/.trellis/tasks/archive/2026-08/08-12-dji-4g-cellular/implement.md +++ /dev/null @@ -1,460 +0,0 @@ -# 大疆 QDC507 原生蜂窝短信:实施计划 - -## 1. Execution Shape - -这是一个原子跨层纵切,不拆成可独立归档的 child task:稳定身份、注册状态、SIM fence、durable SMS、per-Line transport 和生产 evidence gate 互相构成准入条件。任何一项单独上线都会留下不可安全使用的半成品。 - -实施分为两个代码阶段,中间有一个强制的真实 HIL 停止点: - -- Stage A 完成全部类型化实现、合成 fixture、应用/Web 集成与 build-tag HIL runner,但保持 production QDC SMS 关闭。 -- Stage B 只有在 Stage A 质量门通过、重新取得精确 HIL 授权且真实纵切成功后,才提升证据并装配 production Agent/simplusd。 - -不得在 `task.py start` 前执行本清单,不得把任务创建或本规划审批解释为 HIL 执行授权。 - -## 2. Ordered Implementation Checklist - -### Phase A1 — Shared identity and QDC base adapter - -- [x] 阅读本任务 PRD/design、backend/infra/docs/Web specs、ADR 0017/0019 和研究记录;确认 worktree 中其他改动并保留。 -- [x] 搜索所有 `QDC507{}`、`DefaultRegistry`、identity/operator helpers 和 capability assertions,建立修改前影响清单。 -- [x] 把 ML307A 内可复用的 EF_SPN、EF_AD MNC length、IMSI bounded parser 提取为 SIM-neutral helper;保持 SIM AKA 专用逻辑与错误不被短信需求污染。 -- [x] 为 `QDC507` 实现 `EquipmentIdentityAdapter`、`SIMIdentityAdapter`、`RFControlAdapter`,命令固定为设计中的 allowlist;identity metadata best effort,RF 必须 handshake + write + read-back。 -- [x] 增加合成 QDC fixtures:15 位有效/无效 IMEI、`+QCCID` quoted/unquoted/malformed、SPN、两/三位 MNC、缺文件/非法响应、中国联通 profile data 无硬编码。 -- [x] 修正 QDC capability evidence/interface 一致性,并锁定电话、数字媒体、Host VoWiFi、operator selection 仍未被提升。 -- [x] 用编译期类型断言与合成第二型号 adapter fixture 验证 registry 和公共 capability services 只依赖小型统一接口;不得为 QDC507 在 application/domain/API/Web 增加型号分支。 -- [x] 增加 scanner/HIL-only `SubscriberNumberAdapter` 与 QDC 固定 `CNUM` parser;只接受唯一显式 `+E.164`,所有 unavailable 结果均不影响 identity、注册或 SMS readiness,且不进入公共/持久/日志边界。 - -Target files/areas: - -- `internal/modemadapter/qdc507.go` -- `internal/modemadapter/identity.go` -- `internal/modemadapter/*operator*`, `*usim*`, corresponding tests -- `internal/hardwareprobe/at_runtime*.go` -- `internal/modemadapter/registry_test.go` - -Focused validation: - -```bash -go test ./internal/modemadapter ./internal/hardwareprobe -``` - -Rollback point: QDC identity/RF capability commit boundary. No real hardware action is part of this phase. - -### Phase A2 — One cellular classifier and Managed Modem status - -- [x] Add the pure typed classifier for SIM/RF/registration states with an exhaustive table test covering every Agent registration enum and precedence conflict. -- [x] Replace the application RF-only status read with a single full `RuntimeStatusReader`; keep the RF setter as a separate mutation port. -- [x] Map one targeted probe into `domain/modem.CellularStatus`, including normalized PLMN, bounded operator/RAT/signal, observation time and stable error code. Never persist or cache a registered status for an offline modem. -- [x] Extend `api/openapi.yaml` with the nested cellular object and closed enums; regenerate Go and Web outputs through project generators. -- [x] Update HTTP response mapping/tests for exact safe fields and explicit absence of private Agent fields. -- [x] Update `Modems.tsx` desktop and compact renderers with exhaustive status tags, network/RAT/signal/age display, unavailable context and responsive tests. -- [x] Update synthetic desktop/mobile E2E fixtures and assertions without real modem data. - -Target files/areas: - -- `internal/agentapi/protocol*.go` or a focused cellular classifier file -- `internal/application/modem/agent_rf.go` (split/rename as appropriate) -- `internal/application/modem/service*.go` -- `internal/domain/modem/model.go` -- `api/openapi.yaml`, generated Go/Web outputs -- `internal/api/httpapi/server*.go` -- `web/src/pages/Modems*.tsx`, `web/e2e/app.spec.ts` - -Focused validation: - -```bash -go test ./internal/agentapi ./internal/application/modem ./internal/api/httpapi -make generate -make verify-generated -corepack pnpm --dir web typecheck -corepack pnpm --dir web test -- Modems -corepack pnpm --dir web build -``` - -Rollback point: OpenAPI source plus all generated outputs must move together. Do not hand-edit generated files. - -### Phase A3 — Shared Agent operation gate and fenced SMS target - -- [x] Introduce one context-aware per-device gate with no unbounded allocation for invalid device IDs. -- [x] Inject it into scanner and SMS runtime; migrate probe, identity, SIM AKA and RF paths from the global mutex without changing their typed behavior. -- [x] Add a messaging-owned private runtime-Line resolver that joins the already resolved Line, current subscription profile and physical equipment observation to bounded equipment/SIM fingerprints; do not add either fingerprint to public `inventory.Line`, revision payloads or OpenAPI. -- [x] Extend Agent List/Read/Send/ACK requests with device generation and bounded expected equipment/SIM fingerprints; update client/server validation, correlation, local simulator fixtures and no-store/logging contracts. -- [x] Build SMS target resolution under the same gate: re-resolve the device/generation, fresh probe, constant-time equipment+SIM comparison and Send-only cellular readiness. -- [x] Add stable Agent error sentinels, HTTP codes, retryability, `ErrorResponse.Is` and Agent gateway mappings; distinguish definite failure from outcome unknown. -- [x] Add concurrency, cancellation, queued hotplug, SIM-swap TOCTOU, malformed response and privacy tests. - -Target files/areas: - -- `internal/hardwareprobe/scanner.go`, RF/identity/SIM AKA files and tests -- `internal/modemadapter/sms.go`, `registry_test.go` -- `internal/agentapi/sms_protocol.go`, client/server and tests -- `internal/application/inventory`, `internal/application/line` -- `internal/application/messaging/agent_sms.go` and tests - -Focused validation: - -```bash -go test -race ./internal/hardwareprobe ./internal/modemadapter ./internal/agentapi -go test ./internal/application/inventory ./internal/application/line ./internal/application/messaging -``` - -Rollback point: protocol client/server/request fixtures and internal Line privacy audit must be reviewed as one change. - -### Phase A4 — QDC durable SMS v2 and SIM storage - -- [x] Change `SMSAdapter` calls to use `SMSRuntimeTarget{Device, SubscriptionKey}`; keep wire `DeviceID` correlation transient. -- [x] Update driver preparation to validate exact `CMGF=0` then `CPMS="SM","SM","SM"`; no `ME/MT`, persistent setting, or arbitrary command fallback. -- [x] Refactor inbound IDs/state keys/send and ACK digests from transient device ID to subscription key. -- [x] Implement private state schema v2 with explicit checked subscription namespace, atomic operations, mode/ownership validation, WAL/FULL sync and checkpoint close. Use a fixed v2 production filename; do not migrate or delete v1 automatically. -- [x] Preserve/extend existing GSM7/UCS-2, multipart, partial send, outcome unknown, application-database-before-ACK, Agent recovery-state-before-delete, index reuse and delete reconciliation behavior; `SM` is a physical staging area, not the only or long-term message store. -- [x] Add reopen/hotplug/swap tests: same SIM on new port sees the same pending state; another modem/SIM cannot list/read/ACK/replay it; stale generation or expected identity fails before driver I/O. -- [x] Verify no fixtures contain real phone/SIM/device data and no logs add body/address/PDU/fingerprint. - -Target files/areas: - -- `internal/modemadapter/qdc507sms/adapter.go`, `driver.go`, `message.go`, `state.go`, `sqlite_state.go` -- corresponding adapter/driver/transport/state tests -- `internal/modemadapter/sms.go` - -Focused validation: - -```bash -go test -race ./internal/modemadapter/qdc507sms ./internal/modemadapter -``` - -Rollback point: retain any v2 database; never delete uncertain operation or partial ACK evidence during rollback. - -### Phase A5 — Per-Line transport resolver - -- [x] Replace `sender/inbox/hostVoWiFiSMS` global selection with explicit transport bundles and one resolver owned by messaging. -- [x] Define eligibility separately from availability and return ambiguity for multiple eligible bundles; never priority-select or fallback. -- [x] Resolve once for Send, persist before the selected dispatch, and carry the resolved SIM fingerprint to Agent. -- [x] Resolve each Line independently for inbound and use inbox/reports from the same selected bundle; collect a failed Line without starving other ready Lines, and never try another transport for it. -- [x] Update Simulator composition to one synthetic Agent bundle and hardware composition test fixtures to Agent + optional VoWiFi bundles. -- [x] Add native-only, VoWiFi-only, zero, ambiguous, selected-unavailable and selected-runtime-failure tests with call counters; retain existing Host VoWiFi submit-report behavior. -- [x] Add Web/API message status presentation for new bounded preflight failure codes without exposing raw transport detail. - -Target files/areas: - -- `internal/application/messaging/service.go`, `inbound.go`, gateways and tests -- `cmd/simplusd/main.go`, `main_test.go` -- `web/src/messages/status*.ts`, `web/src/api/errors.ts`, focused page tests - -Focused validation: - -```bash -go test -race ./internal/application/messaging ./cmd/simplusd -corepack pnpm --dir web test -- Messages -corepack pnpm --dir web typecheck -``` - -Rollback point: global transport fields must be removed atomically; no hybrid global/per-Line path may remain. - -### Phase A6 — Fixture-only HIL composition and pre-HIL quality gate - -- [x] Add a `qdc507_hil` build-tag typed runner/test using the same runtime scanner, gate, target resolver, driver and store. It discovers exactly one QDC507, reads only the private destination from bounded stdin JSON, accepts only timeout argv, and internally generates a bounded 128-bit `crypto/rand` GSM-7 marker; entropy failure stops before runtime construction. It accepts no caller marker, AT/QMI or device path. -- [x] Attempt typed `CNUM` only after successful registration and a fresh unique target/SIM fence; discard the value in memory, report only de-identified available/unavailable, continue on unavailable, and keep the approved external destination as expected inbound sender. -- [x] Make initial state capture, operation count, unknown-outcome stop, identity/generation recheck and RF/service cleanup explicit and testable with fakes. -- [x] Keep the runner out of normal build/test and ordinary production handler; no long-lived `--enable-qdc507-sms` or generic HIL socket. -- [x] Run an architecture-boundary audit: QDC507/VID/PID/AT/QMI/device-path knowledge may appear only in composition, discovery, registry, adapter/driver, private fixtures and documentation—not in Line, messaging, Managed Modem, HTTP/OpenAPI or Web control decisions. -- [x] Run all Stage A deterministic checks and a privacy diff review. -- [x] Stop and present the exact real HIL proposal. Do not deploy/restart Agent, set RF, select CPMS on hardware, send/receive SMS or run the tagged HIL until the user explicitly approves that exact proposal. - -Target files/areas: - -- focused build-tag HIL test/runner near `internal/hardwareprobe` / `qdc507sms` -- Makefile build-only target and `docs/development.md` command classification, if a persistent target is retained -- test fixtures for the runner state machine - -Pre-HIL validation: - -```bash -go test ./internal/agentapi ./internal/attransport ./internal/modemadapter/... ./internal/hardwareprobe/... -go test ./internal/application/inventory ./internal/application/modem ./internal/application/line ./internal/application/messaging -go test ./internal/api/httpapi ./cmd/simplus-agent ./cmd/simplusd ./internal/containercontract -make check-format -make verify-generated -make check-container-files -make check-docs -make lint -make test -corepack pnpm --dir web build -make web-e2e -make security -``` - -Safe static/build commands do not authorize HIL. Diagnose scoped failures only; do not repair unrelated work. - -### Phase H — Exact authorization and controlled HIL (hard stop) - -Observed partial result: the one approved outbound was delivered to the peer but -remains protocol outcome-unknown/unconfirmed; subscriber-number observation was -available; the reply arrived after runner stop; RF Off restoration was blocked by -two active mode=1 data calls. The separately approved receive-only recovery was run -once and stopped on multiple inbound candidates before application persistence or -ACK/delete; no retry was attempted. Later code/standard review established that its -`CMGL=4` list may have marked unread records read and that assembled candidates were -written to the Agent-private recovery database during listing. They were not promoted -to the user-visible application database. The later fresh inbound path and separately approved new -outbound confirmation both completed; the confirmation returned persisted/confirmed/complete while the -historical unknown remained unchanged. Stage B is unblocked for this exact SMS capability. - -- [x] Diagnose the delivered-but-unknown submit path without resending: add exact one-time payload-echo - suppression below `TTYTransport.Prompt`, preserve every non-exact/duplicate/URC line, and add low-level plus - complete `Driver.Send` regressions for `CMGF/CPMS -> prompt -> payload echo -> +CMGS -> OK`. Treat this as a - high-probability root cause only; never mutate or resend the original unknown operation. -- [x] Implement a separate build-tag `simplus-qdc507-hil-outbound-confirm` runner for a future newly - authorized one-shot submit. Its concrete graph is send-only: `QDC507Outbound` fixed registration/ - equipment/QCCID observation, shared operation gate, `SMSOutboundRouter`, outbound-only tty driver, - operation-only existing-v2 SQLite store, and a narrow application store that can create/finish only - its new row. It retains no Line/topology service, RF setter, inbound List/Read/ACK/Delete, subscriber, - call/data/QMI/SIM-AKA or generic AT capability. It requires RF already On, SIM ready, no voice/fax/unknown call, - no pending inbound state and no prior confirmation attempt; waits boundedly for registration, creates - a crypto-random operation ID and marker, sends at most once, preserves the original unknown app/ledger - rows byte-for-field, and never retries unknown. Add it to the compile-only Make target; implementation - and tests do not authorize running it. The exactly approved run is complete; existing mode-1 data bearers - were left untouched and were not treated as voice calls. - -- [x] Add a separate tagged inbound-recovery command whose interfaces and object - graph contain no Send/RF/registration/call/data/subscriber capability or - destination/marker input; use discovery-only, identity/SIM-only and - command-only concrete types rather than hiding complete objects behind interfaces. -- [x] Correlate the complete private application message set with exactly one - same-SIM recovery outcome-unknown operation using operation ID and constant-time - request-digest comparison; never query recovery SQLite schema from `qdc507hil`. -- [x] Add receive-only fencing, exact sender/body cardinality, persist-before-ACK, - strict raw-PDU cardinality, PDU revalidation/delete, pending-empty, - replay/restart and service-restore tests. -- [x] Obtain separate exact authorization before executing recovery. Do not use - recovery implementation or checks to access the real modem/SIM/database, start - services, read/delete real SMS, change RF, or introduce hangup/data controls. - -- [x] Execute the authorized receive-only recovery exactly once with a two-minute - inbound deadline and five-minute total deadline. It stopped on multiple inbound - candidates before application persistence or ACK/delete; no outbound, RF, - registration, call/data action, or retry occurred. Temporary binary removed and - both fixed Agent services verified inactive/disabled with sockets absent. Later - review records the possible unread-to-read transition and the expected private - recovery-ledger candidate writes; neither is described as a fully read-only run. -- [x] Obtain exact authorization for one read-only classification of whether the - multiple candidates contain exactly one source+marker match. This authorization - does not include adoption/ACK/delete and is not exercised by ordinary implementation checks. -- [x] Implement a third build-tag-isolated classifier binary whose concrete graph has - no application/recovery write, Send, ACK/Delete, RF, registration, call/data, - subscriber-number, arbitrary Command or Prompt capability. Open application and - recovery SQLite with `mode=ro` only; derive the candidate from one application - snapshot, correlate the unique same-SIM unknown ledger, parse fixed-SM raw PDUs in - memory, recheck the complete fence before/after, and emit only bounded de-identified - cardinality/integrity categories. -- [x] Add deterministic construction/reflection, true read-only DB/no-artifact, - exactly-one/zero/multiple, unexpected/malformed/incomplete, fence, cleanup, CLI and - privacy tests. Include the classifier in the compile-only tagged Make target while - keeping production/default registry closed. -- [x] Review identified that 3GPP/current Quectel `CMGL=4` changes `REC UNREAD` to - `REC READ`; keep the real classifier constructor fail closed while retaining tagged - parser/fence/SQLite fixtures. The existing read-only authorization does not cover - this SIM state mutation. -- [x] Select and implement the fixed CRSM EF_SMS GET RESPONSE/READ RECORD double-scan candidate, - update PRD/design/spec/docs, and cover exact legacy/FCP fixtures, status bytes, parser bounds, - scan/fence changes, concrete graph and production-closed behavior. This is fixture-ready only. -- [x] Implement a separate tagged CRSM unread-preservation verifier with no application/recovery DB, - candidate input or broad modem capability: fixed service ownership, unique QDC507, one gate across - zero-unread double-scan baseline, de-identified ready flush, exact free-to-unread transition and three - stable full scans with fresh fences. Add bounded arrival/total CLI, privacy/object-graph/state-machine - tests, compile-only Make wiring and implemented-not-run docs. Do not execute it. -- [x] Under a new exact authorization, run the current QDC507 CRSM preservation verifier once with - a two-minute arrival deadline and five-minute total deadline. It failed with a de-identified unknown - result before `ready-for-one-controlled-inbound`, so no new SMS was requested/sent and no retry was - attempted. Temporary binary was removed; both fixed Agent services remained inactive/disabled and - sockets absent. This did not verify response shape or unread preservation. -- [x] Cancel additional CRSM scans: the user-reviewed exact cleanup and subsequent normal - fresh-inbound validation superseded this diagnostic branch, so no renewed authorization - was requested and the real classifier remains fail closed. -- [x] Do not execute the tagged read-only classifier against the real modem/SIM/private HIL - databases; retain it only as a compile-tested fixture until a separate future task proves - the firmware contract and obtains new exact authorization. -- [x] Preserve the hard stop: no classifier result was used for adoption, application write, - ACK/delete or recovery action. - -- [x] After the user privately reviewed exactly two pending assembled recovery candidates and - explicitly authorized clearing only those two, implement a separate - `simplus-qdc507-hil-clear-reviewed` tagged entrypoint. It does not read the private TXT and - hard-codes reviewed count 2. Its concrete graph retains only fixed service ownership, - QDC507 equipment/QCCID/SIM-ready fencing, one full-cleanup operation gate, a dedicated - `CMGR=<stored index>` / single-index `CMGD=<same index>` driver, and a narrow v2 recovery - store that validates private artifacts/schema/WAL/FULL/integrity before `mode=rw` and can mutate only - monotonic segment delete-started/deleted progress plus one atomic both-record ACK transaction. - A delete-started crash residue stops before another read/delete. Document that CMGR may mark unread read before - the approved deletion. -- [x] Add tagged race/fixture coverage for exact-two, malformed/already-ack/subscription mismatch, - read-digest-delete-persist ordering, multipart progress, first-success/second-failure, - generation/SIM/index/digest/delete uncertainty, DB failure, operations/sender/body immutability, - schema/tamper/artifact/symlink/mode/WAL, service restoration, object-graph/command allowlist, - output privacy, no list/bulk delete, and ordinary production closure. Add the binary to the - compile-only Make target and update canonical docs/spec/design as implemented-not-run. -- [x] Execute the reviewed cleanup only in the main session under the already exact authorization. - Code/test agents must not stop services, open the real private database, touch modem/SIM, run - the tagged binary, or delete the private TXT. Stop without automatic retry on any uncertainty; - execution is not part of implementation validation and does not promote HIL/production evidence. - The main session deletes the outside-repository private TXT only after a successful DB-only cleared - verification; every failure or unknown result retains it for review. -- [x] The one authorized run returned `reviewed=2; cleared=0; pending=unknown` before segment deletion. - A subsequent read-only recovery-DB progress summary (no SIM access) confirmed three pending segments, - zero `delete-started`, and zero deleted segments. No retry occurred; the private TXT remains. Obtain - renewed exact authorization before any diagnostic scan or later cleanup attempt. -- [x] Apply a diagnostic-only cleanup follow-up: allow SQLite to create only revalidated private WAL/SHM - auxiliaries for an existing valid main DB, add the exhaustive bounded `stage` output contract, and make - ordinary QDC runtime fail closed on `delete-started` while preserving legacy segment JSON. This follow-up - used only tagged synthetic/compile checks; it did not rerun cleanup, open the real private DB, stop services, - or touch modem/SIM. The historical one-run output above predates the `stage` field. -- [x] After the user explicitly requested continuing the already-authorized cleanup without repeated approval, - rerun the same exact-two target once after independent review. It completed with - `reviewed=2; cleared=2; pending=zero; stage=complete`; all three stored PDU segments were revalidated and - deleted, both recovery records were atomically acknowledged, and the private TXT/temporary binary were - removed. Both fixed Agent services remained inactive/disabled with sockets absent. - -- [x] After the user confirmed the next normal inbound flow, implement a separate - `simplus-qdc507-hil-fresh-inbound` tagged runner rather than reusing the old marker-bound recovery. - Derive only bounded E.164 peer and Line from one read-only application snapshot of the unique original - outcome-unknown outbound; correlate its unchanged same-SIM unknown ledger read-only; compile expected - body `OK` in and accept no private input. Take service ownership before opening state and restore it - unconditionally. Require recovery pending zero plus strict fixed-SM physical zero before a fresh fence and - flushed ready; after ready accept exactly one complete peer+`OK`, persist application DB before ACK, then - require PDU revalidation/single-index delete/progress and final application/provider idempotency, original - outbound unchanged, recovery zero and SM zero. Support only startup-snapshot exact app-persisted and - physically-complete SIM-pending replay before any acknowledgement operation has started; reject a - fresh-branch application replay, ledger-only segment, or accepted/uncertain ACK without another delete. -- [x] Add tagged state-machine/application-store/object-graph/CLI/source/privacy tests for wrong sender/body, - multiple/incomplete/malformed, persist/fence failure, delete uncertainty, replay/idempotency, final zero, - cleanup and no Send/Prompt/RF/register/call/data/subscriber/outbound mutation. Add the command to the - compile-only Make target and update docs/spec/design as implemented-not-run. Do not execute it or access - real service/modem/SIM/private DB during implementation checks; this flow uses no CRSM auxiliary validation. -- [x] In the main execution session only, use the already user-confirmed flow: two-minute arrival/five-minute - total deadline; wait for the flushed ready line, then ask the original approved peer to send exactly one - new body `OK`; stop without automatic retry on any error or uncertainty. Record only bounded stage/result. -- [x] Diagnose the pre-ready malformed baseline without exposing private material: add bounded decode/incomplete/ - content categories, confirm the protocol difference with a one-shot private structural probe, and add - 16-bit concatenation UDH (`IEI 0x08`) decoding plus regression while retaining 8-bit-only outbound encoding. - After the fix, retain the correct non-empty baseline stop; export the exactly two newly assembled pending - records only to an excluded 0600 private TXT for user review, without application persistence, ACK or delete. -- [x] After the user reviewed and approved those exactly two new pending records, execute the fixed exact-two - cleanup once; it completed with `cleared=2; pending=zero; stage=complete`, then remove the private TXT and - temporary binary. A following fresh-inbound run reached and flushed ready but received no new message within - the two-minute window; it stopped at `stage=arrival` with DB-only recovery pending still zero and no app/ACK/delete side effect. -- [x] On the next approved fresh-inbound window, retain the unique arrived `OK` candidate after an arrival-loop stop; - prove it is the exact 11-digit domestic representation of the one approved `+86` peer, add a narrowly bounded - comparison/canonicalization regression, persist it once through a build-tag one-shot bridge with no ACK/Delete, - then remove that bridge and finish through the ordinary replay path. Final result: - `ready=false; persisted=true; cleared=true; pending-zero=true; stage=complete` with no resend or broader radio action. - -- [x] Complete the separately authorized fresh inbound and new outbound confirmation without reusing or mutating the historical unknown operation; retain only sanitized results. -- [x] Confirm no native/container Agent competed, keep RF unchanged, restore service ownership, stop on uncertainty, and remove temporary binaries/input artifacts. - -This phase intentionally has no unconditional shell command in the plan; the exact command depends on the approved target and current service ownership and must be restated at execution time. - -### Phase B1 — Evidence promotion and production Agent composition - -Prerequisite: Phase H passed completely. - -- [x] Promote only QDC composite `sms-control` evidence to observed; keep base/default discovery registry non-production. -- [x] Add required private `state-root` composition, fixed v2 state filename, adapter/runtime registry/gate/SMS backend construction and failure cleanup in `cmd/simplus-agent`. -- [x] Expose SMS only through the typed managed hardware handler; make `WriteTimeout >= agentapi.SMSRequestTimeout` and add contract tests. -- [x] Close/checkpoint the store after servers and monitor stop; startup and shutdown failures remain non-zero. -- [x] Update container entrypoint, native dev unit, Debian installer and tests with the fixed state root. Do not add capabilities, network attachment, device rules or writable sysfs. -- [x] Test missing identity key/state root, non-private path, symlink, incompatible schema, backend construction failure, clean shutdown and no enable flag. - -Target files/areas: - -- `cmd/simplus-agent/main.go`, tests -- `internal/agentapi/server.go`, client/server tests -- `containers/agent-entrypoint.sh`, `scripts/dev/install-agent.sh`, `scripts/release/install-debian.sh` -- `internal/containercontract/contract_test.go` - -Focused validation: - -```bash -go test ./cmd/simplus-agent ./internal/agentapi ./internal/containercontract -make check-container-files -``` - -### Phase B2 — Production simplusd composition and public docs - -- [x] Update typed Agent policy to require RF, equipment identity and SMS features while rejecting retired generic mutation features. -- [x] Always construct/register Agent native SMS in hardware backend; independently register Host VoWiFi when configured. -- [x] Add composition tests proving simultaneous availability, QDC native selection and ML307A VoWiFi regression. -- [x] Add ADR 0026 and update architecture, active MVP plan, compatibility matrix, development/HIL classification, installation state-root details and sanitized handoff in their canonical owners. -- [x] Compatibility wording names HIL (not Runtime), exact capability, remaining unverified phone/data/other-SIM/operator/long-term boundaries and automated guards; it contains no raw evidence. -- [x] Run the complete non-HIL final gate and inspect the full diff for credentials, phone/SIM/device identity, private paths/topology, raw transcripts/PDU/logs/screenshots and accidental product-scope expansion. - -Target files/areas: - -- `cmd/simplusd/main.go`, tests -- `docs/decisions/0026-*.md` -- `docs/architecture.md`, `docs/plans/active/mvp.md`, `docs/compatibility.md` -- `docs/troubleshooting.md`, `docs/development.md`, `docs/installation.md`, `docs/handoff.zh-CN.md` - -Final validation: - -```bash -go test -race ./internal/application/messaging ./internal/hardwareprobe ./internal/modemadapter/... -make check-format -make verify-generated -make check-container-files -make check-docs -make lint -make test -corepack pnpm --dir web build -make web-e2e -make security -git diff --check -``` - -No final validation command may repeat real SMS/RF/HIL. Phase H evidence is separate. - -## 3. Acceptance Mapping - -| PRD acceptance | Owning phases | -| --- | --- | -| AC1 identity/operator/subscriber fixtures | A1, A6 | -| AC2 stable modem/Line/privacy/port change | A1, A3, A4 | -| AC3 registration Web status | A2 | -| AC4 explicit RF only | A1, A2, A3 | -| AC5 unique per-Line transport/no fallback | A5, B2 | -| AC6 fenced preflight/stable failures | A2, A3, A5 | -| AC7 outbound encoding/idempotency/unknown | A4 | -| AC8 inbound durability/SIM storage/swap | A4 | -| AC9 production Agent lifecycle | B1 | -| AC10 evidence gate | A6, H, B1, B2 | -| AC11 controlled real HIL | H | -| AC12 repository quality gate | A6, B2 | - -## 4. High-Risk Files and Review Points - -- `internal/application/messaging/service.go` / `inbound.go`: preserve persist-before-dispatch, persist-before-ACK, per-modem gate and Host VoWiFi submit reports while removing global selection. -- `internal/modemadapter/qdc507sms/*`: no state-key use of transient device ID; no loss of outcome-unknown or PDU index-reuse guards. -- `internal/hardwareprobe/*`: no deadlock when a fenced SMS preflight probes under the shared gate; use a locked internal helper rather than recursively acquiring. -- `internal/agentapi/*`: request/response correlation, bounded fingerprint validation, stable error mapping and log privacy must change on both ends together. -- `internal/application/inventory` / `line` / `messaging`: construct the private runtime target from existing topology evidence without adding fingerprints to public Line/API or logs. -- `cmd/simplus-agent/main.go`: construction must close every partially opened dependency and align HTTP timeout with 130-second Agent request budget. -- `cmd/simplusd/main.go`: hardware Agent policy promotion only after HIL; no Simulator fallback and no VoWiFi override. -- architecture boundary: concrete QDC507 construction is allowed in the Agent composition root and model metadata may be displayed, but application/domain/API/Web control flow must remain replaceable by any adapter implementing the same capability contracts. -- `api/openapi.yaml` + generated outputs: one source, deterministic generation, no private probe fields. -- deployment/docs: state root is private data, Agent remains no-network and unprivileged after entrypoint. - -## 5. Rollback Rules - -- Before HIL: remove/adjust Stage A code normally; production remains closed. -- After unknown send: never retry the same business message with a new operation ID and never delete its Agent operation state. -- After partial inbound ACK: retain v2 state and let a corrected build reconcile only after PDU revalidation. -- On failed production rollout: remove Agent/simplusd production wiring and return QDC SMS evidence to non-observed, but retain the private v2 database. -- Restore RF/service ownership only for the same fenced target; ambiguity blocks automatic cleanup and requires user direction. -- Never use VoWiFi, QMI WMS, data attachment, broader privileges or an unrelated code repair as an implicit rollback. - -## 6. Follow-up Checks Before `task.py start` - -- [x] `prd.md` has no unresolved product decision or open question. -- [x] `design.md` fixes data flow, contracts, no-fallback semantics, state namespace, HIL gate and rollback. -- [x] This implementation plan maps every AC and has a hard authorization stop before HIL. -- [x] `implement.jsonl` and `check.jsonl` contain only relevant specs/research/docs and validate. -- [x] Latest planning summary is shown to the user. -- [x] A subsequent user message explicitly approves that exact summary for implementation. diff --git a/.trellis/tasks/archive/2026-08/08-12-dji-4g-cellular/prd.md b/.trellis/tasks/archive/2026-08/08-12-dji-4g-cellular/prd.md deleted file mode 100644 index 45e54c8..0000000 --- a/.trellis/tasks/archive/2026-08/08-12-dji-4g-cellular/prd.md +++ /dev/null @@ -1,168 +0,0 @@ -# 大疆 QDC507 原生蜂窝短信 - -## Goal - -让安装中国联通实体 SIM 的大疆 QDC507 成为可稳定添加、可创建 Line、可观察蜂窝注册状态的模组,并通过运营商蜂窝基站完成原生短信收发。整个纵切明确不使用 VoWiFi,也不包含电话或通用蜂窝数据。 - -## Background and Confirmed Facts - -- QDC507 与 ML307A 共用一个 `simplus-agent`、Agent 协议和 adapter registry;型号差异留在类型化 adapter 内,不建立第二套 Agent(`docs/architecture.md:59-72`、`docs/compatibility.md:24`)。 -- 当前 QDC507 adapter 已支持 USB 识别、固定 primary AT interface 2、QMI interface 4、标准只读 AT probe 和 SIM presence,但尚未实现稳定设备身份、SIM/Profile 身份或可调用的 RF adapter(`internal/modemadapter/qdc507.go:15-64`、`docs/compatibility.md:21`)。因此它还不能满足稳定 `ManagedModem -> Line` 绑定。 -- 仓库已有未装配的 QDC507 PDU-mode SMS driver、Linux tty transport、durable SQLite recovery 和 fixture。默认 registry 与 production Agent 均不声明 `sms-v1`(`docs/architecture.md:105-122`、`internal/modemadapter/qdc507sms/adapter.go:18-47`、`cmd/simplus-agent/main.go:127-156`)。 -- 该项是任务开始时的 baseline;受控 HIL 完成后,production composite 现已装配 `sms-v1`, - 而安全的 `DefaultRegistry()` 仍保持 SMS closed。 -- 标准 probe 已读取 RF、SIM、CS/packet/EPS 注册域、当前运营商/RAT 和信号;模组页目前只显示 online、RF 与 SIM presence,不能判断是否连上基站(`internal/modemadapter/standardat/probe.go:25-98`、`internal/application/modem/service.go:96-130`、`api/openapi.yaml:2141-2155`)。 -- hardware `simplusd` 当前把短信 sender/inbox 全局设置为 Simulator、Host VoWiFi 或空;原生 Agent SMS 与 Host VoWiFi 无法同时按 Line 路由(`cmd/simplusd/main.go:83-169`、`internal/application/messaging/service.go:199-270`)。既有 ADR 要求依据实际 transport、Line 能力和实时可用状态 fail closed,禁止静默 fallback(`docs/decisions/0019-line-identity-and-communication-paths.md:21`)。 -- RF 属于 `ManagedModem` 且只能由管理员显式改变;添加模组、创建 Line、短信与 Host VoWiFi 均不得隐式改变 RF(`docs/decisions/0017-managed-modems-and-capability-adapters.md:25`、`docs/decisions/0019-line-identity-and-communication-paths.md:16-18`)。 -- Simplus 产品范围包含原生蜂窝短信,但明确排除通用蜂窝数据上网、热点、NAT 和蜂窝网关(`docs/product.md:5`、`docs/product.md:84`)。电话属于未来产品范围,但不在本 MVP。 -- Quectel EC25/EC21 官方资料为 `CGSN/QCCID/CIMI/CRSM/CFUN/CPMS/CMGF/CMGL/CMGR/CMGS/CMGD` fixture 提供依据;这不能代替 QDC507 固件和当前 SIM 的真实 HIL。DJI 官方资料说明 SIM PIN 会阻止联网;Simplus 只报告锁卡,不修改 PIN。 -- 2026-08-12 的固定只读 HIL-0 尝试因开发 Agent 未运行而在接触 modem 前停止;本任务尚无新的 QDC507/SIM/RF/注册现场证据,也未部署或重启服务。 - -## Requirements - -### Sanitized partial HIL evidence - -- 获批的单次出站 marker 已由测试 peer 实际收到,但 runner/协议结果保持 - outcome-unknown,因此应用消息仍为 `unconfirmed`,禁止重发;这不是完整 HIL pass。 - 代码复核定位到一个能解释该现象的高概率协议差异:真实 modem 可能在 payload dispatch 后 - 回显刚发送的 exact hexadecimal PDU,而旧 tty transport 只过滤 `AT+CMGS` 命令回显;后续 - `+CMGS`/`OK` 因多出一行而被保守归类为 malformed post-dispatch/unknown。实现现只过滤最多 - 一次、逐字节等于刚发送 payload(可带 Ctrl-Z)的回显;不同 hex、重复回显与 URC 仍 fail closed。 - 该推断没有原始私有 transcript 可反证,必须通过一条新的独立获批出站验证,且不回改旧 unknown。 -- 为该新出站实现了独立 build-tag send-only confirmation runner;它只保留固定注册/身份观测、 - typed outbound router/adapter/driver、operation-only existing-v2 recovery store 与单行应用状态迁移, - 没有 RF/入站/电话/数据/号码/generic command 能力。它在发送前要求同一 fenced SIM、RF 已 On、 - 无语音/传真/未知通话、无 pending inbound、无历史 confirmation attempt,并生成新的随机 marker/ - operation ID;最多发送一次,旧 unknown app/ledger 永不重发或修改。该入口已按精确授权执行, - 去标识结果为 persisted/confirmed/complete;历史 unknown 仍未修改或重发。 -- 本机号码的瞬时私有观测为 available;号码本身未记录。peer 的入站回复在 runner 停止后 - 到达。获批的 receive-only recovery 随后在 `SM` 发现多条入站候选并按歧义停止;它没有 - 接管到应用库,也没有 ACK 或删除任何 SIM 短信。后续代码复核确认该列表路径可能把 - `REC UNREAD` 改成 `REC READ`,并把已组装候选写入 Agent 私有 recovery 数据库;没有 - 把这些候选提升为用户可见消息。继续分类或接管需要新的精确授权。 -- 初始 RF Off 未能恢复,因为清理阶段观察到两个 active mode=1 data calls。没有加入 hangup、 - 强制 RF Off、电话或数据控制。后续确认入口证明 mode=1 是可与 SMS 共存的既存 data bearer; - 它未被创建、配置、挂断或公开,语音/传真/未知通话仍阻断。入站与新确认出站 HIL 均通过后, - production SMS 已按 typed adapter/store/router 装配。 -- 针对上述多候选歧义,build-tag 隔离的只读 classifier 已实现并通过合成测试,但尚未在 - 真实 modem/SIM 或私有 HIL 数据库上执行。它只报告去标识的候选/匹配 cardinality 与 - PDU 完整性,不写应用/Agent 数据库,不 ACK/delete/send,不改变 RF 或最终服务状态。 - 即使报告多条中恰一条匹配,也必须在新的精确授权前停止,不能据此接管或删除。 -- 复核发现 3GPP/当前 Quectel 契约下 `CMGL=4` 会把 `REC UNREAD` 改为 `REC READ`;因此 - 旧 classifier 已由 build-tag-only 固定 CRSM EF_SMS 双扫描替换。新 concrete graph 只发 - GET RESPONSE/READ RECORD,固定 `6F3C`/`7F106F3C`/absolute P2=4/176-byte record, - 不含 `CMGF/CPMS/CMGL/CMGR/CSIM/UPDATE` 或 fallback。标准与官方 Quectel family fixture - 只证明 parser/transcript 设计,不证明当前 QDC507 固件的 CRSM response shape 或 unread - preservation;真实 constructor 因而继续 fail closed。验证该固件并执行分类都需要新的 - 精确授权,既有“只读分类”授权不覆盖新的 CRSM 验证动作。preservation 验证必须使用 - 另行获批、为本次验证新建的受控 synthetic unread fixture,不得把现有私有 inbox 候选 - 隐式当作验证输入。 -- 获批的 CRSM preservation 验证已严格执行一次,但在输出 ready 之前以去标识 unknown - 结束,因此没有请求或产生新的受控入站短信,也没有重试。该结果无法区分现存 unread、 - 固件响应 shape 不兼容、基线不稳定或 fence 失败;没有新增授权前不得追加扫描诊断。 -- 独立 build-tag-only preservation verifier 已实现并通过合成 fixture,但从未真实运行。它不打开 - application/recovery DB,不读取既有私有候选;固定 service ownership 后在同一 device gate 中 - 要求 zero-unread 双扫描 baseline,只接受一条新 controlled inbound 导致的唯一 free-to-unread - 变化,再要求三次 full scans 中全部 176 bytes 与所有其他 records 保持不变。CLI 只有有界 - arrival/total timeout,ready/final output 去标识;真实执行、从获批 peer 手工发送 exactly one - 新消息以及首次 CRSM read 可能改变 unread 的风险仍需要新的精确授权,且只能运行一次、不得 - retry。成功也只解除后续 code/spec review blocker,不授权 classifier/adoption/ACK/delete。 -- 用户已经在仓库外私有 TXT 中逐条审阅两条 pending assembled recovery candidate,并精确批准 - 清空这两条记录对应的 SIM stored segments;公开任务材料不记录其内容、sender、index、PDU、 - identity、时间或私有路径。独立 build-tag-only reviewed cleanup 入口已经实现并通过合成测试, - 但代码/测试阶段从未运行,没有访问真实 recovery DB、modem/SIM 或服务。授权 count 固定为 2, - 只覆盖当前 fenced SIM 的恰好两条 unacknowledged inbound recovery records;不覆盖新到/未记录 - 短信、bulk delete/list、application/operations/outbound mutation、发送、RF、注册、电话或数据。 -- 获批的 reviewed cleanup 随后严格执行一次并在逐段删除前返回去标识 - `cleared=0; pending=unknown`,没有重试。DB-only 进度检查确认两条记录共三个 stored segments - 全部仍为 pending,`delete-started=0`、`deleted=0`,因此没有物理 PDU 被删除或留下删除不确定态; - 私有 TXT 当时保留。随后修复 SQLite WAL/SHM 合法辅助文件的 false-negative,并按用户要求 - 继续同一已审阅清理目标;第二次执行返回 `cleared=2; pending=zero; stage=complete`。三个 PDU - 分段均逐段复核后删除,两条 recovery records 在一个事务中 acknowledged,私有 TXT 与临时 - 二进制已删除,服务状态保持 inactive/disabled。 - 每段必须 fresh fence、stored-index `CMGR` + 常时 digest 复核、single-index `CMGD`、progress commit; - `CMGR` 可能在删除前把 unread 改成 read,该两条记录的删除授权明确接受此中间变化。任何歧义或 - 不确定立即停止且不自动 retry;`CMGD` 前先持久化 delete-started,只有确定删除、post-fence 与 - 最终 CAS 都成功才变为 deleted,重启发现 delete-started 时不会再次读取或删除该 index。完成后 - 只通过 recovery DB 验证两条在一个事务中同时 acknowledged 和 subscription pending empty。 - -- 上述 reviewed cleanup 完成后,用户已明确确认继续一次正常蜂窝短信端到端入站验证:独立 - build-tag-only fresh-inbound runner ready 后,由原已批准测试 peer 手工发送恰好一条正文精确为 - `OK` 的新短信,目标是 application DB persist -> PDU revalidate/single-index delete -> recovery/ - `SM` pending empty。本轮不再做 CRSM 辅助验证。该 runner 已实现并通过合成测试,但代码/测试 - 阶段未运行,没有停止服务、打开真实私有 DB 或访问 modem/SIM。它只从原 outcome-unknown outbound - 的单一 read-only application snapshot 推导 bounded E.164 peer 与 Line,并只读核对同 SIM unknown - ledger;新正文固定编译为 `OK`,不复用旧 marker,也不修改原 outbound/operation。 - -- 该 fresh-inbound 纵切最终已完成:唯一受控 `OK` 先持久化到应用 DB,再经 PDU revalidate、 - single-index delete 与 durable progress 清理,最终 application/recovery/`SM` 一致且 pending zero; - 原 outcome-unknown outbound 与同 SIM ledger 保持不变。公开材料只记录去标识结论,不记录号码、 - PDU、设备/SIM 身份或私有路径。该成功只覆盖当前 SIM/批准 peer 的入站,不自动开放 production。 - -- fresh-inbound 真实基线随后暴露出合成 fixture 缺失的标准 16-bit concatenation UDH - (`IEI 0x08`);原 parser 只接受 8-bit `IEI 0x00`,因此把可组装的真实长短信误报为 malformed。 - 实现现已无损支持 8/16-bit 入站 reference,同时保持出站只生成既有 8-bit transcript,并新增 - 合成回归。修复后基线正确收敛为 non-empty physical inbox:当前另有两条完整 pending 记录, - 未写应用库、未 ACK/delete;内容只导出到本地排除的 0600 私有 TXT 供用户审阅。 - -- 用户审阅并批准上述新增两条后,exact-two cleanup 返回 - `cleared=2; pending=zero; stage=complete`,私有 TXT 与临时二进制已删除。随后的正常 - fresh-inbound 已成功 flush ready,但两分钟窗口内没有新短信到达,以 - `ready=true; persisted=false; cleared=false; pending-zero=false; stage=arrival` 安全结束; - DB-only 复核为 recovery pending zero,没有应用写入、ACK/delete 或不确定进度。 - -- 用户随后重新开启 fresh-inbound 并在 runner flush ready 后发送唯一一条正文精确为 `OK` 的短信。 - 首进程在 arrival 轮询失败后停止,但 Agent recovery 已有唯一 candidate,应用仍 absent、ACK 未开始, - 因此未要求重发。去标识复核证明 modem 报告的是批准 `+86` E.164 对端的精确 11 位国内表示法; - HIL 仅在逐位等于该唯一批准 peer 后规范化为批准 E.164。一次性 persist-only bridge 先落应用库, - 随后现有 replay 路径完成 fresh fence、PDU digest 复核、single-index delete 与最终全量验证,返回 - `persisted=true; cleared=true; pending-zero=true; stage=complete`。没有新出站、RF、电话、数据或 VoWiFi 动作。 - -- R1 — 路径与范围:QDC507 本任务内的通信只走运营商蜂窝网络;不启动、不依赖、不回退 Host VoWiFi。MVP 不启用电话、数字通话媒体、蜂窝数据、APN、热点、NAT 或网关。 -- R2 — 统一的类型化能力边界:Web/API 和应用层只提交稳定业务 ID、固定业务意图,并按身份、SIM、运行状态、RF、SMS 等小型能力接口工作;不得按 `QDC507`、型号、VID/PID 或厂商做控制分支。VID/PID、interface、设备路径、AT/QMI 文本与厂商响应只能存在于 discovery/registry/adapter/driver 内;不得新增任意命令或设备路径入口。以后接入另一款已验证的同能力模组时,只需实现并注册相同 adapter 能力,不应修改 Line、消息、Managed Modem、HTTP/OpenAPI 或 Web 业务流程。 -- R3 — 稳定身份:QDC507 必须用型号固定的只读流程取得经校验的 IMEI,并转为每实例 HMAC 绑定;当前 SIM/Profile 必须用 ICCID 的每实例 HMAC 和掩码提示表示。原始 IMEI 仍只允许经现有显式 no-store 读取,原始 ICCID/IMSI 不进入公共 API、数据库或普通日志。 -- R4 — 运营商与本机号码元数据:归属运营商名称与 MCC-MNC 必须从当前 SIM/Profile 的类型化读取结果 best effort 推导;不得把“中国联通”或 `460-01` 写成 QDC507 型号常量。HIL 可在唯一设备/SIM fence 且蜂窝注册成功后,通过型号 adapter 的固定只读 `CNUM` best effort 读取本机号码;只有唯一、TON 145 且响应已显式携带 `+` 的 E.164 才可作为瞬时私有观测,空、重复、歧义、national format、malformed、overflow 或查询失败均视为 unavailable,不猜测、不阻断注册或短信 readiness。该值不进入公共 HTTP/OpenAPI/Web、数据库、普通日志或 task/HIL 报告;成功 HIL 只可输出去标识的 available/unavailable 能力结论。 -- R5 — 可观察注册:Managed Modem 列表必须显示有界的蜂窝状态摘要、CS/packet/EPS 注册域、当前运营商/PLMN、RAT、信号与观察时间,或稳定失败码。状态只来源于现有只读 probe;不增加扫描/选网/拨号动作。 -- R6 — 显式 RF:QDC507 RF On/Off 只接受管理员在 Managed Modem 上发起的布尔目标,使用型号固定动作并立即读回。不得提供持久上电 RF 策略;添加模组、创建 Line 和短信动作均不得自动开启 RF。 -- R7 — Line 与 transport:管理员可基于稳定 QDC507 与当前 SIM/Profile 创建 Line。原生 Agent SMS 与 Host VoWiFi SMS 必须按每条 Line 选择唯一 transport:`SMS` 能力匹配 Agent,`HostVoWiFiAuth` 匹配 VoWiFi;零个或多个匹配都 fail closed,选定 transport 失败时不得尝试另一个。QDC507 Line 不声明 `HostVoWiFiAuth`,因此只能匹配 Agent。 -- R8 — 发送前置:应用到 Agent 的内部 SMS 请求必须携带当前设备代际,以及 Line 所绑定的预期模组/SIM 脱敏指纹;原生 SMS Send 在同一 Agent 设备操作锁内重新确认 device generation、完整 probe、当前模组与 SIM 指纹仍匹配、SIM READY、RF On 和至少一个 CS/packet/EPS 注册域处于 home/roaming/SMS-only 注册。任何失败返回稳定错误,不自动 RF On、改 PIN、选网、设 APN 或重发;这些指纹不得进入公共 HTTP/API 或日志。 -- R9 — 短信功能:复用现有 GSM7/UCS-2 单段与长短信编码、120 秒整次 multipart dispatch budget、success/definite failure/outcome-unknown 语义和应用层发送前持久化。部分发送、prompt 后响应丢失、进程重启遗留 accepted 均不得自动重发。 -- R10 — 入站与删除:List/Read/ACK 同样携带设备代际与预期模组/SIM 脱敏指纹,并要求当前 target 与身份仍匹配、SIM READY,但不要求当前在网;它们固定选择 (U)SIM 的 `SM` 作为物理收件暂存区,而不是把 SIM 当作产品唯一或长期消息库。完整短信成功写入本机应用消息数据库后才能 ACK;Agent 随后在删除前重新读取并核对 storage index + PDU digest,逐段保存删除进度,再从 SIM 删除已安全接管的 PDU,防止索引复用误删。 -- R11 — 稳定恢复命名空间:QDC507 SMS durable state、入站 message ID 和 send/ACK digest 必须按当前 SIM 的脱敏稳定指纹分区,不按临时 USB 端口 ID 分区。换端口继续恢复同一 SIM,换 SIM 形成隔离命名空间;Agent wire response 仍只回显当前临时 device ID。 -- R12 — 生产装配门:production Agent 只有在真实 HIL 通过、durable state/registry/router 完整构造后才把 QDC507 `sms-control` 提升为 observed 并声明 `sms-v1`。最终已验收构建中,state 打不开、schema 不兼容、adapter 构造失败或依赖缺失必须启动失败,不可退化为无恢复发送;HIL 前构建则完全不装配该 backend。 -- R13 — 真实验收:任务完成必须包含当前 QDC507 + 中国联通 SIM 的受控 HIL:显式 RF On 与读回、蜂窝注册、至少一条获批出站和一条获批入站短信、初始 RF/服务状态恢复。具体号码、内容、次数和停止条件在执行前重新陈述并取得独立授权。 - -## Acceptance Criteria - -- [x] AC1–AC11:实现、fixture、受控入站/出站 HIL、证据提升与 production typed 装配均已完成; - 细项保持以下 AC12 的非 HIL 最终门禁,真实标识和原始证据均未进入仓库。 -- [x] AC12(R1–R13):`go test` focused suites、OpenAPI/Go/TypeScript generation drift、Web typecheck/Vitest/build/desktop+mobile E2E、container/deployment contracts、docs/privacy checks、lint 和全量测试通过;类型断言、registry/router 与合成第二型号 adapter fixture 证明上层仅依赖统一能力接口,应用/domain/HTTP/OpenAPI/Web 不含 QDC507/AT/QMI/设备路径控制分支;所有自动化只使用合成数据,不触发真实 RF、短信、电话、数据或 VoWiFi。 - -## Out of Scope - -- Host VoWiFi 配置、SIM AKA、ePDG、SIP/IMS-over-Wi-Fi 注册,及任何 native/VoWiFi fallback。 -- 通用蜂窝数据 attachment/PDP context、APN、宿主上网、热点、NAT、蜂窝网关、手动选网和网络制式/频段配置。 -- QDC507 电话控制、呼入/呼出、DTMF、来电媒体与 USB 数字音频。 -- SIM PIN/PUK 修改、自动解锁、eUICC mutation、运营商账户/套餐管理,以及把号码发现做成产品功能、Line 身份、公共字段或持久状态;只保留本任务 HIL 内固定 `CNUM` 的 best-effort 瞬时私有观测。 -- QMI WMS;只有 AT PDU-mode HIL 证明该方案无法满足目标后,才能另建需求与授权评估替代方案。 -- 未经执行时独立批准的 Agent 部署/重启、真实短信、RF 变更、modem 持久化写入或其他 HIL-1/HIL-2 动作。 -- 与该纵切无关的 modem、网络或消息历史重构。 - -## Deferred Authorization and Technical Evidence - -- 现有 outcome-unknown 操作的入站恢复另用 build-tag 隔离、构造上无 Send capability 的固定 - runner;它只能从单条应用 `unconfirmed` 与同 SIM 的唯一 recovery unknown operation 推导 - 相关数据,先持久化唯一 exact reply 再 ACK/PDU 复核删除。它不要求注册、不改变 RF、 - 不修改原出站记录,也不把当前部分证据提升为 production readiness。 - -- 多候选后的只读 classifier 是比 recovery 更窄的独立对象图:两个 SQLite 只以真正 - read-only 模式打开,SM 原始 PDU 只在内存解析,入口无私密 argv/stdin,且没有应用写入、 - recovery state mutation、Send、ACK、Delete、Prompt、RF、注册、电话、数据或本机号码能力。 - classifier 的一次报告不是 adoption/ACK/delete 授权,也不改变 HIL 或 production 证据等级; - 固定 CRSM EF_SMS 双扫描实现已通过合成 fixture,但当前固件的 response shape 与 unread - preservation 仍未 HIL 验证,真实入口保持 fail closed,不会形成该报告。 - -- 当前短信主路径的 `CGSN/QCCID/CFUN/CNUM/CPMS`、注册域与指定 SIM/peer 互通已由 fixture + - 最小受控 HIL 收敛;CRSM unread preservation、其他 SIM/运营商/peer 与长期稳定性仍未知。 - `CNUM` unavailable 不阻断,其余关键路径失败时 production 保持 fail closed。 -- 真实 HIL 的精确 Line/号码、合成短信内容、动作次数、前置状态、超时、停止条件、服务切换和回滚将在自动化检查通过后单独列出。此前对“需要完成 HIL”的范围决策不等于执行这些动作的授权,也不扩大到电话、蜂窝数据、VoWiFi、PIN、选网或其他写入。 diff --git a/.trellis/tasks/archive/2026-08/08-12-dji-4g-cellular/research/nonmutating-sms-enumeration.md b/.trellis/tasks/archive/2026-08/08-12-dji-4g-cellular/research/nonmutating-sms-enumeration.md deleted file mode 100644 index 4f11c29..0000000 --- a/.trellis/tasks/archive/2026-08/08-12-dji-4g-cellular/research/nonmutating-sms-enumeration.md +++ /dev/null @@ -1,112 +0,0 @@ -# Research: non-mutating-sms-enumeration - -- Query: Find a fixed QDC507/Quectel + 3GPP SIM `SM` inbound-candidate enumeration that does not change SMS unread/read or persistent SIM/ME state, replacing `AT+CMGL=4`; evaluate `AT+CRSM` over `EF_SMS` (`6F3C`), `EF_SMSP`/`EF_SMSS`, and Quectel vendor alternatives. -- Scope: mixed (repository plus primary 3GPP/ETSI/Quectel documentation only) -- Date: 2026-08-13 - -## Findings - -### Decision - -There is **no QDC507-firmware-specific primary evidence yet** which proves a truly non-mutating list/read command. Therefore the current real classifier must remain closed. Do not replace its fail-closed constructor with an executable reader based only on this research. - -The best *qualified design candidate* is a separate, fixed `AT+CRSM` reader that uses only UICC `GET RESPONSE` and `READ RECORD` against `EF_SMS`, not `CMGF`, `CPMS`, `CMGL`, `CMGR`, `CSIM`, `UPDATE BINARY`, or `UPDATE RECORD`. At the UICC command level `READ RECORD` is a read operation, whereas the standard `+CMGL` and `+CMGR` contracts expressly change received-unread to received-read. This is strong enough to select the implementation direction, but not strong enough to claim QDC507 behaviour or authorize a real run. - -If “does not change *any* SIM/ME state” includes transient current-file selection or undocumented modem cache/locking effects, no AT-level standard proves that absolute property: TS 27.007 says the MT performs locking and file selection internally and coordination with the MT application is implementation-dependent. The proposed contract can claim only: **no persistent EF update command is emitted and no known status-changing SMS command is emitted**. QDC507 HIL evidence is still required before claiming preservation of the actual unread byte. - -### Primary standards and official module sources - -| Source | What it establishes | -| --- | --- | -| [3GPP TS 27.005 Rel-18 / ETSI TS 127 005 V18.0.0, clauses 4.1–4.2](https://www.etsi.org/deliver/etsi_ts/127000_127099/127005/18.00.00_60/ts_127005v180000p.pdf) | `+CMGL[=<stat>]` and `+CMGR=<index>` return the PDU but change `received unread` to `received read`; neither is acceptable for this authorization. | -| [3GPP TS 27.007 / ETSI TS 127 007 V13.7.0, clause 8.18](https://www.etsi.org/deliver/etsi_ts/127000_127099/127007/13.07.00_60/ts_127007v130700p.pdf) | `+CRSM=<command>[,<fileid>[,<P1>,<P2>,<P3>[,<data>[,<pathid>]]]]`; `176` READ BINARY, `178` READ RECORD, `192` GET RESPONSE, and the two UPDATE commands are distinct. MT handles file selection/locking; SIM-command coordination is implementation-dependent; `pathid` is a UICC path from MF. | -| [3GPP TS 31.102 / ETSI TS 131 102 V12.10.0, clause 4.2.25](https://www.etsi.org/deliver/etsi_ts/131100_131199/131102/12.10.00_60/ts_131102v121000p.pdf) | USIM `EF_SMS`: FID `6F3C`, linear-fixed records, length 176 bytes, byte 1 status and bytes 2–176 SMSC address plus TPDU; access is READ PIN / UPDATE PIN. Incoming read and unread status encodings are defined here. | -| [3GPP TS 51.011 / ETSI TS 151 011 V4.10.0, clause 10.5.3](https://www.etsi.org/deliver/etsi_ts/151000_151099/151011/04.10.00_60/ts_151011v041000p.pdf) | Legacy SIM equivalent: `EF_SMS` FID `6F3C`, 176-byte linear-fixed records, READ CHV1 / UPDATE CHV1. | -| [3GPP TS 31.102 / ETSI TS 131 102 V8.21.0, clause 4.7](https://www.etsi.org/deliver/etsi_ts/131100_131199/131102/08.21.00_60/ts_131102v082100p.pdf) | UICC filesystem diagram places `EF_SMS` (`6F3C`), `EF_SMSP` (`6F42`), and `EF_SMSS` (`6F43`) beneath MF/`DF_TELECOM` (`7F10`), rather than assuming the currently selected ADF. | -| [ETSI TS 102 221 V13.0.0, clauses 8.4.2–8.4.3](https://www.etsi.org/deliver/etsi_ts/102200_102299/102221/13.00.00_60/ts_102221v130000p.pdf) | Path-from-MF rules and the special `7FFF` ADF marker. A MF path must not start with `3F00`; selecting by the explicit `7F106F3C` path avoids reliance on the modem’s current ADF/DF selection. | -| [Quectel EC25/EC21 AT Commands Manual V1.3, `CRSM` and `CMGL`](https://quectel.com/content/uploads/2021/03/Quectel_EC25EC21_AT_Commands_Manual_V1.3.pdf) | Official Quectel LTE-family evidence for `CRSM` response shape and for `CMGL` changing unread records to read. It is a fixture source only, not QDC507 evidence. | -| [Quectel EG06xK/Ex120K/EM06xK AT Commands Manual, `CRSM` and `CMGL`](https://forums.quectel.com/uploads/short-url/zwVbq0bdsIXR1qTexLq478k8enT.pdf) | Another official Quectel LTE-family manual: `CRSM` supports `pathId`, and says CRSM configuration is not saved; its `CMGL` documentation again says unread becomes read. It does not establish QDC507 support. | -| [Quectel GSM SMS Application Note V1.1](https://quectel.com/content/uploads/2021/03/Quectel_GSM_SMS_Application_Note_V1.1.pdf) | Old GSM-family extension documents `CMGL` `<mode>=1` as “not change status.” It is neither in current TS 27.005 nor documented in the EC25/EC21 LTE manual, so it cannot be assumed on QDC507. | -| [Quectel BG77xA/BG95xA AT Commands Manual, `QCMGR`](https://quectel.com/content/uploads/2024/05/Quectel_BG77xA-GLBG95xA-GL_AT_Commands_Manual_V1.1.pdf) | `QCMGR` is explicitly similar to `CMGR` and text-mode-only; it is not a proven state-preserving PDU enumeration alternative. | - -### Fixed CRSM candidate protocol - -`EF_SMS` has FID `0x6F3C` = decimal `28476`. Use the explicit UICC MF path `"7F106F3C"` in every operation; do **not** assume an ADF-USIM path and do not prefix `3F00`. Treat lack of the path argument or a path rejection as unsupported, not as permission to silently retry in another current DF/ADF. - -The command grammar requires placeholders before `pathid`. Logical calls (shown only as a proposed compiled-in transcript, never a user/API-supplied command) are: - -1. `GET RESPONSE`: `AT+CRSM=192,28476,,,,,"7F106F3C"`. - The empty P1/P2/P3/data fields are deliberate. Require exactly `+CRSM: 144,0,"<bounded-even-hex>"` followed by `OK`; reject CME/ERROR, any non-`90 00` status word, missing/extra result data, malformed quoting/hex, or unexpected transcript/URC handling. Parse only a documented legacy response or a UICC FCP that establishes a linear-fixed `EF_SMS`, record length exactly 176, and an explicit record count within a compiled maximum. Otherwise return `unsupported`/`invalid`, never guess a slot count. -2. For each record number `n` in the validated inclusive range `1..count`, issue READ RECORD in absolute mode: `AT+CRSM=178,28476,n,4,176,,"7F106F3C"`. - `178` is READ RECORD, P1 is the 1-based record number, P2=`4` is absolute record mode, and P3=`176` requests the whole record. Again require one `+CRSM: 144,0,"<352 hex chars>"` plus `OK`; no continuation/status-word recovery or partial result is safe for a classifier. -3. Interpret record byte 1 before any PDU parser. Standard received-read is `0x01`; received-unread is `0x03`; free is `0x00`; the remaining 175 bytes begin with the TS-Service-Centre-Address and then the TPDU. Do not emit these bytes, their digest, index, sender, time, or decoded body. Only decoded incoming (`0x01`/`0x03`) records may feed the existing in-memory multipart classifier. Free records are ignored; any other used/outgoing/status-report/unrecognised status makes the result `unexpected` rather than letting an exact match hide it. - -The response parser must cap all line, response, record-count, and total-byte budgets. It must accept neither an arbitrary `+CRSM` command number nor an arbitrary file/path. `+CRSM` is a *restricted access* AT command, not an excuse to introduce generic APDU or AT execution. - -This design intentionally does **not** emit `AT+CMGF=0` or `AT+CPMS="SM","SM","SM"`: both are ME settings, and neither is required to read the explicitly-addressed EF. It also deliberately does not use `EF_SMSP` (`6F42`) or `EF_SMSS` (`6F43`): they are SMS parameters/status metadata, do not enumerate inbound PDUs, and may expose unrelated subscriber/SMSC state. No `UPDATE` command is ever in the reader’s type or command allowlist. - -### State-preservation and race boundary - -- Standards support that the emitted UICC operations are GET RESPONSE/READ RECORD, not UPDATE, and do not specify an unread-to-read transition for them. This is materially different from `CMGL`/`CMGR`, for which TS 27.005 expressly mandates the transition. -- TS 27.007 does **not** promise that `+CRSM` is free of transient MT selection, locks, caches, or contention; it says the MT handles locking/file selection and coordination is implementation-dependent. Quectel manuals inspected provide no QDC507-specific promise that raw `READ RECORD` bypasses an ME SMS cache. Treat any such promise as unproven until the exact firmware is observed. -- A per-device operation gate and existing pre/post equipment/SIM generation fences exclude local concurrent actors but cannot freeze a network-delivered SMS or SIM Toolkit action during a record-by-record scan. A safe classifier must scan the bounded EF twice and require the same count and byte-for-byte same records (comparison only in memory); any difference, transport loss, status word other than `90 00`, identity change, overflow, malformed PDU, or incomplete multipart group returns only the existing de-identified `unknown`/integrity outcome. This reduces, but cannot create, an atomic UICC snapshot; the result remains evidence only and never authorizes adoption/ACK/delete. -- Raw EF record number is likely the physical SIM slot, but TS 27.005 does not prove it equals the Quectel `CPMS="SM"` index used by later `CMGD`. The classifier does not need this mapping. Any later ACK/delete path needs a new authorization plus explicit QDC507 proof of record-number/index mapping and PDU-digest revalidation; it must not infer it from this classifier. - -### Quectel alternatives assessed - -| Candidate | Evidence | Result | -| --- | --- | --- | -| `CMGL=4` / `CMGR=<index>` | TS 27.005 and official Quectel LTE manuals explicitly mark received-unread as read. | Rejected. | -| `CMGL=<stat>,1` | The old Quectel GSM SMS Application Note documents a nonstandard `<mode>=1`, but current TS 27.005 syntax and Quectel EC25 LTE manual expose only `<stat>`. No QDC507 manual/firmware evidence was found. | Rejected unless a QDC507 primary manual and exact firmware HIL prove it; no fallback/probe on a real card under the existing authorization. | -| `QCMGR` / Quectel concatenation helpers | Official manuals describe it as CMGR-like and text-mode-only, with no state-preservation guarantee. | Rejected. | -| QMI WMS or a generic `CSIM` APDU path | No QDC507 primary-source evidence was found in scope. `CSIM` would also violate the repository’s no-generic-command object-graph rule. | Out of scope; do not substitute. | -| `CRSM` GET RESPONSE + READ RECORD of `7F10/6F3C` | Standard file/record semantics provide the only supported non-UPDATE candidate; Quectel LTE-family manuals document CRSM shape/path but not QDC507. | Recommended design direction, still unavailable until fixture and HIL evidence. | - -### Files found - -| File | Description | -| --- | --- | -| `internal/modemadapter/qdc507sms/readonly_classifier.go` | Tagged classifier now implements the recommended fixed CRSM EF_SMS double scan and bounded legacy/FCP parser; this is synthetic fixture readiness, not firmware evidence. | -| `internal/modemadapter/qdc507sms/driver.go` | Inbox driver likewise uses `CMGL=4` and `CMGR`; `prepare` changes SMS-mode/storage selection. | -| `internal/qdc507hil/classifier_hardware_qdc507_hil.go` | Real classifier construction remains deliberately closed with a QDC507 CRSM response-shape/unread-preservation firmware blocker. | -| `internal/modemadapter/qdc507sms/unread_preservation.go` | Tagged zero-unread baseline, exact free-to-unread transition and three-stable-scan verifier; synthetic fixture only. | -| `internal/qdc507hil/unread_preservation_hardware_qdc507_hil.go` | Constructible independent future-HIL graph with no application/recovery DB; implemented but never run. | -| `cmd/simplus-qdc507-hil-crsm-preserve/main_qdc507_hil.go` | De-identified ready/final CLI with only bounded arrival/total timeouts and PTY or `/dev/null` stdin. | -| `.trellis/tasks/08-12-dji-4g-cellular/design.md` | Design declares the precise non-mutating-enumeration gate before real classification can be proposed. | -| `.trellis/spec/core/infra/hardware-and-hil-safety.md` | Requires an object graph without generic command/prompt, Send, ACK/Delete, RF, registration, call/data, or subscriber-number capabilities; explicitly calls `CMGL` non-read-only. | - -### Code patterns - -- The tagged classifier's only modem operations are compiled-in GET RESPONSE and READ RECORD request variants; the transport exchange is unexported and accepts no caller command, APDU, path, endpoint, or fallback. -- The normal inbox still lists with `CMGL=4` and reads with `CMGR`, `internal/modemadapter/qdc507sms/driver.go:152-185`; its preparation changes `CMGF`/`CPMS` at `:260-282`. -- The real HIL constructor remains deliberately unavailable because standards/Quectel-family fixtures do not prove the current QDC507 firmware's CRSM response or unread preservation. -- The task design prohibits promotion or execution until a newly authorized firmware-specific preservation HIL succeeds. -- The hardware spec requires the narrower concrete composition and fails closed until the exact enumeration is proven, `.trellis/spec/core/infra/hardware-and-hil-safety.md:50-73`. - -### Required capability/object graph and tests - -Implement only after task design/spec review, with a distinct `EFSMSRecordEnumerator` (or equivalently named) whose sole public operation is classification and whose command table is an unexported fixed list of the two CRSM operation forms above. It owns a bounded typed transcript transport, never a generic `Command`, APDU, prompt, or device-path API. It must not implement or embed the regular SMS driver/inbox backend. - -Required synthetic tests: - -- exact GET RESPONSE and READ RECORD transcripts; malformed AT framing, duplicate/unexpected lines, non-`90,0` SW values, FCP/legacy response mismatch, record length/count bounds, path-placeholder serialization, and no undocumented retry; -- `EF_SMS` record fixtures for free, read, unread, outgoing/unexpected, wrong-length, malformed PDU, read/unread single part, all multipart completion/duplicate/span cases, and proof that status byte is removed before PDU decoding; -- double-scan equality, pre/post device/SIM generation fence failures, changing record count/bytes, cancellation, and overflow all yield a non-actionable classification; -- reflection/static command-allowlist tests proving absence of CMGL/CMGR/CMGF/CPMS/CSIM/UPDATE/Send/ACK/Delete/RF/registration/call/data/subscriber-number capabilities; output tests prove no PDU/index/address/body/identity leaks; -- fixtures proving no reliance on `CPMS` index mapping. Any future mapping test belongs to the separately authorized ACK/delete capability, not this classifier. - -Authorization boundary: compilation and synthetic tests are non-HIL. A real unread-preservation proof cannot be obtained merely by a no-message HIL-0 probe: it requires an authorised, controlled unread record and a privacy-preserving before/after comparison of its EF status byte using this same fixed reader. Creating such a record entails a real inbound SMS, so it needs new exact HIL authorization; never use an existing private inbox as an implicit test fixture. Even a successful classifier report is not adoption, ACK, deletion, resend, RF, call/data, or production-promotion authorization. - -## Related specs - -- `.trellis/spec/core/infra/hardware-and-hil-safety.md` — typed hardware boundary, classifier composition, real-HIL authorization, and fail-closed condition. -- `.trellis/spec/core/backend/application-boundaries.md` — bounded typed errors and transport detail containment. -- `.trellis/spec/core/backend/quality-and-testing.md` — fixture-first hardware validation, deterministic tests, privacy, and fail-closed unsupported hardware cases. -- `.trellis/tasks/08-12-dji-4g-cellular/prd.md` R10–R13 / AC8–AC12 and `.trellis/tasks/08-12-dji-4g-cellular/design.md` section 9.4 — SMS fence, durable handling, existing classifier stop condition, and authorization requirements. - -## Caveats / Not Found - -- No public primary QDC507 AT manual, firmware release note, or Quectel/DJI statement was found that confirms `CRSM`, the `7F106F3C` path, EF response shape, record/index mapping, or a non-mutating vendor SMS list/read command for the exact device/firmware. -- No current standard `CMGL` “do not change status” parameter exists in the reviewed TS 27.005 Release 18 contract. The old Quectel GSM extension is not portable evidence. -- `EF_SMS` can be optional (USIM service availability and card policy), requires PIN/CHV read access, and can be unavailable/locked. Never enter PIN, alter access conditions, select another storage, or fall back to `CMGL`/`CMGR` when it is inaccessible. -- UICC receive/storage races cannot be eliminated with CRSM alone. The design can be non-mutating and fail closed, but cannot truthfully promise an atomic inbox snapshot or safe later deletion without the separate mapping/evidence/authorization boundary. diff --git a/.trellis/tasks/archive/2026-08/08-12-dji-4g-cellular/research/repository-evidence.md b/.trellis/tasks/archive/2026-08/08-12-dji-4g-cellular/research/repository-evidence.md deleted file mode 100644 index a1c6d1b..0000000 --- a/.trellis/tasks/archive/2026-08/08-12-dji-4g-cellular/research/repository-evidence.md +++ /dev/null @@ -1,95 +0,0 @@ -# QDC507 原生蜂窝短信:仓库与公开资料证据 - -## 1. 问题重述 - -本任务不是让大疆模组成为通用蜂窝数据网卡,而是让 QDC507 在 Simplus 现有 `ManagedModem -> Line -> messaging` 模型中,以中国联通实体 SIM 经蜂窝基站完成原生短信收发,并确保整个路径不依赖、不启动也不回退 Host VoWiFi。 - -## 2. 当前仓库事实 - -### 2.1 单一 Agent 与型号边界已经存在 - -- QDC507 与 ML307A 已使用同一个 `simplus-agent`、Agent 协议和 adapter registry;不应新增第二套 Agent。`docs/architecture.md:62-78`、`docs/compatibility.md:24`。 -- QDC507 当前已固定 USB 匹配、primary AT interface 2、QMI interface 4、标准 3GPP 只读 probe 和 SIM presence 查询。`internal/modemadapter/qdc507.go:15-64`。 -- QDC507 当前只实现 `ATProbeAdapter` 与 `SIMPresenceAdapter`,尚未实现 `EquipmentIdentityAdapter`、`SIMIdentityAdapter` 或 `RFControlAdapter`,因此不能满足稳定 Managed Modem/Line 绑定。`internal/modemadapter/qdc507.go:17-20`、`docs/compatibility.md:21`。 -- 现有共享 identity helper 已验证 IMEI Luhn 校验、ICCID 长度限制、每实例 pseudonym 与末四位提示;ML307A 已提供可复用的类型化实现形状。`internal/modemadapter/identity.go:10-70`、`internal/modemadapter/ml307a.go:69-130`。 - -### 2.2 短信候选实现完整,但尚未 production 装配 - -- `internal/modemadapter/qdc507sms` 已有 PDU driver、Linux tty transport、完整 `SMSAdapter`、durable SQLite state,以及发送重放/结果未知和入站 persist-before-delete 语义。`internal/modemadapter/qdc507sms/adapter.go:18-47`、`internal/modemadapter/qdc507sms/driver.go:124-250`、`docs/architecture.md:105-122`。 -- 完整 adapter 目前刻意不进入 `modemadapter.DefaultRegistry`;默认 registry 只有发现型 `QDC507{}` 与 `ML307A{}`,因此 production Agent 不声明 `sms-v1`。`internal/modemadapter/qdc507sms/adapter.go:27-30`、`internal/modemadapter/registry.go:186-192`。 -- production Agent 只创建默认 scanner,并用不接受 SMS backend 的 managed-hardware handler 启动。`cmd/simplus-agent/main.go:127-156`、`internal/agentapi/server.go:45-52`。 -- Agent 容器与原生 systemd 已有 UID 10002/私有 state directory/无网络边界;`./data/agent` 映射到 `/var/lib/simplus-agent`,适合保存 QDC507 durable SMS state,不需要新增权限或网络。`compose.yaml:20-41`、`containers/agent-entrypoint.sh:18-58`、`scripts/release/install-debian.sh:86-103`。 - -### 2.3 应用短信 transport 目前是全局单选,不能满足无回退要求 - -- hardware `simplusd` 当前只创建 Agent inventory;若配置 Host VoWiFi supervisor,则全局把 messaging sender/inbox 设为 VoWiFi gateway。它没有装配原生 Agent SMS gateway。`cmd/simplusd/main.go:105-144`。 -- hardware policy 当前明确拒绝 Agent `sms-v1`,这是旧 read-only 边界,真实 HIL 通过后必须原子更新。`cmd/simplusd/main.go:542-557`。 -- `messaging.Service` 当前只有一个全局 sender/inbox,并用 `hostVoWiFiSMS` 布尔值切换全体 Line 的 capability/availability 判断。`internal/application/messaging/service.go:199-266`、`internal/application/messaging/inbound.go:28-71`。 -- ADR 0019 已规定短信由实际 transport、Line 能力与实时状态选择,必须 fail closed,不得恢复全局 access mode 或静默切换 transport。`docs/decisions/0019-line-identity-and-communication-paths.md:15-22`。 - -设计结论:messaging 需要按 Line 选择唯一 transport。`line.Capabilities.SMS` 选择原生 Agent;只有不具备原生 SMS、但具备 `HostVoWiFiAuth` 且对应 worker 在线的 Line 才选择 Host VoWiFi。选中的 transport 不可用时直接失败,不尝试另一条路径。 - -### 2.4 注册状态已在 Agent 解析,但未进入业务/Web - -- 标准 probe 已读取 `CFUN/CREG/CGREG/CEREG/COPS/CSQ` 并输出 RF、三个注册域、当前运营商/RAT 和信号。`internal/modemadapter/standardat/probe.go:25-98`、`internal/modemadapter/standardat/parse.go:98-218`。 -- `inventory.AgentSource` 使用该 probe 生成设备/SIM/Line,但只映射身份、SIM presence 与 capability,丢弃注册、网络和信号。`internal/application/inventory/agent_source.go:35-169`。 -- Managed Modem domain/OpenAPI/Web 当前只展示 online、RF、SIM presence、型号、序列号与能力,无法从 Web 判断是否已连接基站。`internal/domain/modem/model.go:43-54`、`api/openapi.yaml:2126-2155`、`web/src/pages/Modems.tsx:1-240`。 - -设计结论:复用 Agent targeted probe,通过应用层类型化 status reader 将注册摘要、域、运营商、RAT 与有界信号状态投影到 Managed Modem API/UI;不引入网络扫描、手动选网、APN 或任意命令入口。 - -### 2.5 Agent 内部串行边界需要统一 - -- `hardwareprobe.Scanner` 用自己的 `controlMu` 串行 probe/RF/identity;`modemadapter.SMSRouter` 另有独立 per-device gate。两者没有共享同一 modem operation owner。 -- QDC507 tty transport 的 non-blocking flock 能避免同时打开,但只能产生 busy/failure,不能保证 probe、RF 和 SMS 的业务级顺序。 - -设计结论:新增一个 Agent 内部、按 device ID 的 context-aware operation gate,由 scanner 的 probe/RF/identity 与 SMS router 共用。它不跨进程、不进入 Web/API,也不替代 operation ID 与 durable SMS state。 - -### 2.6 durable SMS 状态不能绑定临时 USB 位置 - -- 现有候选 adapter 把 `DeviceReport.ID` 写进入站 message ID、state key 和 send/ACK request digest;该 ID 来自本次 USB 拓扑位置,换端口会变化。`internal/modemadapter/qdc507sms/message.go:135-171`、`internal/modemadapter/qdc507sms/adapter.go:101-108`、`internal/modemadapter/qdc507sms/state.go:60-124`。 -- Agent 只在综合只读 probe 中取得当前模组/SIM 的每实例 HMAC 指纹;普通 Agent SMS 请求目前只有临时 device ID。应用的当前内部 topology 已含 persistent Line 绑定可重新核对的设备/Profile 证据,因此 messaging 可构造私有 runtime target;内部 Agent SMS contract 必须增加 device generation 与预期模组/SIM 指纹。Router 在同一设备锁内重新 probe、确认三者仍匹配,再把 SIM 指纹作为 adapter state namespace。这样关闭 Line 解析后换模组/换卡、dispatch 前误发的 TOCTOU。原始 IMEI/ICCID/IMSI、号码、指纹和设备路径不得进入公共 contract 或普通日志。 -- 现有 driver 只执行 `CMGF=0`,未固定 `CPMS`。官方 EC25/EC21 手册说明 `CPMS` 的 `mem1` 决定读/删存储、`mem2` 决定写/发存储、`mem3` 决定收到的短信存储,且 `SM` 表示 (U)SIM 存储。若沿用默认 `ME`,换 SIM 后旧短信可能与新 SIM 混淆。 - -设计结论:production adapter 使用稳定 SIM 指纹作为 durable namespace、operation digest 和入站 message ID 的输入;Agent wire response 仍回显本次请求的临时 device ID。每个短信操作先执行固定 `CMGF=0` 与 `CPMS="SM","SM","SM"`,从而把入站存储与物理 SIM 关联;禁止 `AT&W` 或其他持久化配置命令。旧的 fixture-only schema v1 不做不安全的自动归属迁移,production 使用明确的 v2 state 文件/schema,并在发现旧版本时 fail closed。 - -### 2.7 public modem status 应扩展现有 targeted probe,而不是再造网络控制 - -- `AgentSource` 已在同一 snapshot 周期内缓存完整 probe,当前只在映射 inventory 时消费身份/SIM/capability 字段。`internal/application/inventory/agent_source.go:34-58`、`internal/application/inventory/agent_source.go:167-186`。 -- `ManagedModem.Service.List` 已通过 `AgentRFController.State` 做一次新鲜 targeted probe,但只取 RF 字段。`internal/application/modem/service.go:96-130`、`internal/application/modem/agent_rf.go:22-39`。 - -设计结论:把现有 RF-only reader 扩为一次返回 RF/SIM/注册/运营商/RAT/信号的 runtime status reader;模组列表仍只增加当前已经存在的那一次 targeted probe,不再叠加第二次状态读取。一个共享、纯函数的 cellular classifier 同时服务模组页状态和 Agent SMS preflight。公共 API 只输出枚举化注册摘要、三个注册域、当前运营商/PLMN、RAT、有界信号值、观察时间和稳定失败码;不增加网络扫描、手动选网、APN、数据拨号或任意命令入口。 - -## 3. 公开技术资料 - -- Quectel EC25/EC21 官方 AT 手册说明 `AT+CGSN` 返回设备 IMEI、`AT+QCCID` 返回 SIM ICCID、`AT+CIMI` 和 `AT+CRSM` 提供 SIM 归属元数据、`AT+CFUN` 控制并读回 RF 功能级,以及 `CMGF/CMGL/CMGR/CMGS/CMGD` 的 3GPP PDU-mode SMS 流程:<https://quectel.com/content/uploads/2021/03/Quectel_EC25EC21_AT_Commands_Manual_V1.3.pdf>。 -- 3GPP 27 系列将 TS 27.005 定义为 SMS/CBS 的 DTE-DCE 接口,将 TS 27.007 定义为 UE AT 命令集:<https://www.3gpp.org/DynaReport/27-series.htm>。 -- DJI 官方 FAQ 说明 Cellular 模块使用运营商 nano-SIM,SIM PIN 会导致联网失败。Simplus 因此只显示锁卡并 fail closed,不自动解锁或修改 PIN:<https://repair.dji.com/help/content?customId=01700008632&lang=zh-CN&paperDocType=ARTICLE&re=CN&spaceId=17>。 - -公开资料只能支持命令选择与 fixture。QDC507 实际固件响应、RF 写入、运营商注册及真实短信仍必须由本型号和当前 SIM 的受控 HIL 证明。 - -## 4. 证据提升与 production 装配策略 - -1. 基础 `modemadapter.QDC507` 增加只读设备/SIM 身份和显式 RF capability,但继续把 `sms-control` 保持 `documented`。 -2. `qdc507sms.Adapter` 在完整 tty driver + durable store 被注入后覆写 capability evidence;只有真实 HIL 被接受后,组合 adapter 才把 `sms-control` 声明为 `observed`。 -3. `modemadapter.DefaultRegistry` 保持 discovery-only,防止无 durable dependency 的 scanner 误报 SMS。production Agent 由 composition root 构造含组合 QDC507 adapter 的 runtime registry,并把同一 registry 注入 scanner 与 SMS router。 -4. HIL 前的 ordinary production composition 完全不装配 SMS backend;HIL 通过后的最终 production composition 若 durable state、registry/driver/adapter 或 handler 构造失败,Agent 必须启动失败,不能以无 durable state 的方式发送或继续声明 `sms-v1`。 -5. HIL 前先用 build-tag 隔离、只选择唯一 QDC507 的固定 typed runner 组合相同 scanner/router/adapter;该 runner 不接受 AT、QMI、设备路径或任意命令。HIL 通过后才更新兼容性结论、production policy 和 observed capability。不得增加 `--enable-qdc507-sms` 之类长期 runtime 开关,也不得开放 arbitrary AT/QMI。 -6. production Agent 只接受一个绝对、私有的 state 根目录(原生和 Compose 都已有 `/var/lib/simplus-agent`);固定文件名由 Agent 内部派生,SQLite open/schema/registry/backend 任一失败都使进程启动失败。Agent shutdown 必须关闭并 checkpoint 该 store,shared HTTP `WriteTimeout` 必须覆盖 `SMSRequestTimeout`。 - -## 5. 发送前置、错误和状态 - -- SMS Send 在 shared operation gate 内执行固定只读前置检查:完整 probe、SIM READY、稳定 SIM 指纹、RF On、至少一个 CS/packet/EPS 域为 home/roaming/SMS-only 注册;随后才选择 SIM storage、设置 PDU mode 并 dispatch。List/Read/ACK 同样要求 SIM READY 与相同指纹,但不要求当前仍在网,以便处理已经存储的入站消息。 -- RF Off、SIM locked/absent、searching、denied、not registered 和 unknown 分别映射到有界稳定错误;这些错误经过 Agent client/gateway 进入现有 durable message state,不包含原始响应、号码或设备路径。 -- List/Read 可以读取现有 modem SMS storage;Acknowledge 仍严格遵守应用入库成功后才删除。发送前置失败不得触发 RF、选网、PIN、APN 或 transport fallback。 -- 部分 multipart 成功、prompt 后响应丢失和重启遗留 accepted 继续使用现有 `SMS_SEND_OUTCOME_UNKNOWN`,不自动重发。 - -## 6. 仍需真实证据验证的技术未知 - -- 当前 QDC507 固件对选定只读 IMEI/ICCID/SIM operator 查询的实际响应形状。 -- QDC507 的 `CFUN=1/4` typed write/read-back 及服务恢复行为;不验证持久上电策略。 -- 当前中国联通 SIM 在现场的 SIM READY、归属元数据、EPS/packet/CS 注册组合及短信承载可用性。 -- PDU-mode 单段/长短信、入站存储、索引复用与自号码或获批测试号码的实际互通。 -- `AT+CPMS="SM","SM","SM"` 在当前 QDC507 固件上的接受、读回及重新启动后的接收存储行为。 -- 当前开发 Agent 未运行,因此本轮只读 HIL-0 在接触设备前即停止,没有取得新的设备证据;部署/重启 Agent 与任何后续 RF/SMS 动作都等待独立授权。 - -这些未知不改变 MVP 设计,但决定 capability 能否从 fixture/documented 提升到 HIL/Runtime。失败时保持 production fail closed,并在同一任务内修正 adapter/transport 后重新走授权门;不得改用 VoWiFi、QMI WMS、蜂窝数据或更宽权限作为未经批准的替代。 diff --git a/.trellis/tasks/archive/2026-08/08-12-dji-4g-cellular/task.json b/.trellis/tasks/archive/2026-08/08-12-dji-4g-cellular/task.json deleted file mode 100644 index 8b37531..0000000 --- a/.trellis/tasks/archive/2026-08/08-12-dji-4g-cellular/task.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "id": "dji-4g-cellular", - "name": "dji-4g-cellular", - "title": "大疆 4G 模块蜂窝接入", - "description": "", - "status": "completed", - "dev_type": null, - "scope": "core,web,infra,docs", - "package": "core", - "priority": "P2", - "creator": "Leon", - "assignee": "Leon", - "createdAt": "2026-08-12", - "completedAt": "2026-08-13", - "branch": null, - "base_branch": "main", - "worktree_path": null, - "commit": null, - "pr_url": null, - "subtasks": [], - "children": [], - "parent": null, - "relatedFiles": [], - "notes": "", - "meta": { - "dev_type": "feature" - } -} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result-recurrence/check.jsonl b/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result-recurrence/check.jsonl deleted file mode 100644 index 6e3c9ad..0000000 --- a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result-recurrence/check.jsonl +++ /dev/null @@ -1,4 +0,0 @@ -{"file":".trellis/spec/core/backend/api-contracts.md","reason":"Verify one-hour current expiry works while 24-hour/local bounds and all existing Feishu security contracts remain fail closed."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Check current-shape fixture accuracy, duration overflow/boundaries, privacy, race tests, and full validation gates."} -{"file":".trellis/spec/core/backend/application-boundaries.md","reason":"Confirm no scope drift into HTTP/Web/storage and no change to test-before-persist or stable public errors."} -{"file":".trellis/tasks/08-12-fix-feishu-binding-result-recurrence/research/root-cause.md","reason":"Review the code against the precise sanitized recurrence evidence and why the previous fix was incomplete."} diff --git a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result-recurrence/design.md b/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result-recurrence/design.md deleted file mode 100644 index ec46ed5..0000000 --- a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result-recurrence/design.md +++ /dev/null @@ -1,39 +0,0 @@ -# 飞书绑定 expiry 上限复发修复:技术设计 - -## 1. Root cause - -第一轮补丁正确加入 `expires_in` 字段,却继续沿用初版 `expiresIn > 900` 的本地拒绝条件。当前飞书中国 Begin 成功响应是 3600 秒,因此在 device code、authority、HTTP status 等校验全部通过后,仍同步返回 `ErrFeishuProviderResultInvalid`。 - -截图中的顶部 mutation Alert + 卡片缓存 idle 与该路径完全一致。脱敏结构探针进一步排除了网络、provider error、device code、URL authority、最终 URL 长度及 Poll pending 语义。 - -## 2. Minimal code change - -只修改 `internal/application/notification/feishu.go` 的 expiry 归一化及 `binding_test.go` fixture/边界测试: - -- 增加 `feishuRegistrationLifetimeLimit = 24 * time.Hour`。 -- `normalizeFeishuRegistrationLifetime(currentSeconds, legacySeconds, intervalSeconds) (time.Duration, error)` 负责: - - 两个正值不同 → invalid; - - current 正值优先,否则 legacy 正值; - - 两者均非正 → 600 秒; - - 用秒数与 `limit/time.Second` 比较,避免乘法溢出; - - duration 小于 poll interval 或超过 24 小时 → invalid。 -- `Begin` 用返回 duration 直接计算 `ExpiresAt`。 - -不改 device code、URL、HTTP decode、Poll、messenger、状态机、API、Web 或存储。 - -## 3. Test contract - -- 现场结构合成 fixture 使用 514+ 字节特殊字符 synthetic code、`open.feishu.cn` 和 `expires_in=3600`,通过 service `StartFeishuBinding` 断言 waiting + 一小时期限。 -- table test 覆盖 current 3600、legacy 60、matching、default 600、24 小时边界、24 小时+1 秒、conflict、短于 interval 和极大整数。 -- 原有 exact authority、opaque code、HTTP 400 device-flow、token/message strict success 和 expiry fence 测试保持。 -- 聚焦 race/HTTP 后运行全量门禁;无 OpenAPI/生成源变更,但仍运行 `make verify-generated` 检查漂移。 - -## 4. Observability and privacy - -不记录 expiry 探针的临时 code/URL/响应。`expires_in=3600`、HTTP status、稳定 `authorization_pending` 和 authority 是非身份结构事实,可写入任务根因材料。公共错误仍为稳定 `FEISHU_BINDING_RESULT_INVALID`,不新增 provider 正文回显。 - -## 5. Deployment and live acceptance - -用户批准实施后,仍需在提交阶段单独确认提交分组。用户已经要求修复现场,但本次最终规划摘要后需新批准才能实施;部署授权将随该批准一并确认。部署只从当前提交重建 `dev` 镜像并原地 `docker compose up -d`,保留 `./data`。 - -代理只验证镜像摘要、版本、health 和认证。用户随后点击“绑定飞书私聊”;出现 URL 后由用户决定是否打开和完成真实授权。 diff --git a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result-recurrence/implement.jsonl b/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result-recurrence/implement.jsonl deleted file mode 100644 index 792ce4a..0000000 --- a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result-recurrence/implement.jsonl +++ /dev/null @@ -1,4 +0,0 @@ -{"file":".trellis/spec/core/backend/api-contracts.md","reason":"Preserve the ephemeral Feishu binding contract while correcting provider-owned expiry normalization and bounds."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Apply realistic external-provider fixture, privacy-safe structure evidence, race, and targeted-to-broad validation rules."} -{"file":".trellis/spec/core/backend/application-boundaries.md","reason":"Keep the fix inside the narrow registrar without changing state, persistence ordering, or public error boundaries."} -{"file":".trellis/tasks/08-12-fix-feishu-binding-result-recurrence/research/root-cause.md","reason":"Inject the exact recurrence stage, sanitized expires_in=3600 evidence, pending Poll evidence, and minimal root fix."} diff --git a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result-recurrence/implement.md b/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result-recurrence/implement.md deleted file mode 100644 index e5352c7..0000000 --- a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result-recurrence/implement.md +++ /dev/null @@ -1,30 +0,0 @@ -# 飞书绑定 expiry 上限复发修复:实施计划 - -## 1. Fix and tests - -- [x] 提取有界 expiry 归一化 helper,以 24 小时 duration 常量替代 900 秒魔法上限并避免整数溢出。 -- [x] 将当前结构 fixture 改为 `expires_in=3600`,断言 service 进入 waiting 且期限为一小时。 -- [x] 增加 24 小时边界/越界、短于 interval、冲突、default、legacy 与极大整数测试。 -- [x] 确认 opaque code、exact authority、registration 4xx、Poll expiry、token/message strict success 回归不变。 - -## 2. Verification - -- [x] `go test -race ./internal/application/notification` -- [x] `go test ./internal/api/httpapi ./internal/storage/sqlite` -- [x] `make check-format && make lint && make test && make verify-generated` -- [x] `make check-docs && make security && git diff --check` -- [x] 检查 diff/任务/日志不含临时值、完整 URL、credential、身份或 provider body。 - -## 3. Prevention and delivery - -- [x] 使用 `trellis-break-loop` 补充分析“探针选择性报告导致错误上限幸存”的防复发机制。 -- [x] 按 `trellis-update-spec` 将 provider duration 的安全上限与 fixture 字段完整性规则写入 backend code-spec。 -- [x] 按 Phase 3.4 提交确认流程提交;未推送远端。 -- [x] 经最终规划批准后重建 `dev` 镜像、原地更新 Compose、确认新镜像/版本/health/401;未打开 URL。 -- [x] 用户重新点击、主动完成授权并确认绑定成功,飞书中可见对应私聊。 - -## 4. Start gate - -- [x] 根因由截图路径和脱敏 Begin/Poll 结构证据闭合。 -- [x] 没有未决产品、安全或兼容决策。 -- [x] 用户在最终规划摘要后另行批准实施与本地部署。 diff --git a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result-recurrence/prd.md b/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result-recurrence/prd.md deleted file mode 100644 index bb5d0a0..0000000 --- a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result-recurrence/prd.md +++ /dev/null @@ -1,42 +0,0 @@ -# 继续修复飞书绑定结果误判 - -## Goal - -修复飞书私聊一键绑定在第一轮协议兼容补丁与本地容器更新后仍返回 `FEISHU_BINDING_RESULT_INVALID` 的复发问题,使管理员能稳定看到并使用短期授权 URL,同时不弱化最小权限、隐私和 fail-closed 边界。 - -## Confirmed Background - -- 用户在运行版本 `9f9ac73` 的本地 `dev` 容器更新后,从桌面浏览器再次触发绑定,页面仍显示“飞书返回了无法使用的授权结果”。 -- Web 顶部 Alert 只由 start mutation/query error 产生,而卡片仍显示缓存的 idle(`web/src/pages/Notifications.tsx:79-82,121-140`);这证明 POST 在同步 Begin 阶段失败,尚未进入后台 Poll。 -- 第二次不打开 URL 的脱敏 Begin 探针确认:HTTP 200、514 字节 opaque device code、`open.feishu.cn` 精确 authority、interval=5、当前字段 `expires_in=3600`,其余已修边界均通过。当前代码仍拒绝 `expiresIn > 900`(`internal/application/notification/feishu.go:114-124`),因此根因闭合。 -- Begin 后立即执行一次不打开 URL 的脱敏 Poll,得到 HTTP 400、稳定状态 `authorization_pending`、无 credential/user info;当前 registration-only JSON 逻辑已覆盖该串联路径。 -- 第一轮修复只报告旧字段 `expire_in` 为缺失,没有记录当前 `expires_in` 的值,随后设计继续沿用原始 900 秒上限;这是第一次修复未覆盖复发的直接原因。 -- 上一轮研究与复盘位于 `.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result/`。产品边界仍是飞书中国版、授权人私聊、仅 `im:message:send_as_bot`、测试后落库、凭据不回显和仅本地解绑。 - -## Requirements - -- R1:把 provider `expires_in` 作为正数秒数处理,兼容 legacy `expire_in` 和两者冲突检测;当前 3600 秒响应必须成功进入 waiting。 -- R2:用独立常量设置 24 小时本地安全上限并做 `time.Duration` 安全转换;零/负值继续使用官方 SDK兼容的 600 秒默认值,超出上限、短于 interval 或冲突值继续 fail closed。 -- R3:保持 4096 字节 opaque code、两个精确飞书中国 authority、registration-only 非 2xx JSON、Lark 拒绝、token/message strict success、测试后落库和旧 Webhook 行为不变。 -- R4:增加精确复现 `expires_in=3600` 的合成回归,并覆盖边界值/越界值、legacy/default/conflict 与相邻 Poll 路径;不得使用现场 device code、完整 URL、身份或原始供应商正文。 -- R5:修复后执行独立检查、`trellis-break-loop` 复盘、提交确认和经用户批准的本地 `dev` 容器更新;代理不得打开授权 URL、创建应用或发送消息。 - -## Acceptance Criteria - -- [x] AC1:合成当前 Begin 结构(包括 `expires_in=3600`)返回 `waiting`、可信 URL 和正确的一小时本地期限,不再返回 result-invalid。 -- [x] AC2:24 小时边界可接受,超出一秒、短于 interval、conflict、非法 authority/opaque code 仍被拒绝且不产生渠道。 -- [x] AC3:HTTP 400 pending/slow-down/denied/expired、token/message strict success、notification race、HTTP privacy、SQLite/Webhook 和全量门禁继续通过。 -- [x] AC4:任务/测试/日志/API 不包含任何临时授权值、完整 URL、credential、身份或原始 provider body。 -- [x] AC5:更新本地容器后服务健康、运行版本正确、绑定 API 受认证保护;用户重新发起并主动完成授权后确认绑定成功,飞书中可见对应私聊。 - -## Out of Scope - -- 群聊、Lark、入站消息、远程命令或权限扩展。 -- 更改 Web/OpenAPI/SQLite schema;截图已经证明错误来自服务端 Begin 上限。 -- 由开发代理打开授权 URL、创建飞书应用或发送测试消息。 -- 要求用户披露临时 code、完整 URL、身份、credential 或原始响应。 - -## Risks and Deferred Items - -- RFC 8628/官方 SDK没有规定很小的通用 expiry 上限;24 小时是 Simplus 的本地资源与安全护栏,不是对 provider 字段语法的猜测。未来若飞书返回更长期限,应先重新评估,而不是自动长期持有轮询状态。 -- 自动化和不打开 URL 的探针能证明 Begin/Poll pending 兼容;创建应用与测试私聊的最终现场证据仍依赖用户主动授权。 diff --git a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result-recurrence/research/break-loop.md b/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result-recurrence/research/break-loop.md deleted file mode 100644 index f146574..0000000 --- a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result-recurrence/research/break-loop.md +++ /dev/null @@ -1,38 +0,0 @@ -# Bug Analysis: 飞书 expiry 误判在第一轮修复后复发 - -## 1. Root Cause Category - -- **Category**:E — Implicit Assumption(直接原因),D — Test Coverage Gap 与 C — Change Propagation Failure(第一轮修复不完整)。 -- **Specific Cause**:初版代码假设一键注册最多 900 秒。第一轮已发现 provider 改用 `expires_in`,但脱敏探针只报告 legacy `expire_in` 缺失,没有报告当前字段的值;实现与 fixture 因而继续使用 60 秒并保留 900 秒上限。真实值 3600 秒在所有其他校验通过后仍被拒绝。 - -## 2. Why the First Fix Failed - -1. **证据选择性过强**:探针验证了字段名、device code 长度和 authority,却没有列出每个参与生产校验的非敏感数值。 -2. **修复按已知差异枚举,而非重新执行完整校验路径**:字段名修好后,没有用真实结构逐项重放 interval/expiry/URL 最终长度。 -3. **fixture 只模拟形状,不模拟边界值**:所谓 current fixture 使用 `expires_in=60`,证明了新字段能解码,却没有证明当前 provider 的 3600 秒能通过。 -4. **旧魔法值缺少来源标注**:900 秒既不是 RFC 8628、官方 SDK,也不是产品决策,因此在审查中没有明显触发重新验证。 - -## 3. Prevention Mechanisms - -| Priority | Mechanism | Specific Action | Status | -| --- | --- | --- | --- | -| P0 | Runtime | 用命名的 24 小时本地 lifetime 常量和溢出安全 helper 替代 900 秒魔法数 | DONE | -| P0 | Regression | current fixture 使用真实结构值 3600,并从 service 层断言 waiting + 一小时期限 | DONE | -| P0 | Boundary tests | 覆盖 24h、24h+1s、短于 interval、current/legacy/default/conflict 和最大整数 | DONE | -| P0 | Cross-arch | 在 386 上执行 duration helper/current fixture,并完成 Linux arm64 编译 | DONE | -| P1 | Code-spec | 固化 Feishu expiry 归一化、错误矩阵和必测边界 | DONE | -| P1 | Test process | 隐私探针必须清点所有参与校验的非敏感字段,不能只报告字段存在性 | DONE | - -## 4. Systematic Expansion - -- **Similar Issues**:外部 API 的 TTL、retry-after、poll interval、cursor version、upload size 等数值都可能被旧 fixture 隐式约束。需要区分 provider 契约、产品限制与本地资源护栏。 -- **Design Improvement**:数值归一化集中到小 helper,先用整数与显式上限比较,再转换 duration/size,避免溢出和散落魔法数。 -- **Process Improvement**:现场结构探针的输出 schema 应由生产 validator 的输入字段反向生成;每个被读取字段必须有类型和值域摘要或明确的隐私豁免。 -- **Knowledge Gap**:fail-closed 不等于越窄越安全。无依据的小上限会拒绝合法供应商状态,正确边界应兼顾明确的本地资源风险和官方行为。 - -## 5. Knowledge Capture - -- [x] 更新 `.trellis/spec/core/backend/api-contracts.md` 的 lifetime 签名、矩阵与测试要求。 -- [x] 更新 `.trellis/spec/core/backend/quality-and-testing.md` 的探针完整性、数值边界与跨架构规则。 -- [x] 当前任务包含复发根因和本复盘,无需另建 issue。 -- [x] 仓库没有 `src/templates/markdown/spec/`,没有项目模板副本需要同步。 diff --git a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result-recurrence/research/root-cause.md b/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result-recurrence/research/root-cause.md deleted file mode 100644 index 49a5c8b..0000000 --- a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result-recurrence/research/root-cause.md +++ /dev/null @@ -1,35 +0,0 @@ -# 飞书绑定结果误判复发:根因证据 - -## Failure stage - -`Notifications.tsx` 顶部 Alert 由 start mutation/query error 产生;binding error code 只在卡片内部显示。截图同时出现顶部 `FEISHU_BINDING_RESULT_INVALID` 文案和缓存 idle 文案,证明 `POST start` 同步失败,后台 Poll 尚未开始。 - -运行容器版本确认是 `9f9ac73`,三个服务健康;不是旧镜像未部署。 - -## Privacy-bounded Begin structure - -一次不打开 URL的固定 Begin 请求只输出以下结构: - -- HTTP 200;字段为 `device_code`、`expires_in`、`interval`、`user_code`、`verification_uri`、`verification_uri_complete`; -- device code 为 514 字节且在 4096 上限内,未记录值; -- verification URL 为 56 字节、精确 authority `open.feishu.cn`、无 userinfo/fragment,未记录路径/query/完整值; -- interval=5,`expires_in=3600`,无 legacy `expire_in`,无 error; -- 即使保守预留 1024 字节 query,最终 URL仍远小于 4096。 - -当前 `feishu.go` 在 expiry 校验处拒绝任何大于 900 秒的正值,因此该成功结构确定返回 result-invalid。 - -## Privacy-bounded immediate Poll - -另一次 Begin 后立即 Poll(不打开 URL)得到:HTTP 400,字段 `code/error/error_description`,稳定 error 为 `authorization_pending`,无 client credential 或 user info。当前 registration-only non-2xx JSON 分支已正确处理,无需再次修改。 - -## Why the first fix missed it - -第一轮探针输出了 legacy `expire_in` 为 null,却没有同时输出当前 `expires_in` 的值。设计随后只修字段名、opaque code、authority 和非 2xx JSON,保留了原始 `>900` 假设。合成 current fixture 又使用 60 秒,未复现真实 3600 秒。 - -## Conclusion - -复发根因是 provider duration 的无依据小上限与选择性结构证据共同造成。最小根修复是把 expiry 归一化为有界 duration,接受当前一小时值,并用独立 24 小时本地安全上限防止无限期内存轮询;其余第一轮修复保持不动。 - -## Sanitized live acceptance - -本地 `dev` 容器更新到修复版本后,用户从产品页面主动重新发起并完成授权,确认 Simplus 绑定成功,飞书中可见对应私聊。任务记录不保存授权 URL、应用凭据、用户身份、消息内容或现场截图。 diff --git a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result-recurrence/task.json b/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result-recurrence/task.json deleted file mode 100644 index 92cd2f9..0000000 --- a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result-recurrence/task.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "id": "fix-feishu-binding-result-recurrence", - "name": "fix-feishu-binding-result-recurrence", - "title": "继续修复飞书绑定结果误判", - "description": "Diagnose and fix the recurring FEISHU_BINDING_RESULT_INVALID after the first live-shape compatibility patch.", - "status": "completed", - "dev_type": null, - "scope": "internal/application/notification Feishu registration expiry normalization and recurrence regression", - "package": "core", - "priority": "P2", - "creator": "Leon", - "assignee": "Leon", - "createdAt": "2026-08-12", - "completedAt": "2026-08-12", - "branch": null, - "base_branch": "main", - "worktree_path": null, - "commit": null, - "pr_url": null, - "subtasks": [], - "children": [], - "parent": null, - "relatedFiles": [], - "notes": "", - "meta": {} -} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result/check.jsonl b/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result/check.jsonl deleted file mode 100644 index 1cbbb38..0000000 --- a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result/check.jsonl +++ /dev/null @@ -1,4 +0,0 @@ -{"file":".trellis/spec/core/backend/api-contracts.md","reason":"Verify registration-only non-2xx JSON handling does not weaken token/message HTTP success, public privacy, or binding state contracts."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Check regression fixture realism, failure coverage, race safety, full test gates, and absence of real provider side effects."} -{"file":".trellis/spec/core/backend/application-boundaries.md","reason":"Check dependency direction, stable error mapping, transient credential lifetime, and persistence ordering."} -{"file":".trellis/tasks/08-12-fix-feishu-binding-result/research/root-cause.md","reason":"Review the patch against the exact sanitized mismatch and ensure it fixes every serial failure without broad trust relaxation."} diff --git a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result/design.md b/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result/design.md deleted file mode 100644 index 1752c7c..0000000 --- a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result/design.md +++ /dev/null @@ -1,52 +0,0 @@ -# 飞书一键绑定授权结果兼容修复:技术设计 - -## 1. Root cause - -`FeishuClient.Begin` 将三个非官方保证误当成协议契约: - -1. `device_code` 必须匹配 App credential 正则且不超过 512 字节; -2. 过期字段只可能叫 `expire_in`; -3. `verification_uri_complete` 只能位于 `accounts.feishu.cn`。 - -当前飞书中国端点的脱敏结构是:514 字节 opaque device code、`expires_in` 和 `open.feishu.cn` 验证页。device code 校验先失败;即使移除它,URL 主机校验仍会失败。此后 Poll 还有一处潜在串联缺陷:注册端点可在 HTTP 4xx 中返回正常 device-flow JSON,而当前 `doJSON` 在解码前统一拒绝非 2xx。 - -## 2. Boundary-preserving fix - -改动只位于 `internal/application/notification/feishu.go` 及其合成测试,不改变 application state machine、HTTP/OpenAPI、Web、SQLite 或持久凭据模型。 - -### Begin response - -- 用具名的内部 begin response 类型同时接收 `expires_in` 与兼容字段 `expire_in`。 -- 当前字段优先;两者同时为正但不一致时 fail closed;两者都缺失/非正时继续使用官方 SDK 的 600 秒默认值。 -- 保留 interval、expiry 和总响应体的有界检查。 -- device code 只要求非空、JSON 字符串且字节长度不超过 4096;不 trim、不修改、不记录,原样经 `url.Values` 编码用于 Poll。 - -### Verification URL - -- HTTPS 精确主机 allowlist 为 `open.feishu.cn`(当前返回)与 `accounts.feishu.cn`(兼容旧官方 SDK fixture)。 -- 继续拒绝非 HTTPS、非默认端口、userinfo、fragment、空/超长 URL 和其他主机。 -- `createOnly=true`、最小 `preset=false`、唯一 `im:message:send_as_bot` scope 及应用名称/描述 query 保持不变。 - -### Registration HTTP semantics - -- 将 bounded JSON decode 与“必须 2xx”拆开。 -- 只有固定 `/oauth/v1/app/registration` 调用可以解码非 2xx 且非空、未超限的 JSON;Poll 随后按 `error` 字段映射 pending、slow_down、denied、expired 或 provider failure。 -- Begin 的错误 JSON 不得因为字段为空而伪装成有效结果;映射为稳定 provider failure/result-invalid,不回显 error description。 -- tenant token 与 message create 继续使用 strict-2xx helper,且必须有显式 numeric `code=0`。 - -## 3. Test design - -- 用合成当前结构复现现场:大于 512 字节、包含旧正则不接受字符的 opaque device code,`expires_in`,`https://open.feishu.cn/...`;断言 Begin 成功并保留原 device code。 -- 保留 legacy `expire_in` + `accounts.feishu.cn` fixture。 -- 增加 conflicting expiry fields、空/超长 device code、非 allowlist URL、userinfo/port/fragment 和超大响应拒绝测试。 -- Poll fixture 用 HTTP 400 返回 `authorization_pending`/`slow_down`,随后 200 成功;denied/expired 仍映射正确。 -- token/message 的非 2xx 或缺失/非零 `code` 继续失败。 -- 运行 notification race tests、HTTP privacy tests 与全量回归,确认无需生成文件变化。 - -## 4. Operational verification - -实施与检查不打开验证 URL。完成、提交并获得部署授权后,从当前源码重建 `dev` 镜像并原地执行 Compose 更新,保留 `./data`;只检查服务 health、版本以及未登录绑定路由仍返回 401。用户随后在页面主动点击并打开新 URL 完成真实验收。 - -## 5. Rollback - -修复不涉及 schema 或持久数据。回滚只需恢复前一应用镜像/提交;正在等待的内存绑定会在进程重启后回到 idle,不产生半配置渠道。 diff --git a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result/implement.jsonl b/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result/implement.jsonl deleted file mode 100644 index b58e59f..0000000 --- a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result/implement.jsonl +++ /dev/null @@ -1,4 +0,0 @@ -{"file":".trellis/spec/core/backend/application-boundaries.md","reason":"Preserve narrow provider ports, stable errors, side-effect ordering, and test-before-persist behavior while fixing protocol parsing."} -{"file":".trellis/spec/core/backend/api-contracts.md","reason":"Apply the ephemeral external-app binding contract, bounded external responses, privacy, auth, and strict provider success semantics."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Follow targeted-to-broad Go testing, race coverage, synthetic external-provider fixtures, and privacy assertions."} -{"file":".trellis/tasks/08-12-fix-feishu-binding-result/research/root-cause.md","reason":"Inject the sanitized live response structure, official SDK comparison, exact root cause, and minimal safe fix boundary."} diff --git a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result/implement.md b/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result/implement.md deleted file mode 100644 index c3b1618..0000000 --- a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result/implement.md +++ /dev/null @@ -1,29 +0,0 @@ -# 飞书一键绑定授权结果兼容修复:实施计划 - -## 1. Protocol parsing - -- [x] 在 `feishu.go` 为 begin response 增加当前 `expires_in` 与 legacy `expire_in` 的有界归一化。 -- [x] 将 device code 从 App credential 正则中分离,作为非空、最大 4096 字节的 opaque 临时值;禁止日志/公共错误输出。 -- [x] 将验证 URL 精确 allowlist 扩展为 `open.feishu.cn` 与 `accounts.feishu.cn`,保留 HTTPS、端口、userinfo、fragment 和长度约束。 -- [x] 拆分 registration-only bounded JSON decode 与 strict-2xx OpenAPI decode,使 4xx pending/slow-down/denied/expired 可按 JSON 处理,但 token/message 语义不放宽。 - -## 2. Regression coverage - -- [x] 增加当前飞书 Begin 结构的完全合成 fixture,证明 514+ 字节/特殊字符 device code、`expires_in` 和 `open.feishu.cn` 可进入 waiting。 -- [x] 覆盖 legacy fixture、冲突 expiry、空/超长 device code、非 allowlist URL、URL authority 异常和超大响应。 -- [x] 覆盖 HTTP 400 pending/slow-down 后成功、denied/expired 和未知错误;确认 token/message 非 2xx 与显式 code 检查不变。 -- [x] 运行 `go test -race ./internal/application/notification` 与 `go test ./internal/api/httpapi`,确认状态机/隐私回归。 - -## 3. Knowledge and delivery - -- [x] 修复后使用 `trellis-break-loop` 分析“过度校验供应商 opaque 字段 + fixture 未贴近现场结构”的成因和预防措施。 -- [x] 按 `trellis-update-spec` 更新 backend API/quality code-spec,固化 opaque state、registration-only status policy 和 fixture 证据规则。 -- [x] `make check-format`、`make lint`、`make test`、`make verify-generated`、`make check-docs`、`make security` 与 `git diff --check` 全部通过。 -- [x] 按 Phase 3.4 提交确认流程提交,未推送远端。 -- [x] 经用户明确部署授权后重建 `dev` 镜像、原地更新 Compose、确认新镜像/版本/health/401 契约;未打开真实 URL。 - -## 4. Start gate - -- [x] 根因已有不含临时值的结构证据。 -- [x] 产品、安全和兼容边界没有未决决策。 -- [x] 用户审阅最终规划摘要后另行明确批准实施与本地 `dev` 容器更新。 diff --git a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result/prd.md b/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result/prd.md deleted file mode 100644 index 538a498..0000000 --- a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result/prd.md +++ /dev/null @@ -1,44 +0,0 @@ -# 修复飞书一键绑定授权结果解析 - -## Goal - -修复已部署的飞书私聊一键绑定:管理员点击“绑定飞书私聊”后,应获得可打开的短期授权 URL,而不是看到“飞书返回了无法使用的授权结果”,同时继续保持最小权限、测试后落库和凭据不回显边界。 - -## Confirmed Background - -- 用户在 2026-08-12 的本地 `dev` 容器中实际点击绑定后,页面停留在“尚未发起绑定”,并显示“飞书返回了无法使用的授权结果”。 -- 该文案对应稳定错误 `FEISHU_BINDING_RESULT_INVALID`,表明失败发生在正式渠道创建前;当前截图没有显示授权 URL,也没有证据表明已发送测试消息或写入渠道。 -- 一次不打开 URL 的脱敏 Begin 探针确认:飞书当前返回 `expires_in`、514 字节 opaque `device_code` 和 `open.feishu.cn` 授权页;当前实现只读 `expire_in`、把 device code 限制为最多 512 个受限字符,并只允许 `accounts.feishu.cn`(`internal/application/notification/feishu.go:88-124`)。因此至少两个本地校验会拒绝当前官方有效响应。 -- 飞书官方 Go SDK v3.9.10 对 device code 只做非空校验、对过期秒数提供默认值、直接使用返回的验证 URL,并按 JSON 语义处理注册轮询;Hermes 同样会解析注册端点在 HTTP 4xx 中返回的 `authorization_pending` JSON。当前 Simplus 通用 JSON helper 会在解码前拒绝非 2xx,修复 Begin 后仍会导致轮询提前失败。 -- 当前实现只允许飞书中国版、授权人私聊、`im:message:send_as_bot`、无入站能力;本次缺陷修复不得放宽这些产品和安全边界。 - -## Requirements - -- R1:以代码、官方协议实现和脱敏运行证据定位错误;研究与公开输出不得包含 device code、credential、`open_id`、完整 URL、原始响应或现场日志。 -- R2:修正一键创建应用 begin/poll 协议,使官方有效响应能进入 `waiting` 并返回短期验证 URL;缺字段、错误租户、非零 provider code、非法 URL、过期或超大响应仍应 fail closed。 -- R2a:device code 作为不透明、非空且有总长度上限的轮询凭据处理,不按 App credential 字符集验证;授权 URL 只允许当前和兼容的飞书中国官方精确主机,仍拒绝重定向、userinfo、端口与 fragment。 -- R2b:优先支持当前 `expires_in` 并兼容旧 `expire_in`;注册端点允许解析有界 JSON 错误状态以识别 pending/slow-down/denied/expired,tenant token 与消息端点继续要求 HTTP 2xx 和显式 `code=0`。 -- R3:保持 create-only、最小权限、无事件/回调、授权用户私聊、测试成功后才加密持久化及本地解绑语义不变。 -- R4:增加能复现现场失败形态的合成回归测试,并覆盖官方有效响应与关键非法响应;自动化不得创建真实飞书应用或发送真实消息。 -- R5:修复后更新本地 `dev` 容器,并只做不触发真实飞书授权的健康/契约检查;真实 URL 生成由用户在产品内再次主动点击验证。 - -## Acceptance Criteria - -- [x] AC1:根因由官方协议/SDK 源代码、当前实现和脱敏结构探针之间的具体差异解释,不记录任何临时授权值。 -- [x] AC2:合成官方 begin 成功响应返回 `waiting`、HTTPS 飞书授权 URL 和过期时间,且不会被误判为 `FEISHU_BINDING_RESULT_INVALID`。 -- [x] AC3:非零/缺失 provider code、缺失 device code、非飞书 URL、Lark、过期及非法授权结果继续被稳定拒绝且不产生渠道。 -- [x] AC4:现有状态机、竞态、SQLite、HTTP 隐私与 Web 绑定测试继续通过,旧 Webhook 行为不变。 -- [x] AC5:本地容器重建后服务健康,新绑定路由仍受管理员认证保护;自动化与代理不进行真实飞书授权或投递。 - -## Out of Scope - -- 改变私聊目标、增加群聊、Lark、入站消息或远程命令。 -- 要求用户披露飞书账户、App ID/App Secret、`open_id`、完整授权 URL 或原始 provider 响应。 -- 由开发代理代替用户执行真实授权或测试消息。 -- 改动 OpenAPI、Web 交互、SQLite schema 或既有渠道数据;本次错误无需这些层的契约变更。 - -## Risks and Deferred Items - -- 飞书一键注册端点不是普通 OpenAPI,HTTP status 与 JSON device-flow 状态并非一一对应;修复必须只对固定注册端点启用“解析非 2xx JSON”,不能放宽 token/消息客户端。 -- device code 是敏感且短期的不透明值。放宽字符集不等于无界接受:仍需响应体上限、device code 长度上限、进程内瞬时保存和终态清理。 -- 自动化只能证明与已观察结构及官方实现兼容;修复后真正打开 URL、创建应用和收到测试私聊仍由用户主动完成。 diff --git a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result/research/break-loop.md b/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result/research/break-loop.md deleted file mode 100644 index 267caad..0000000 --- a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result/research/break-loop.md +++ /dev/null @@ -1,56 +0,0 @@ -# Bug Analysis: 飞书有效 Begin 响应被本地过度校验拒绝 - -## Bayesian diagnosis - -### Priors - -| Hypothesis | Prior | Reason | -| --- | ---: | --- | -| H1:飞书服务或网络返回错误 | 35% | 外部注册端点失败是常见原因 | -| H2:Simplus 请求参数与官方协议不一致 | 30% | 自定义窄客户端可能遗漏 SDK 行为 | -| H3:Simplus 对有效响应做了错误的额外验证 | 35% | 页面稳定错误是 result-invalid,且失败发生在 Begin | - -### Discriminating evidence and update - -- 代码证据:表单字段与官方 SDK 一致,降低 H2;Begin 对 device code、field name 和 URL host 增加了 SDK 没有的约束,提高 H3。 -- 脱敏结构探针:官方端点返回成功对象且无 error,显著降低 H1;其 514 字节 opaque code、`expires_in` 和 `open.feishu.cn` 会连续触发本地拒绝,使 H3 超过 99%。 -- 官方 SDK/Hermes:device code 只做非空处理,registration 状态可位于非 2xx JSON,再次支持 H3,并暴露出修完 Begin 后的串联 Poll 风险。 - -结论置信度超过 99%:缺陷来自本地隐式假设与不真实 fixture,而非用户操作、飞书账户或网络。 - -## 1. Root Cause Category - -- **Category**:E — Implicit Assumption(主因),D — Test Coverage Gap 与 A — Missing Spec(促成因素)。 -- **Specific Cause**:实现把 provider-owned `device_code` 错当成 App credential,复用了 512 字节受限字符正则;同时假定旧字段 `expire_in` 和旧验证 authority。原测试 fixture 恰好复制这些假设,没有代表当前官方返回,也没有覆盖 registration 4xx JSON 语义。 - -## 2. Why Fixes Failed - -1. **初次实现研究充分但契约提取不完整**:阅读了官方 SDK的一键注册流程,却在自定义安全客户端中加入了未由官方协议保证的 device-code grammar 和单一 authority,没有逐条标注“官方保证”与“本地防御”。 -2. **合成测试形成封闭自证**:测试使用短字母数字 code、`expire_in`、`accounts.feishu.cn` 和全 2xx 响应,恰好与生产实现一致,因此能证明代码内部一致,却不能证明供应商兼容。 -3. **第一轮独立审查关注 fail-closed 强度**:修复了过期轮询和显式成功码,但没有一份当前 provider Begin 结构作为对照,无法发现过度校验。 -4. **没有先做最小结构探针**:在不打开 URL、不创建应用的前提下,本可安全观察字段名、长度、authority 和 HTTP status;缺少这一步让现场差异直到用户点击才暴露。 - -## 3. Prevention Mechanisms - -| Priority | Mechanism | Specific Action | Status | -| --- | --- | --- | --- | -| P0 | Architecture | registration-only bounded JSON/status policy 与 strict-2xx token/message policy 分离 | DONE | -| P0 | Runtime validation | opaque code 只做非空、4096 字节上限、瞬时保存和原样 round-trip;验证 URL 使用精确 authority allowlist | DONE | -| P0 | Test coverage | 加入当前结构、legacy 结构、4xx device-flow 状态、expiry 冲突、authority 混淆及 strict token/message 回归 | DONE | -| P0 | Code-spec | 在 backend API spec 固化字段、状态、错误矩阵与 Wrong/Correct 示例 | DONE | -| P1 | Test guideline | 外部协议 fixture 必须锚定官方实现/当前脱敏结构,opaque 字段不得复用其他 credential grammar | DONE | -| P1 | Review evidence | 安全允许时先做只输出字段/类型/长度/authority 的结构探针,不采集真实值 | DONE | - -## 4. Systematic Expansion - -- **Similar Issues**:扫描确认当前 `device_code` 只存在于飞书注册客户端;`providerCredentialPattern` 继续用于 App ID/Secret、`open_id` 与 access token,不应全局删除。未来 OAuth/device-flow、异步 job token、cursor 或 challenge 等 provider-owned opaque 字段都应遵循同一规则。 -- **Design Improvement**:把 transport 的“有界读取/JSON 解码”和 operation 的“允许哪些 HTTP status/业务状态”分层,避免一个通用 helper 同时过宽或过窄。 -- **Process Improvement**:新增外部协议时,测试矩阵至少有一条 fixture 来自官方实现当前字段形态,并明确哪些校验由协议保证、哪些是本地安全上限。 -- **Knowledge Gap**:opaque 不代表无限信任;正确做法是限制大小、来源、生命周期、公开面和使用位置,而不是猜测字符集。 - -## 5. Knowledge Capture - -- [x] 更新 `.trellis/spec/core/backend/api-contracts.md` 的飞书短期授权可执行契约。 -- [x] 更新 `.trellis/spec/core/backend/quality-and-testing.md` 的外部协议 fixture 与 opaque-state 测试规则。 -- [x] 在本任务保存脱敏根因证据和本复盘;无需另建 issue,根修复已在当前任务完成。 -- [x] 检查模板同步目标:本仓库没有 `src/templates/markdown/spec/`,项目 code-spec 无对应模板副本可同步。 diff --git a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result/research/root-cause.md b/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result/research/root-cause.md deleted file mode 100644 index d03cf95..0000000 --- a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result/research/root-cause.md +++ /dev/null @@ -1,36 +0,0 @@ -# 飞书绑定 Begin 结果误拒绝:根因证据 - -## Observed product behavior - -- 用户点击“绑定飞书私聊”后仍看到 idle 文案,页面显示 `FEISHU_BINDING_RESULT_INVALID` 对应中文错误。 -- `StartFeishuBinding` 只有在 `FeishuRegistrar.Begin` 成功后才公开 waiting URL;因此失败发生在 Begin 或其本地结果验证,不在授权、测试消息或持久化阶段。 - -## Repository evidence - -- `internal/application/notification/feishu.go:88-109` 只解码 `expire_in`,并用 `providerCredentialPattern` 验证 device code;该正则最大 512 字节且仅接受 `[A-Za-z0-9._-]`。 -- `internal/application/notification/feishu.go:122-125` 只接受 `accounts.feishu.cn` 验证 URL。 -- `internal/application/notification/feishu.go:195-197` 的 Poll 复用 strict-2xx `doJSON`;`feishu.go:297-304` 在 JSON 语义处理前拒绝任何非 2xx。 -- `internal/application/notification/binding_test.go:300-322` 的原 fixture 使用短 device code、`expire_in` 和 `accounts.feishu.cn`,因此没有覆盖当前现场结构。 - -## Official implementation evidence - -- 飞书官方 Go SDK v3.9.10 `scene/registration/registration.go` 的 `beginRegistration` 只要求 device code 和 verification URI 非空;它不把 device code 当 App credential 验证。 -- 同一 SDK 使用 600 秒默认过期时间,构造 QR URL 时直接基于 provider 返回值追加 query,并在 registration request 中先解码 JSON,不把 HTTP 非 2xx 作为解码前置失败。 -- 官方文档确认一键创建基于 RFC 8628,回调提供 URL/expire seconds,最终返回 Client ID/Secret 和授权用户信息:<https://github.com/larksuite/oapi-sdk-go#one-click-app-registration>。 -- Hermes 的公开实现也明确说明注册端点会在 HTTP 4xx 返回 `authorization_pending` JSON,并在 Begin 只检查 device code 非空。Hermes 仅作为交叉验证;官方 SDK 和现场结构是主证据。 - -## Privacy-bounded live structure probe - -在不打开 URL、不创建应用、不轮询和不发送消息的前提下,对固定飞书中国 Begin 端点执行一次请求,仅计算结构属性: - -- 对象字段包含 `device_code`、`expires_in`、`interval`、`user_code`、`verification_uri`、`verification_uri_complete`; -- device code 非空、长度 514,未命中当前 Simplus 正则;未记录其值; -- verification URL 是 HTTPS,精确主机为 `open.feishu.cn`,未记录路径、query 或完整值; -- interval 为 5,过期字段名为 `expires_in`; -- 响应不含 provider error 字段。 - -该结构会先在 `feishu.go:97` 被拒绝;即使只修 device code,也会在 `feishu.go:124` 再次被拒绝。根因已闭合,无需用户提供任何敏感响应。 - -## Technical conclusion - -这是供应商 opaque device-flow 字段被误用 App credential 校验、且合成 fixture 固化旧响应形态导致的协议兼容缺陷。最小修复是:device code 改为有界 opaque 值、兼容 `expires_in`、精确加入当前中国官方验证主机,并让 registration-only 客户端解析有界 4xx JSON;不应删除 URL allowlist、响应上限、Lark 拒绝或 token/message strict success checks。 diff --git a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result/task.json b/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result/task.json deleted file mode 100644 index 83e8b3d..0000000 --- a/.trellis/tasks/archive/2026-08/08-12-fix-feishu-binding-result/task.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "id": "fix-feishu-binding-result", - "name": "fix-feishu-binding-result", - "title": "修复飞书一键绑定授权结果解析", - "description": "", - "status": "completed", - "dev_type": null, - "scope": "internal/application/notification Feishu device-flow compatibility and synthetic regression coverage", - "package": "core", - "priority": "P2", - "creator": "Leon", - "assignee": "Leon", - "createdAt": "2026-08-12", - "completedAt": "2026-08-12", - "branch": null, - "base_branch": "main", - "worktree_path": null, - "commit": null, - "pr_url": null, - "subtasks": [], - "children": [], - "parent": null, - "relatedFiles": [], - "notes": "", - "meta": {} -} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-08/08-13-line-phone-number-observation/check.jsonl b/.trellis/tasks/archive/2026-08/08-13-line-phone-number-observation/check.jsonl deleted file mode 100644 index ac5ccda..0000000 --- a/.trellis/tasks/archive/2026-08/08-13-line-phone-number-observation/check.jsonl +++ /dev/null @@ -1,10 +0,0 @@ -{"file":".trellis/spec/core/backend/application-boundaries.md","reason":"Audit that Line owns aggregation and no model/AT/VoWiFi implementation leaks upward."} -{"file":".trellis/spec/core/backend/api-contracts.md","reason":"Audit Agent/OpenAPI validation, compatibility, bounded lists, and public field removal."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Run the required focused-to-broad, protocol, privacy, and generated consistency checks."} -{"file":".trellis/spec/core/infra/hardware-and-hil-safety.md","reason":"Confirm ordinary checks have no hardware side effects and production exposes no arbitrary command seam."} -{"file":".trellis/spec/core/docs/privacy-and-publication.md","reason":"Audit repository, logs, errors, SSE, fixtures, and docs for real-number leakage."} -{"file":".trellis/spec/web/frontend/index.md","reason":"Audit generated API ownership, HTTP authority, responsive UI, and required frontend gates."} -{"file":".trellis/spec/web/frontend/component-guidelines.md","reason":"Audit desktop/mobile display of zero, deduplicated, and differing sourced numbers."} -{"file":".trellis/spec/web/frontend/type-safety.md","reason":"Ensure generated types/Zod drive the Web boundary with no casts or duplicate interfaces."} -{"file":".trellis/spec/web/frontend/quality-guidelines.md","reason":"Run focused Vitest, typecheck, build, E2E, security, and generated drift verification."} -{"file":".trellis/tasks/08-13-line-phone-number-observation/research/qdc507-cnum.md","reason":"Check implementation against the fixed CNUM evidence and privacy constraints."} diff --git a/.trellis/tasks/archive/2026-08/08-13-line-phone-number-observation/design.md b/.trellis/tasks/archive/2026-08/08-13-line-phone-number-observation/design.md deleted file mode 100644 index 5459f4a..0000000 --- a/.trellis/tasks/archive/2026-08/08-13-line-phone-number-observation/design.md +++ /dev/null @@ -1,116 +0,0 @@ -# Design — 统一线路手机号观测 - -## 1. Boundary and ownership - -手机号属于当前 `Line` 绑定的 SIM/Profile 身份观测,不属于 Modem、短信 transport 或 Host VoWiFi 页面。维持固定依赖链: - -```text -QDC507 SubscriberNumberAdapter (AT+CNUM) - -> Agent SIMObservation.SubscriberNumber - -> hardware.SubscriptionProfile.CellularPhoneNumber - -> inventory.Line current profile - \ - Line Service -> domain/line.View.PhoneNumbers - / -VoWiFi supervisor status (IMS number) -> Line-defined PhoneNumberSource port - -> OpenAPI ManagedLine.phoneNumbers -> Web Lines page -``` - -Web 不再 join `VoWiFiLineState.phoneNumber`。Line service 是唯一来源合并者。它定义 consumer-owned 的窄端口,接受 `Line ID -> optional IMS E.164` 当前快照;实现 adapter 可以读取 `vowifisupervisor.API.List`,但 Line package 不依赖 `application/vowifi` 或 `domain/vowifi`,避免循环依赖。 - -## 2. Cellular typed capability - -新增 `SubscriberNumberAdapter`,只暴露: - -```go -ReadSubscriberNumber(context.Context, attransport.Query) (string, error) -``` - -QDC507 固定调用一次 `AT+CNUM`。parser 使用有界 CSV 语义,只接受: - -- transcript 精确为一条 `+CNUM` record 加 final `OK`; -- 三字段 `<alpha>,<number>,<type>`;alpha 可为空但必须是合法 bounded CSV; -- number 本身匹配 `^\+[1-9][0-9]{2,14}$`; -- type 精确为 `145`。 - -只有 `OK` 是正常 unavailable。重复/多条 record、额外 URC/echo、少/多字段、national/unknown type、缺 `+`、controls、overflow、错误/mixed/trailing terminal 或 query error 均返回 unavailable。上层不会基于 TON、PLMN、运营商或已知号码补全。 - -`executeATProbe` 只在 ready present SIM identity 已成功读取后调用本能力,并把成功结果放入同一 `SIMObservation`。`SIMProfileIdentity` 继续只负责稳定身份与归属运营商;号码保持独立能力,避免 identity 读取接口被可选 metadata 耦合。号码读取是 best effort;失败不降级完整 probe。Agent client validation 要求号码只有在 keyed SIM fingerprint、masked hint、present+ready 时出现,并验证 E.164。 - -## 3. Profile and Line propagation - -独立 subscriber-number 结果随后进入: - -- `agentapi.SIMObservation.SubscriberNumber`; -- `hardware.SubscriptionProfile.CellularPhoneNumber`; -- `inventory.SubscriptionProfile` 与当前 resolved Line; -- `domain/line.View.PhoneNumbers`。 - -号码不进入 `domain/line.Record` 或 SQLite。换卡会产生不同 profile fingerprint;缺卡、锁卡、identity failure 或 profile mismatch 时没有当前 profile,Line 返回空的蜂窝号码观测,不保留旧值。号码参与当前 topology revision,使硬件号码变化能出现在下一次 Line refetch;setup/hardware topology HTTP mapper、日志与 SSE payload 不输出号码,只有管理员鉴权的 `ManagedLine` mapper 显式输出。 - -Simulator profile 使用 synthetic `+E.164`,保证 UI 与公共契约可离线测试。 - -## 4. IMS source and merge - -Line-defined port 返回与稳定业务 Line ID 关联的当前 IMS 号码。只有 supervisor `Status.PhoneNumber` 已经通过现有 IMS E.164 validator、对应当前 Line,且该 Managed Line 此刻仍解析到原 SIM/Profile 时才贡献 `ims` 观测;stopped/offline/empty、Line unavailable 或 list failure 不贡献号码,也不使整个 Line list 失败。此端口只用于 `List/Add/Update` 返回的展示 `View`,不得进入 `Topology`,因此短信、电话、出口和 VoWiFi 控制的 Line 解析不依赖 supervisor 号码状态。端口在 hardware composition 中注入,在 Simulator 中使用合成 source 或仅蜂窝 synthetic observation。 - -Line merge algorithm: - -1. 收集合法 `cellular-sim` 与 `ims` `(number, source)`; -2. 按 exact E.164 number 分组; -3. sources 去重并按固定枚举顺序排序; -4. observations 按 number 排序; -5. 最多两项。 - -结果矩阵: - -| Cellular | IMS | `ManagedLine.phoneNumbers` | -| --- | --- | --- | -| empty | empty | `[]` | -| A | empty | `[{A,[cellular-sim]}]` | -| empty | A | `[{A,[ims]}]` | -| A | A | `[{A,[cellular-sim,ims]}]` | -| A | B | `[{A,[cellular-sim]},{B,[ims]}]`(按号码排序) | - -不选主号码,不把不同值视为 Line failure。 - -## 5. Public API and Web - -OpenAPI-first 新增: - -```yaml -PhoneNumberObservation: - required: [number, sources] - number: E.164 - sources: unique array, 1..2, enum [cellular-sim, ims] - -ManagedLine.phoneNumbers: - required array, maxItems: 2 -``` - -从 `VoWiFiLineState` 删除 `phoneNumber`,同步 generated Go/TS/Zod。该接口是本地同版本前后端整体更新,不保留页面双读兼容路径;domain supervisor 内部字段可暂时保留作为 Line adapter 输入。 - -线路页表格/卡片只渲染 `ManagedLine.phoneNumbers`: - -- 空:`未获取`; -- 每个号码独立显示; -- source 标签为 `蜂窝 SIM`、`IMS`; -- 同值时一个号码旁显示两个标签; -- 不同值时两行全部显示。 - -VoWiFi 状态查询继续只用于激活/在线/阶段/出口,不再提供号码给页面。 - -## 6. Privacy, operation, and rollback - -- 号码是当前只读观测,不持久化,不进普通日志/错误/SSE/task evidence;测试只用 synthetic values。 -- 生产 probe 新增固定 read-only `CNUM`,无 RF/SIM/SMS/data write;普通测试不能触碰设备。 -- HIL-0 已确认当前 QDC507/SIM 返回唯一 type-145 E.164;真实值只在私有对话展示。 -- 回滚是恢复旧镜像/提交;没有 schema migration 或持久数据回滚。 -- 完整门禁通过后重建 `dev` 三镜像、原地 Compose update、核对 health/revision,并私下确认真实 Line API/UI observation;不打印号码到日志或 task。 - -## 7. Key risks - -- `CNUM` 是 SIM phonebook 数据,可能为空或陈旧;因此保留来源并与 IMS 不同值同时展示。 -- Line/VoWiFi 构造可能形成依赖环;通过 Line-owned narrow port + supervisor adapter 避免。 -- Probe cache 会暂存号码于 Agent/simplusd 进程内存;这是当前 observation 所需,但不得落盘或进入 error/log。 -- 删除公开 VoWiFi phoneNumber 会触及生成物与 UI fixtures;必须 OpenAPI-first 原子更新并运行 generated drift checks。 diff --git a/.trellis/tasks/archive/2026-08/08-13-line-phone-number-observation/implement.jsonl b/.trellis/tasks/archive/2026-08/08-13-line-phone-number-observation/implement.jsonl deleted file mode 100644 index 67b0859..0000000 --- a/.trellis/tasks/archive/2026-08/08-13-line-phone-number-observation/implement.jsonl +++ /dev/null @@ -1,10 +0,0 @@ -{"file":".trellis/spec/core/backend/application-boundaries.md","reason":"Own the consumer-defined Line port, typed hardware boundary, best-effort semantics, and dependency direction."} -{"file":".trellis/spec/core/backend/api-contracts.md","reason":"Change Agent and OpenAPI contracts atomically with bounded validation and privacy-safe fields."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Apply protocol, hardware failure, cross-layer, privacy, and generated-drift test requirements."} -{"file":".trellis/spec/core/infra/hardware-and-hil-safety.md","reason":"Keep CNUM fixed/read-only inside the model adapter and preserve the approved HIL/privacy boundary."} -{"file":".trellis/spec/core/docs/privacy-and-publication.md","reason":"Prevent the observed phone number and raw transcript from entering repository artifacts or logs."} -{"file":".trellis/spec/web/frontend/component-guidelines.md","reason":"Render sourced number observations consistently in desktop tables and compact cards."} -{"file":".trellis/spec/web/frontend/state-management.md","reason":"Keep ManagedLine HTTP authoritative and avoid reconstructing Line identity from the VoWiFi query."} -{"file":".trellis/spec/web/frontend/type-safety.md","reason":"Use the OpenAPI-generated phone observation model without handwritten duplicate types."} -{"file":".trellis/spec/web/frontend/quality-guidelines.md","reason":"Cover focused component, typecheck, generated, build, and responsive E2E behavior."} -{"file":".trellis/tasks/08-13-line-phone-number-observation/research/qdc507-cnum.md","reason":"Use the primary CNUM contract and sanitized current-device evidence without private values."} diff --git a/.trellis/tasks/archive/2026-08/08-13-line-phone-number-observation/implement.md b/.trellis/tasks/archive/2026-08/08-13-line-phone-number-observation/implement.md deleted file mode 100644 index 9d50a60..0000000 --- a/.trellis/tasks/archive/2026-08/08-13-line-phone-number-observation/implement.md +++ /dev/null @@ -1,43 +0,0 @@ -# Implementation Plan - -## Phase A — Typed cellular observation - -- [x] Add the model-neutral `SubscriberNumberAdapter` seam and QDC507 fixed `AT+CNUM` parser with strict synthetic fixtures. -- [x] Attach the best-effort result only to a ready, identity-known `SIMObservation`; add Agent protocol validation and round-trip tests. -- [x] Propagate the independent cellular observation through Agent `SIMObservation`, hardware `SubscriptionProfile`, inventory mapping, clone/sort/validation and topology digest without coupling it into stable SIM identity reads. -- [x] Add absent/locked/swapped/identity-failure regressions proving no stale number survives and no probe readiness regression occurs. - -## Phase B — Line-owned source merge - -- [x] Add Line domain observation/source types and a consumer-owned optional `PhoneNumberSource` port. -- [x] Carry the current cellular observation through resolved managed Lines without persisting it. -- [x] Implement a narrow supervisor-backed IMS source keyed by stable Line ID; inject it for display views only, without introducing a Line↔VoWiFi dependency cycle or changing business `Topology` availability. -- [x] Implement deterministic empty/single/same/different merge behavior and source ordering; make IMS source failure best effort. - -## Phase C — OpenAPI and Web - -- [x] Add `PhoneNumberObservation[]` to `ManagedLine`, remove `phoneNumber` from public `VoWiFiLineState`, then regenerate Go/TypeScript/Zod outputs. -- [x] Map Line domain observations in HTTP handlers and remove VoWiFi phone mapping. -- [x] Refactor the Lines page to render only `ManagedLine.phoneNumbers`, including source tags, same-number deduplication and two different-number rows. -- [x] Update focused HTTP, desktop/mobile Web tests and E2E fixtures; use synthetic values only. - -## Phase D — Docs/spec and verification - -- [x] Correct architecture/product wording so Line owns phone observations and cellular/IMS are sources; add the executable boundary to the relevant Trellis spec. -- [x] Run focused and race Go tests for modemadapter, hardwareprobe, agentapi, hardware/domain, inventory, Line, VoWiFi source and HTTP. -- [x] Run OpenAPI generation/verification, focused Web tests/typecheck/build, format/docs/container checks, `make lint`, `make test`, desktop/mobile E2E and security. -- [x] Run source/privacy scans proving no real phone number, raw CNUM transcript, arbitrary AT surface, model branch above adapter or new phone-number persistence. -- [x] Dispatch independent Trellis check, fix scoped findings, and rerun affected plus final broad gates. - -## Phase E — Commit and local deployment - -- [x] Present one batched commit plan and commit only after user confirmation; do not push. -- [x] Archive/journal the task after the work commit. -- [x] Build `dev` control/agent/netd images with the work revision, validate Compose, update in place without deleting data. -- [x] Verify bootstrap exit 0, `agent/netd/app` healthy, HTTP health ok, image revision, clean Git state and no active task. -- [x] Privately confirm the running Line API/UI receives the real QDC cellular observation without persisting or logging its value. - -## Hard stops - -- Any need for `CPBS/CPBW`, phonebook preference, RF/SIM/SMS/data mutation, arbitrary AT/API surface, private-value persistence, unexpected competing device owner, unrelated dirty work or failed broad gate stops the relevant phase. -- No product code or HIL action starts until the final planning summary is explicitly approved and `task.py start` succeeds. diff --git a/.trellis/tasks/archive/2026-08/08-13-line-phone-number-observation/prd.md b/.trellis/tasks/archive/2026-08/08-13-line-phone-number-observation/prd.md deleted file mode 100644 index a22ac5d..0000000 --- a/.trellis/tasks/archive/2026-08/08-13-line-phone-number-observation/prd.md +++ /dev/null @@ -1,47 +0,0 @@ -# 统一线路手机号观测 - -## Goal - -让手机号成为 `Line` 的统一当前观测集合,而不是 Host VoWiFi 的 UI 附属字段。支持的型号在硬件边界内通过固定只读能力读取当前 SIM/Profile 的本机号码;IMS 注册号码作为另一种内部观测来源汇入同一 Line 语义。Web、OpenAPI 和其他上层只读取 Line,不知道 `AT+CNUM`、QDC507 或 VoWiFi 的实现细节;当来源返回不同号码时,页面同时显示并标明来源。 - -## Background - -- 当前线路页直接读取 `line.voWiFi?.phoneNumber`(`web/src/pages/Lines.tsx:165,174`)。因此未启用 Host VoWiFi 的原生蜂窝 Line 永远显示“未获取”,即使 SIM 能返回本机号码。这违反了项目现有的 `Web/API -> application intent -> Agent typed service -> model adapter` 边界。 -- `ManagedLine`、`domain/line.View`、`inventory.Line` 和 `hardware.SubscriptionProfile` 当前均没有手机号字段;`VoWiFiLineState.phoneNumber` 是唯一公开来源。 -- QDC507 的普通生产 probe 已读取模块身份、SIM identity、归属运营商与注册状态,但没有调用本机号码能力。之前的严格 `CNUM` 实现只服务一次性 HIL,清理时已删除,没有迁入生产 typed capability。 -- Quectel EC25/EC21(EG25-G 同系列)手册定义 `AT+CNUM` 为从 (U)SIM 读取 subscriber own number;结果可以为空或包含多条。国际号码 type 145 本身应带 `+`,上层不得依据 type、运营商或已知号码猜测补全。 -- 2026-08-13 的当前设备 HIL-0 在唯一 QDC507、SIM ready、CS/PS/EPS 中国联通 LTE 注册且 Agent 独占恢复的前提下,仅执行一次固定 `AT+CNUM`。设备返回唯一显式 `+E.164`、type 145 和最终 `OK`;实际号码只显示在私有对话,未写入仓库、任务、日志或 fixture。探针在宿主权限拒绝时未打开设备,随后通过 network-none、单 tty、read-only、cap-drop 容器完成,并恢复 Agent healthy。 - -## Requirements - -- R1 — Line 统一所有权:号码观测必须进入 Line 应用/领域视图与 `ManagedLine` OpenAPI;线路页只读取该集合,不再 join 或读取 `VoWiFiLineState.phoneNumber`。 -- R2 — typed 型号能力:新增最小 model-neutral subscriber-number adapter seam;QDC507 adapter 固定执行 `AT+CNUM` 并严格解析。Web/API 不得提交型号、命令、endpoint 或来源选择。 -- R3 — 当前 SIM/Profile 归属:蜂窝号码观测必须与同一次 probe 的当前 ready SIM identity 一起进入 Agent `SIMObservation`,再映射到 `SubscriptionProfile` 和对应 Line;换卡、锁卡、缺卡、probe 失败或身份不可确认时号码立即不可用,不得沿用旧卡号码。 -- R4 — 严格号码语义:只接受唯一一条、字段数量和终止符精确、号码本身为 `+E.164`、type 145 的 `CNUM`。空结果是正常 unavailable;重复、多个号码、national/unknown type、缺少 `+`、控制字符、overflow、URC、echo、mixed terminal 或查询失败均 unavailable,不猜测。 -- R5 — best effort:号码读取失败不得令 otherwise-complete modem/SIM probe、Line readiness、蜂窝注册或短信能力失败;但不得把 IMEI、IMSI、ICCID、测试对端号码或运营商信息冒充手机号。 -- R6 — 多来源汇聚:蜂窝 `CNUM` 和 IMS `P-Associated-URI` 仅作为内部 Line number observations。Line 层负责校验当前 Line ID/SIM 绑定、按号码合并并输出公共列表;来源实现不得泄漏到 Web。只有一个来源时保留一项;同值时合并为一项并列出两个来源;不同值时保留两项,不猜测权威顺序、不阻断 Line。IMS 号码查询只参与 Line 展示视图,不进入供短信、电话、出口和 VoWiFi 控制使用的 `Line.Topology`。 -- R7 — 当前观测不持久化:手机号不写 Managed Line 配置或新的 SQLite 字段;由当前硬件/IMS 快照按需返回,避免换卡或号码变更后显示陈旧值。只有经过管理员鉴权的 `ManagedLine` 业务 API 暴露号码;硬件拓扑/setup、普通日志、错误、SSE 和 task evidence 不包含真实号码。 -- R8 — 公共契约收口:新增 `ManagedLine.phoneNumbers`,最多两项;每项包含一个规范 `+E.164` `number` 和非空唯一 `sources`,source 枚举仅为 `cellular-sim`、`ims`。列表按号码稳定排序;空列表表示未获取。移除公开 `VoWiFiLineState.phoneNumber`,生成物由 OpenAPI 源刷新。VoWiFi 页面仍展示运行状态,但不再拥有 Line 身份字段。 -- R9 — 型号可扩展:本次只为已有真实证据的 QDC507 注册蜂窝号码能力。其他型号不实现时上层保持同一 Line 接口并显示 unavailable,不在 Line/Web 增加型号分支。 -- R10 — Simulator 与回归:Simulator 提供合成 Line 号码;测试覆盖 QDC success/unavailable/malformed、Agent 协议与校验、换卡/缺卡清空、Line source merge、OpenAPI/HTTP、Web 桌面与移动展示,并确保 fixture 不含真实号码。 - -## Acceptance Criteria - -- [x] AC1:当前 QDC507/SIM 在不启用 VoWiFi 时,线路页通过 `ManagedLine` 显示 HIL-0 已确认的本机号码;Web 源码不再读取 `voWiFi.phoneNumber`。 -- [x] AC2:固定 QDC507 `CNUM` parser 仅接受唯一显式 `+E.164` type 145 + final `OK`;所有空、歧义、畸形和失败 fixture 均产生 unavailable,且不影响 Line readiness。 -- [x] AC3:号码沿 `adapter -> Agent SIM observation -> inventory SubscriptionProfile -> Line application view -> OpenAPI -> Web` 贯通;任一换卡/锁卡/缺卡/identity mismatch 会清空,且无型号/AT 分支越过 adapter。 -- [x] AC4:只有一个来源时 Line 返回一项;蜂窝与 IMS 同值时返回一项且列出两个来源;不同值时返回两项并分别标注来源,Web 全部展示且有桌面/移动回归。 -- [x] AC5:手机号不新增数据库持久化,不进入日志/SSE/错误/任务 evidence;真实号码不进入 Git,所有测试使用 synthetic E.164。 -- [x] AC6:focused/race tests、OpenAPI generate/verify、Web typecheck/tests/build、`make lint`、`make test`、docs/container/security checks 通过;普通自动化不访问真实设备。 -- [x] AC7:更新本地 `dev` 三镜像并原地更新 Compose;数据目录保留,`agent/netd/app` healthy、HTTP health ok、运行 revision 指向新工作提交,真实线路页显示统一 Line 号码。 - -## Out of Scope - -- 写入或修改 SIM 的 Own Numbers phonebook、`CPBS`/`CPBW`、Qualcomm phonebook preference 或任何 modem persistent setting。 -- 通过 IMSI/ICCID/IMEI、测试对端、运营商账户或短信内容推导号码。 -- 为没有真实证据的 ML307A/其他型号擅自启用 `CNUM`;手工号码编辑和持久 override 另行决策。 -- RF、蜂窝注册、短信、电话、VoWiFi 激活或蜂窝数据副作用。 - -## Key Decision - -- 用户明确要求不同来源返回不同号码时在 Web UI 全部显示。公共 Line 因而拥有来源明确的号码观测列表,不选“主号码”;同值去重并合并来源,不同值分别显示。 diff --git a/.trellis/tasks/archive/2026-08/08-13-line-phone-number-observation/research/qdc507-cnum.md b/.trellis/tasks/archive/2026-08/08-13-line-phone-number-observation/research/qdc507-cnum.md deleted file mode 100644 index 64f0a73..0000000 --- a/.trellis/tasks/archive/2026-08/08-13-line-phone-number-observation/research/qdc507-cnum.md +++ /dev/null @@ -1,34 +0,0 @@ -# QDC507 CNUM evidence - -## Primary contract - -- Quectel EC25/EC21 AT Commands Manual V1.3, section 8.1, defines `AT+CNUM` - as reading the subscriber's own number(s) from the (U)SIM. -- The response may contain zero or more `+CNUM` records followed by a terminal - result. Type 145 denotes an international number whose value already - contains `+`; callers must not synthesize the prefix. -- Source: <https://quectel.com/content/uploads/2021/03/Quectel_EC25EC21_AT_Commands_Manual_V1.3.pdf> - -## Current-device HIL-0 conclusion - -On 2026-08-13, after confirming exactly one QDC507, a ready current SIM, -home registration in CS/PS/EPS domains and the healthy Compose Agent as the -only endpoint owner, a temporary outside-repository probe executed exactly one -fixed read-only `AT+CNUM` query. The response contained exactly one explicit -international E.164 number, type 145, and final `OK`. - -The first host attempt was denied before opening the device and therefore sent -no command. The successful attempt used a temporary network-none container -with only the selected tty, a read-only probe bind, all capabilities dropped -and `no-new-privileges`; the normal Agent was restored and health-checked. -The temporary source/binary were deleted. The actual number and raw transcript -were displayed only in the direct private conversation and are intentionally -absent here, from fixtures, logs, and Git. - -## Product implication - -The current SIM and QDC507 firmware can supply a cellular subscriber-number -observation. The missing Line number is an integration/ownership defect, not -evidence that this SIM or cellular registration lacks a number. Production -must still treat `CNUM` as best effort because the standard permits an empty or -multi-record result and SIM phonebook data may differ from an IMS identity. diff --git a/.trellis/tasks/archive/2026-08/08-13-line-phone-number-observation/task.json b/.trellis/tasks/archive/2026-08/08-13-line-phone-number-observation/task.json deleted file mode 100644 index b010818..0000000 --- a/.trellis/tasks/archive/2026-08/08-13-line-phone-number-observation/task.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "id": "line-phone-number-observation", - "name": "line-phone-number-observation", - "title": "统一线路手机号观测", - "description": "把蜂窝 CNUM 与 IMS 号码观测收口到统一 Line 字段,修复 Web 直接读取 VoWiFi 状态的抽象泄漏。", - "status": "completed", - "dev_type": null, - "scope": null, - "package": "core", - "priority": "P2", - "creator": "Leon", - "assignee": "Leon", - "createdAt": "2026-08-13", - "completedAt": "2026-08-14", - "branch": null, - "base_branch": "main", - "worktree_path": null, - "commit": null, - "pr_url": null, - "subtasks": [], - "children": [], - "parent": null, - "relatedFiles": [], - "notes": "", - "meta": {} -} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-08/08-13-qdc507-hil-cleanup-serial/check.jsonl b/.trellis/tasks/archive/2026-08/08-13-qdc507-hil-cleanup-serial/check.jsonl deleted file mode 100644 index 127cf65..0000000 --- a/.trellis/tasks/archive/2026-08/08-13-qdc507-hil-cleanup-serial/check.jsonl +++ /dev/null @@ -1,8 +0,0 @@ -{"file":".trellis/spec/core/backend/application-boundaries.md","reason":"Audit that module SN remains adapter-owned display metadata and production SMS identity/fencing is unchanged."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Verify deleted HIL surface, synthetic serial failure matrix and complete non-hardware gates."} -{"file":".trellis/spec/core/infra/hardware-and-hil-safety.md","reason":"Audit fixed read-only probe, owner restoration, no side effects and repository privacy."} -{"file":".trellis/spec/core/infra/containers-and-privileges.md","reason":"Verify rebuilt local Agent/control/netd composition, state preservation and health."} -{"file":".trellis/spec/core/docs/documentation-ownership.md","reason":"Check obsolete command removal and correct ownership of architecture/compatibility/development statements."} -{"file":".trellis/spec/core/docs/privacy-and-publication.md","reason":"Scan final tree and rewritten history for identifiers, transcripts, paths and deleted HIL source."} -{"file":".trellis/spec/web/frontend/quality-guidelines.md","reason":"Verify generated drift, Modems display, typecheck, build and desktop/mobile E2E."} -{"file":".trellis/tasks/08-13-qdc507-hil-cleanup-serial/research/eg25g-serial.md","reason":"Cross-check accepted command grammar and ensure observed private values were not turned into fixtures."} diff --git a/.trellis/tasks/archive/2026-08/08-13-qdc507-hil-cleanup-serial/design.md b/.trellis/tasks/archive/2026-08/08-13-qdc507-hil-cleanup-serial/design.md deleted file mode 100644 index 5e64fe7..0000000 --- a/.trellis/tasks/archive/2026-08/08-13-qdc507-hil-cleanup-serial/design.md +++ /dev/null @@ -1,103 +0,0 @@ -# Design: QDC507 HIL cleanup and EG25-G serial - -## 1. Boundaries - -This task has one product result: the repository contains the production QDC507 cellular-SMS implementation and normal deterministic tests, but no executable one-off QDC507 HIL toolkit. The same cleanup exposes the model adapter as the only owner of a fixed EG25-G module-serial query. - -Keep: - -- production `QDC507SMS`, `qdc507sms.Adapter`, complete Driver/TTY transport, SQLite v2 state and normal SMS router; -- PDU encoding/decoding, 8/16-bit inbound concatenation, payload-echo handling, persist-before-ACK and outcome-unknown behavior; -- shared operation gate, production SMS target resolver, Agent protocol/backend, application Line transport selection and public Web/API behavior; -- deterministic unit, integration, Web and container tests for those production contracts. - -Remove: - -- `internal/qdc507hil/**` and every `cmd/simplus-qdc507-hil*/**` entrypoint; -- every `qdc507_hil` tagged file and the Make target that compiles them; -- HIL-only classifier/preservation/cleanup/recovery/outbound-confirmation state, SQLite and transport code; -- untagged interfaces/types/functions whose only consumers were those runners, including split inbox/outbound routers/adapters, dedicated HIL target resolvers and subscriber-number observation; -- public operational prose for commands that no longer exist. Preserve only the sanitized compatibility conclusion that real inbound/outbound SMS was accepted. - -The removal is reference-driven: after deleting obvious tagged files, search each HIL-only symbol and remove it only when no production consumer remains. Production code must not be rewritten merely to reduce line count. - -## 2. Module serial contract - -### Adapter - -Add a narrow model capability owned by `internal/modemadapter`, for example: - -```go -type ModuleSerialAdapter interface { - Adapter - ReadModuleSerial(context.Context, attransport.Query) (string, error) -} -``` - -Only QDC507 implements it in this task. The fixed query sequence is model-private: - -```text -AT+CGSN=? -AT+CGSN=0 -AT+CGSN=1 -``` - -The parser requires one terminal `OK`, bounded printable response lines, an explicit syntax advertisement containing both supported parameters, a non-empty bounded SN, a valid IMEI from parameter 1, and `SN != IMEI`. Unsupported, ambiguous, echoed, malformed, oversized, control-character or equal-to-IMEI results return unavailable. There is no `EGMR`, set form, fallback or guessed normalization. - -The existing equipment identity remains the stable HMAC-bound IMEI flow. Module SN is display metadata and never replaces the fingerprint or persistence key. - -### Agent and inventory flow - -```text -QDC507 ModuleSerialAdapter - -> existing bounded AT session during DeviceProbe - -> agentapi.ModemIdentity.SerialNumber - -> inventory PhysicalDevice.ModemSerialNumber - -> application modem/line view serialNumber - -> existing OpenAPI/Web serialNumber field -``` - -Do not put module SN in `USBIdentity.SerialNumber`; that field and its fingerprint continue to mean USB descriptor iSerial. Add a distinct internal module-serial field, then select module SN first and USB iSerial only as a display fallback. Candidate `usbSerialHint` and stable USB fingerprint behavior remain unchanged. - -The public schema shape does not change. Descriptions change from “USB Serial” to “observed module serial, otherwise USB serial”. No raw value is persisted by Managed Modem storage; it is a current observation. - -## 3. Fixed real-device observation - -After implementation approval but before committing parser evidence: - -1. Confirm exactly one QDC507 and record the existing Compose owner state privately. -2. Stop the dependent local Compose services so no second process owns the primary AT endpoint. -3. Use a temporary source/binary outside the repository, with a typed allowlist containing only `ATI`, `AT+QGMR`, `AT+CGSN=?`, `AT+CGSN=0`, and `AT+CGSN=1`. -4. Stop on target ambiguity, open/query failure, unexpected transcript or timeout. Do not retry with alternate commands. -5. Restore the exact Compose services and verify `agent/netd/app` health plus HTTP health. -6. Display manufacturer/model/firmware/SN/IMEI directly in the private conversation as authorized. Do not save raw output in tracked files, tests, task artifacts or ordinary logs. - -Synthetic tests may reproduce only the public response grammar and boundary class, never the observed identifiers. - -## 4. Documentation and privacy - -- `docs/development.md` loses all QDC507 tagged-runner build/execution instructions. -- `docs/architecture.md` retains production SMS architecture and sanitized HIL evidence, not historical runner object graphs. -- `docs/compatibility.md` keeps accepted capability and unverified boundaries. -- `.trellis/spec/core/infra/hardware-and-hil-safety.md` removes executable contracts for deleted runners and adds one narrow rule: explicitly authorized low-sensitivity identifiers may be shown in the direct private session, but never written to repository files/logs; credentials/raw protocol/private topology remain prohibited. -- Archived task history remains documentation, not executable code. It must contain no actual private values. - -## 5. Git history reconstruction - -The current branch is exactly three commits ahead of `origin/main`, and none is pushed. After the desired tree passes all checks: - -1. Record the full desired worktree/index and verify no private values. -2. Move `main` back to `origin/main` with the explicitly authorized non-hard history reconstruction, leaving the desired tree intact. -3. Create a new cleaned feature commit containing production QDC507 SMS, this cleanup and module-SN support, excluding Trellis bookkeeping. -4. Recreate the prior QDC507 task archive commit and journal commit, updating journal references to the new feature hash. -5. Archive and journal this task through normal Trellis commits. -6. Verify no branch/tag/ref names any old commit, expire reflogs, run immediate Git GC/prune, and prove each old hash fails `git cat-file -e`. -7. Never create a backup branch/tag and never push. - -This is intentionally unrecoverable after the final prune, matching the user's explicit request. - -## 6. Deployment and rollback - -Only after the cleaned feature commit hash exists, rebuild `control`, `agent` and `netd` with tag `dev`, validate Compose config and run `docker compose up -d`. Preserve `./data`. Verify long-running service health, bootstrap exit 0, HTTP health and image revision. - -Before history GC, a failed implementation can be abandoned while the working tree still contains all changes. After GC there is no rollback to the old HIL sources; production rollback remains possible through `origin/main` or a newly built prior production image, but no HIL toolkit is retained. diff --git a/.trellis/tasks/archive/2026-08/08-13-qdc507-hil-cleanup-serial/implement.jsonl b/.trellis/tasks/archive/2026-08/08-13-qdc507-hil-cleanup-serial/implement.jsonl deleted file mode 100644 index 101b455..0000000 --- a/.trellis/tasks/archive/2026-08/08-13-qdc507-hil-cleanup-serial/implement.jsonl +++ /dev/null @@ -1,8 +0,0 @@ -{"file":".trellis/spec/core/backend/application-boundaries.md","reason":"Typed model adapter ownership, stable identity and no model/AT leakage above the hardware boundary."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Production regression, protocol, privacy and targeted-to-broad validation requirements after deleting fixture-only surfaces."} -{"file":".trellis/spec/core/infra/hardware-and-hil-safety.md","reason":"Exact read-only hardware authorization, endpoint ownership, private evidence and fail-closed command boundary."} -{"file":".trellis/spec/core/infra/build-and-generated-files.md","reason":"Make target removal, generated ownership and reproducible container/build checks."} -{"file":".trellis/spec/core/docs/documentation-ownership.md","reason":"Canonical owners for removing obsolete HIL runner instructions while preserving accepted compatibility evidence."} -{"file":".trellis/spec/core/docs/privacy-and-publication.md","reason":"Repository/history exclusion for identifiers, raw HIL material and private runtime artifacts."} -{"file":".trellis/spec/web/frontend/type-safety.md","reason":"Existing OpenAPI-generated serial field and Web unavailable/display behavior."} -{"file":".trellis/tasks/08-13-qdc507-hil-cleanup-serial/research/eg25g-serial.md","reason":"Official EG25-G command evidence, current repository serial flow and fixed private probe allowlist."} diff --git a/.trellis/tasks/archive/2026-08/08-13-qdc507-hil-cleanup-serial/implement.md b/.trellis/tasks/archive/2026-08/08-13-qdc507-hil-cleanup-serial/implement.md deleted file mode 100644 index b70a0da..0000000 --- a/.trellis/tasks/archive/2026-08/08-13-qdc507-hil-cleanup-serial/implement.md +++ /dev/null @@ -1,52 +0,0 @@ -# Implementation Plan - -## Phase A — Controlled private serial observation - -- [x] Confirm branch/upstream and exactly one QDC507 without printing identifiers into task files. -- [x] Restate the five-command read-only allowlist and stop conditions in the interactive update. -- [x] Stop the current Compose modem owner, run a temporary non-repository typed probe for `ATI`, `AT+QGMR`, `AT+CGSN=?`, `AT+CGSN=0`, `AT+CGSN=1`, remove it, then restore Compose. -- [x] Report actual low-sensitivity manufacturer/model/firmware/SN/IMEI only in the direct conversation; persist only bounded support/shape conclusions. - -## Phase B — Remove HIL-only source and scaffolding - -- [x] Delete `internal/qdc507hil`, all `cmd/simplus-qdc507-hil*`, all `qdc507_hil` tagged files and `build-qdc507-hil-fixture`. -- [x] Delete orphaned HIL-only QDC507 inbox/outbound adapters, routers, target resolvers, dedicated drivers/transports/stores, strict classifier categories, subscriber-number seam and tests. -- [x] Collapse production code only where needed to remove a deleted dependency, preserving the complete production SMS adapter/router/state path. -- [x] Run `rg` assertions proving no `qdc507_hil`, `qdc507hil`, HIL command path or deleted symbol remains outside archived task history. - -## Phase C — Typed module serial - -- [x] Add the narrow module-serial adapter capability and strict QDC507 `CGSN` parser based on the observed public grammar. -- [x] Add module serial to Agent probe identity and internal physical-device observation without changing USB descriptor serial/fingerprint semantics. -- [x] Prefer module serial for existing Managed Modem/Line `serialNumber`, with USB iSerial fallback and empty unavailable behavior. -- [x] Update OpenAPI descriptions and regenerate owned Go/TypeScript outputs if bytes change. -- [x] Add synthetic success, unsupported, equal-to-IMEI, malformed, overflow/control/terminal, fallback, protocol mapping, application service and Web display tests. - -## Phase D — Docs/spec and privacy - -- [x] Remove QDC tagged-runner instructions/object graphs from development and architecture docs while retaining sanitized accepted SMS evidence. -- [x] Update compatibility/active plan/handoff only where their current claims become stale. -- [x] Remove deleted-runner scenarios from the HIL safety spec and add the explicitly authorized direct-conversation/repository privacy distinction without actual identifiers. -- [x] Scan the intended tree for the observed SN, IMEI, phone number, SMS body, raw transcript, device path and temporary probe artifacts. - -## Phase E — Verification - -- [x] Focused: `go test ./internal/modemadapter ./internal/hardwareprobe ./internal/application/inventory ./internal/application/modem ./internal/application/line ./internal/agentapi ./internal/api/httpapi` and QDC SMS packages. -- [x] `make check-format verify-generated check-container-files check-docs`. -- [x] `make lint`, `make test`, Web build, desktop/mobile `make web-e2e`, and `make security`. -- [x] `git diff --check`, no-HIL-source assertion, privacy scan and full diff review. -- [x] Use Trellis check review; fix only scoped findings and rerun affected gates. - -## Phase F — Rewrite unpushed history and deploy - -- [x] Reconfirm `origin/main..HEAD` is the expected old three-commit sequence and remote is unchanged. -- [x] Reconstruct the desired tree on `origin/main` into a cleaned feature commit, prior task archive and updated prior journal; do not create backup refs and do not push. -- [x] Archive/journal this task after the cleaned feature commit. -- [x] Verify old hashes absent from every ref, expire reflogs, GC/prune now and prove old objects cannot be resolved. -- [x] Build `dev` control/agent/netd images from the new feature revision, validate Compose and update the existing local stack without deleting data. -- [x] Verify `agent/netd/app` healthy, bootstrap exit 0, HTTP health, image revision, clean Git state and no active task. - -## Hard stops - -- More than one candidate QDC507, a competing unknown owner, an unsupported response requiring a new command, any write/RF/SIM/SMS/data action, private-value persistence, unexpected dirty work, failed full gate, or an unexpected upstream change stops before history rewrite. -- Any failure after moving branch history but before new commits stops before reflog expiry/GC; preserve the working tree and repair the scoped commit reconstruction first. diff --git a/.trellis/tasks/archive/2026-08/08-13-qdc507-hil-cleanup-serial/prd.md b/.trellis/tasks/archive/2026-08/08-13-qdc507-hil-cleanup-serial/prd.md deleted file mode 100644 index a5cedb4..0000000 --- a/.trellis/tasks/archive/2026-08/08-13-qdc507-hil-cleanup-serial/prd.md +++ /dev/null @@ -1,45 +0,0 @@ -# 清理 QDC507 HIL 并验证序列号 - -## Goal - -从生产仓库中移除上次 QDC507 实机验收产生的一次性 build-tag HIL runner、命令入口、专用恢复/分类/清理实现及其孤立支撑代码,同时保留已经验收并用于生产的 QDC507 原生蜂窝短信能力。按移远 EG25-G 的固定只读方法确认当前大疆 4G 模块能否读取独立模块序列号,并在成功时让产品的“序列号”字段使用该值。 - -## Background - -- `internal/qdc507hil` 共有 8,857 行 Go 代码;对应的七组 `cmd/simplus-qdc507-hil*` 命令另有约 1,456 行。实质实现只在 `qdc507_hil` build tag 下编译,默认/生产容器不包含这些入口。 -- 除上述目录外,`internal/modemadapter/qdc507sms`、`internal/hardwareprobe`、`internal/storage/sqlite`、`internal/modemadapter`、Makefile 和公开文档中还存在仅服务这些 runner 的 tagged 文件、窄化 adapter/router/transport、subscriber-number HIL seam 和说明;只删除顶层目录会留下无生产消费者的代码。 -- 生产必须保留完整 `QDC507SMS -> Driver -> SQLiteStateStore -> SMSRouter -> Agent` 收发路径、PDU 编解码、恢复/ACK 语义、operation gate、设备/SIM fence 和已经通过的普通测试。 -- 当前设备的 USB descriptor 没有 iSerial,所以产品的 `serialNumber` 为空;稳定设备身份已经通过 IMEI 指纹绑定,管理员可显式 no-store 读取 IMEI。 -- 移远官方 EG25-G 产品页列出适用的 EC2x/EG2x/EG9x/EM05 AT Commands Manual;移远官方技术论坛给出的 EG25-G 固件响应为 `AT+CGSN=? -> +CGSN:"sn,imei"(0,1)`,表明 `AT+CGSN=0` 是 SN、`AT+CGSN=1` 是 IMEI。当前 QDC507 adapter 只发无参数 `AT+CGSN`,没有尝试 SN form。 -- 原功能提交、任务归档提交和 journal 提交仅存在于本地 `main`,相对 `origin/main` ahead 3,尚未 push。用户已明确选择在完成清理后重写这三条本地提交,并清理本地 reflog/unreachable objects,使 HIL-only 源码不出现在最终可达 Git 历史中。 - -## Requirements - -- R1 — 删除全部一次性 QDC507 HIL 实现:删除 `internal/qdc507hil`、全部 `cmd/simplus-qdc507-hil*`、所有 `qdc507_hil` tagged supporting files、Makefile compile-only target,以及公开文档中声称这些 runner 仍可构建/执行的内容。 -- R2 — 删除孤立支撑面:在删除 runner 后,通过引用审计移除只服务 HIL 的 QDC507 inbox/outbound adapter、split router/backend、dedicated TTY/driver/store、strict classifier、subscriber-number HIL seam 和测试;保留任何仍被生产短信路径使用的最小公共实现。 -- R3 — 保留生产功能:QDC507 原生蜂窝短信的生产 registry、Agent backend、SIM `SM` 暂存、durable v2 state、入站 persist-before-ACK、出站 outcome-unknown、call-mode safety 和 Line transport 选择行为不得回退。 -- R4 — 固定只读序列号探测:仅针对唯一当前 QDC507,在独占 endpoint 后依次执行 bounded `ATI`、`AT+QGMR`、`AT+CGSN=?`、`AT+CGSN=0`、`AT+CGSN=1`;不发写命令,不改变 RF/SIM/注册/短信/数据状态。查询结束后恢复原 Compose owner,并核对健康状态。 -- R5 — typed 序列号能力:若真实固件明确支持且 `AT+CGSN=0` 返回与 `AT+CGSN=1` 可区分、格式有界的 SN,则把它封装在 QDC507 adapter 的固定只读能力中,并用于 Managed Modem `serialNumber`;若 unsupported/malformed/与 IMEI 混淆,则保持 serial unavailable,不猜测、不用 IMEI 冒充序列号。 -- R6 — 隐私:手机号、模块 SN、IMEI、短信内容等低敏感私有值可在本次一对一对话中按用户明确授权直接读取和显示,但不得进入 Git tracked 文件、测试 fixture、任务/规范/公开文档、普通日志或提交信息。凭据、私有端点、SIM 身份、raw PDU/transcript 等更敏感材料仍只报告必要结论。 -- R7 — 容器:代码完成并通过门禁后,重建当前 `dev` 三镜像并原地更新同一 Compose 服务;保留数据目录,最终 `agent/netd/app` 必须健康且运行新提交 revision。 - -## Acceptance Criteria - -- [x] AC1:当前工作树中不存在 `internal/qdc507hil`、`cmd/simplus-qdc507-hil*`、`qdc507_hil` build tag、QDC507 HIL Make target或仅描述已删除 runner 的公开操作文档。 -- [x] AC2:引用/类型审计和普通构建证明没有 HIL-only adapter/router/driver/store/subscriber-number seam 残留;生产 QDC507 SMS 纵切仍完整。 -- [x] AC3:固定只读实机探测给出当前模块的 manufacturer/model/firmware、SN support、SN 和 IMEI 结论;获批低敏感值只显示在对话,不写入仓库。 -- [x] AC4:若 SN 可用,Managed Modem/API/Web 的现有 `serialNumber` 显示它,并有 synthetic parser/adapter/service/UI 回归;若不可用,有 unsupported/malformed 回归且 UI 仍显示“未提供”。 -- [x] AC5:focused Go tests、全量 `make lint`/`make test`、生成物、Web build/E2E、container/docs/privacy checks 全部通过;自动化不接触真实 modem/SIM。 -- [x] AC6:本地 Compose 更新成功,持久数据保留,`agent/netd/app` 健康,HTTP health 通过,revision 指向新工作提交。 -- [x] AC7:`origin/main..HEAD` 被重建为不含 HIL-only 源码的新提交序列;旧三个 commit hash 不再被任何 ref/reflog/object 解析,不创建 backup branch/tag,不 push。 - -## Out of Scope - -- 重新执行、保留或替代任何短信 HIL runner;再次发送/接收/删除短信。 -- RF、SIM、蜂窝注册、数据 bearer、电话或 modem persistent configuration 变更。 -- 把手机号、IMEI 或 SIM 身份改成普通公开字段或持久化业务数据。 -- 支持未实测的其他移远型号;QDC507 仍通过 typed adapter 接入,上层不出现 EG25-G/AT 分支。 - -## Key Decision - -- 最终不增加一个仅“删除当前树”的提交;而是以 `origin/main` 为父提交重建清理后的功能提交、原任务归档和 journal,再归档本任务。旧三条未推送提交在验证新历史后清理 reflog 并立即 prune,不保留恢复引用。 diff --git a/.trellis/tasks/archive/2026-08/08-13-qdc507-hil-cleanup-serial/research/eg25g-serial.md b/.trellis/tasks/archive/2026-08/08-13-qdc507-hil-cleanup-serial/research/eg25g-serial.md deleted file mode 100644 index e174b48..0000000 --- a/.trellis/tasks/archive/2026-08/08-13-qdc507-hil-cleanup-serial/research/eg25g-serial.md +++ /dev/null @@ -1,28 +0,0 @@ -# EG25-G module serial research - -## Public vendor evidence - -- Quectel's EG25-G product page identifies EG25-G as an LTE Cat 4 LGA module and lists the shared `Quectel_EC2x&EG2x&EG9x&EM05_Series_AT_Commands_Manual_V2.2` as its AT command reference: <https://www.quectel.com/product/lte-eg25-g/>. -- A Quectel official support-forum report for EG25-G firmware shows the supported syntax response `+CGSN:"sn,imei"(0,1)`: <https://forums.quectel.com/t/imeisv-read-set-on-eg25-g/57859>. This is evidence to test parameter 0 as module SN and parameter 1 as IMEI; it does not prove the DJI/Baiwang firmware behaves identically. - -## Repository evidence - -- The scanner reads USB descriptor iSerial into the existing public `serialNumber`, but the currently attached QDC507 exposes no non-empty USB iSerial. -- `internal/modemadapter/qdc507.go` currently uses only `AT+CGSN` for equipment identity and validates it as IMEI. It has no fixed parameter-0 module-SN query. -- Stable device binding is based on an HMAC fingerprint of IMEI, not the display serial, so adding a display SN must not change identity or persistence keys. - -## Fixed research probe - -For the unique current QDC507, with the Compose Agent stopped so no other process owns the primary AT endpoint, issue exactly: - -1. `ATI` -2. `AT+QGMR` -3. `AT+CGSN=?` -4. `AT+CGSN=0` -5. `AT+CGSN=1` - -All are read-only. Do not issue `AT+EGMR`, any set form, RF/SIM/SMS/data command, fallback, or retry. Keep raw results only in the private interactive conversation; persist only a bounded availability/shape conclusion. - -## Sanitized observation - -The one authorized fixed probe completed against exactly one current QDC507. The private firmware reported the documented `sn,imei` parameter grammar; parameter 0 returned one bounded printable module SN, parameter 1 returned a distinct valid IMEI, and both queries terminated normally. Actual manufacturer/model/revision/firmware/SN/IMEI values were shown only in the direct conversation. The Compose Agent was restored healthy and the temporary source/binary was removed. diff --git a/.trellis/tasks/archive/2026-08/08-13-qdc507-hil-cleanup-serial/task.json b/.trellis/tasks/archive/2026-08/08-13-qdc507-hil-cleanup-serial/task.json deleted file mode 100644 index 88dbc09..0000000 --- a/.trellis/tasks/archive/2026-08/08-13-qdc507-hil-cleanup-serial/task.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "id": "qdc507-hil-cleanup-serial", - "name": "qdc507-hil-cleanup-serial", - "title": "清理 QDC507 HIL 并验证序列号", - "description": "", - "status": "completed", - "dev_type": null, - "scope": null, - "package": "core", - "priority": "P2", - "creator": "Leon", - "assignee": "Leon", - "createdAt": "2026-08-13", - "completedAt": "2026-08-13", - "branch": null, - "base_branch": "main", - "worktree_path": null, - "commit": null, - "pr_url": null, - "subtasks": [], - "children": [], - "parent": null, - "relatedFiles": [], - "notes": "", - "meta": {} -} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-08/08-14-audit-layer-boundaries/audit.md b/.trellis/tasks/archive/2026-08/08-14-audit-layer-boundaries/audit.md deleted file mode 100644 index 3ba3d3d..0000000 --- a/.trellis/tasks/archive/2026-08/08-14-audit-layer-boundaries/audit.md +++ /dev/null @@ -1,399 +0,0 @@ -# Static Layer-Boundary Audit - -## Executive conclusion - -At commit `7f6e8923420872a7be09280a4cb99dd6d9c4211d`, the principal Web/API → application → typed Agent/supervisor → model adapter → protocol path is intact. No Critical external command/path escape hatch was found, no production code above the hardware boundary constructs AT/QMI/APDU text, and the browser's raw network ownership is confined to its documented runtime owners. - -The audit did find five active production responsibility/boundary violations: - -| Class | Critical | High | Medium | Low | Total | -| --- | ---: | ---: | ---: | ---: | ---: | -| Confirmed violation | 0 | 1 | 4 | 0 | 5 | -| Architecture concern | 0 | 0 | 0 | 2 | 2 | - -- **V-01 / High:** the still-installed native Debian fallback hardcodes one model's VID/PID and writes `option1/new_id` outside the adapter registry, unlike the registry-backed container path. -- **V-02 / Medium:** `cmd/simplusd` implements SMS retry, notification, attention and realtime-topic policy plus Agent-change publication policy instead of only wiring/lifecycle. -- **V-03 / Medium:** `application/setup.New` constructs concrete filesystem, password, certificate and secret-storage implementations inside the application package. -- **V-04 / Medium:** `application/mihomo.NewRuntimeManager` constructs the concrete local process supervisor inside the application package. -- **V-05 / Medium:** the notification application service constructs and drives raw Webhook HTTP directly instead of consuming a typed delivery port. - -The two Low concerns are concrete adjacent-layer coupling in `httpapi.Server` and SQLite-owned lease types in the dormant `resourcelease` application package. No repair was made by this audit. - -### Remediation closure — 2026-08-18 - -The findings above remain the historical static-audit result at commit -`7f6e8923420872a7be09280a4cb99dd6d9c4211d`; their source anchors must not be -read as current-line references. All seven findings were subsequently repaired -in separately planned, independently checked child tasks: - -| Finding | Closed outcome | Child task | Functional commit | -| --- | --- | --- | --- | -| V-01 / High | Retired the native production deployment and its duplicate VID/PID writer; Docker Compose is the sole production path and driver registration remains registry-backed. | `08-14-retire-native-deployment` | `7013707` | -| V-02 / Medium | Moved SMS synchronization and Agent-change business/event policy into application-owned coordinators; `cmd/simplusd` retains assembly and lifecycle only. | `08-14-move-background-policy-to-application` | `024cbfb` | -| V-03 / Medium | Replaced Setup's hidden filesystem/password/secret/certificate defaults with explicit application-owned dependencies assembled in `cmd/simplusd`. | `08-14-make-setup-dependencies-explicit` | `d66860f` | -| V-04 / Medium | Made the Mihomo supervisor mandatory and injected; `cmd/simplusd` now owns local/client concrete selection and constructor failure handling. | `08-15-inject-mihomo-supervisor-explicitly` | `ffbc69b` | -| V-05 / Medium | Extracted legacy Webhook target/wire/HTTP behavior behind a bounded Notification-owned delivery port and a concrete adapter. | `08-15-extract-notification-delivery-ports` | `04e1770` | -| C-01 / Low | Replaced four concrete HTTP application pointers with exact HTTP-owned consumer ports while preserving typed-nil behavior. | `08-16-narrow-http-application-service-ports` | `aa8b41f` | -| C-02 / Low | Removed the never-assembled `application/resourcelease` package; retained the released runtime migration and SQLite repository/test only as historical storage compatibility fixtures. | `08-17-resolve-resourcelease-storage-coupling` | `1fe33a7` | - -Each child task used offline/synthetic validation and an independent Trellis -check. The cumulative closure does not add a new production capability, alter -the released ResourceGroup lease migration, or authorize any deployment, -private-runtime, modem, RF, SMS/call, SIM/eUICC, network-mutation, or HIL -action. The original static-analysis limitations still apply, but no finding -from this report remains open in the reviewed source tree. - -Except for this remediation-closure section, the remainder of this document is -the preserved report for baseline `7f6e8923420872a7be09280a4cb99dd6d9c4211d`. -Its present-tense verdicts, source anchors and prioritized-remediation list -describe that historical baseline and are superseded by the closure table -above; they are not assertions that the findings remain current. - -Final closure verification rechecked the archived child records and functional -commit ancestry, traced every repaired boundary in the current source, matched -the retained ResourceGroup lease repository, test and runtime-v5 migration to -their baseline SHA-256 values, and passed the supported offline Go tests, vet, -format, generated-drift, documentation and container-contract gates. `make -lint` passed in the repository's local tool/module environment without -external access. An independent stricter `GOPROXY=off` repetition passed its -Go-vet stage but could not make `go run ...@v1.7.12` resolve cached module -metadata; the exact cached Actionlint v1.7.12 binary passed all workflow files. -No service, Compose, external endpoint, private runtime, hardware or HIL action -was used. - -## Baseline, scope and safety - -### Baseline - -- Commit: `7f6e8923420872a7be09280a4cb99dd6d9c4211d`. -- Initial `git status --short`: only the untracked active task directory, `.trellis/tasks/08-14-audit-layer-boundaries/`; no tracked product diff. -- Inventoried source: 163 handwritten production-runtime Go files, 6 handwritten HIL-only command files, 4 generated Go files, 30 handwritten Web runtime files, 19 Web unit/component test files plus 1 E2E spec, 18 generated Web files, 114 Go test files, 4 product-owned runtime C files plus 4 headers and 1 C protocol test, and 18 tracked production container/release/packaging/build-helper files. The production Go count includes the 10 `internal/vowifihil` files compiled into the netd worker; the HIL-only count is limited to the six `cmd/simplus-vowifi-hil-*` mains. The infrastructure count is the prior 16 Docker/Compose/container/release/packaging files plus the two production-invoked `scripts/dev/build-*plugin.sh` helpers. -- `Makefile:22-25` independently distinguishes production commands, HIL commands and generated paths. - -### Included - -- Handwritten runtime Go under `cmd/**` and `internal/**`, including all current `internal/application/**` packages. -- Handwritten Web runtime under `web/src/**`. -- Public OpenAPI source and the way generated Go/Web contracts are consumed. -- Agent, Mihomo and Host VoWiFi typed Unix protocol definitions, clients, handlers and concrete runtime owners. -- The product-owned `components/strongswan-simplus-simaka/**` C runtime and its production package/build path; upstream strongSwan `p-cscf` source remains a locked third-party input rather than repository-owned runtime source. -- Production `Dockerfile`, `compose.yaml`, `containers/**`, `scripts/release/**`, and packaging ownership. -- Tests and development/HIL tools as separately classified secondary evidence. - -### Excluded or treated as output - -- Third-party implementation internals, `.tools/**`, ignored caches, `web/dist/**`, generated OpenAPI/sqlc/Web client internals, and ignored `.umi*` output. -- Private runtime data, databases, logs, captures, screenshots, device nodes and live service state. -- Dynamic instrumentation and real deployment/hardware behavior. - -### Safety statement - -The audit used source inspection, `git` metadata, `rg`, and `go list` with `GOPROXY=off`. It did not start Compose, deploy or prepare a host, discover hardware, access `/dev` or live sysfs, run a HIL helper, send SMS/place calls, mutate RF/SIM/eUICC, create network objects, or inspect private evidence. - -## Judgment model - -Directory distance was only a candidate signal. A confirmed violation requires an active production path that owns behavior in the wrong layer, bypasses the next typed boundary, or constructs a lower concrete implementation outside a composition root. A concrete type/import with no such runtime bypass is an architecture concern. Explicit composition, bounded platform runtime ownership, generated output and deliberate test assembly are allowed exceptions. - -The normative dependency is evidenced by `docs/architecture.md:90-101` and the mechanical invariants at `docs/architecture.md:330-346`. Package ownership and the composition-root rule are at `.trellis/spec/core/backend/directory-structure.md:8-33`; typed application/device boundaries are at `.trellis/spec/core/backend/application-boundaries.md:1-25,135-153`; bounded Unix protocol rules are at `.trellis/spec/core/backend/api-contracts.md:324-371`; hardware safety is at `.trellis/spec/core/infra/hardware-and-hil-safety.md:25-48`. The separately licensed SIM-AKA bridge and package boundary are explicit in `docs/decisions/0020-strongswan-plugin-package.md:16-18,22-46` and `.trellis/spec/core/infra/build-and-generated-files.md`. - -## Allowed behavior-edge matrix - -| Caller/owner | Allowed next behavior boundary | Proven implementation evidence | Forbidden behavior checked | -| --- | --- | --- | --- | -| Web pages/components | Generated Query/SDK helpers and narrow handwritten API helpers | Generated Query imports throughout pages; sensitive direct SDK read at `web/src/pages/Modems.tsx:140-148` | Page-owned Fetch/EventSource, duplicate response contracts, device paths or commands | -| Browser API runtime | Same-origin authenticated public HTTP | `web/src/api/runtime.ts:27-78` | Unbounded/cross-origin transport and raw error/payload ownership | -| Browser realtime owner | Authenticated SSE followed by query invalidation | `web/src/app/RealtimeBridge.tsx:26-68` | Authoritative records or mutations over SSE | -| `internal/api/httpapi` | Consumer-facing application services and passive domain/OpenAPI mapping | Interfaces at `internal/api/httpapi/server.go:58-161`; handlers call those services | Direct SQLite, Agent/supervisor implementation, shell, process or hardware behavior | -| `internal/application/**` | Consumer-owned repository/service ports, domain vocabulary and typed Agent/supervisor capabilities | Representative messaging ports at `internal/application/messaging/service.go:64-96,125-180` | Concrete persistence/runtime construction, model/path/command selection, silent fallback | -| Persistence/filesystem adapters | SQLite/sqlc or bounded filesystem primitives | `internal/storage/sqlite/store.go:39-98`; `internal/storage/filesystem/private_directory.go:18-59` | Upward HTTP/application calls or device/protocol semantics | -| Agent/supervisor clients and handlers | Fixed bounded routes and typed requests/results | Agent constructors at `internal/agentapi/server.go:46-63`; VoWiFi request surface at `internal/vowifisupervisor/types.go:49-57,173-186` | Arbitrary shell, command, path, SIP/RP/APDU or network payload inputs | -| Hardware discovery/runtime | Registry-selected capability interfaces | `internal/hardwareprobe/scanner.go:61-111`; `internal/hardwareprobe/at_runtime.go:25-56` | Manufacturing model commands or exposing targets to business control | -| Model adapters/business drivers | Bounded protocol transport | Capability seams at `internal/modemadapter/registry.go:30-110`; QDC507/ML307A implementations | Upper-layer model branching or generic external command input | -| Generic AT/TTY transport | OS/device I/O only | `internal/attransport/transport.go:18-29`; Linux implementation in `session_linux.go` | Choosing commands, models or business behavior | -| netd workers | Fixed process/network/IMS primitives derived from typed stable inputs | `cmd/simplus-netd/main.go:90-147`; `internal/vowifisupervisor/network.go`, `worker.go`; `internal/vowifihil/config.go`, `preflight.go`, `vici.go`, `xfrm.go` | Public arbitrary routes, interfaces, commands, paths or SIP payloads | -| strongSwan SIM-AKA C bridge | One fixed Agent SIM-AKA Unix request plus fixed IMS APN IDr hook | `components/strongswan-simplus-simaka/simplus_simaka_card.c:63-120`; `simplus_simaka_agent.c:348-517`; `simplus_simaka_apn.c:18-54` | AT/APDU/model selection, arbitrary command/route/payload, or caller-selected production socket | -| `cmd/**` | Flag parsing, dependency construction, process lifecycle and executable-only fixed helpers | Main object graphs in the three production binaries | Business/event policy or hidden lower implementation construction | -| Container/release runtime | Fixed image/service privilege boundary and reviewed entrypoints | `compose.yaml:20-178`; container entrypoints | Registry bypass, broad capabilities, host network, arbitrary hardware/network control | - -## Coverage matrix - -“Candidates” counts traced candidate clusters, not raw lexical matches. Every row has an explicit verdict even when clean. - -| Runtime owner | Inspected source/package set | Actual outbound behavior | Candidates | Verdict | -| --- | --- | --- | ---: | --- | -| Web pages/components/features | `web/src/pages`, `components`, `calls`, `messages`, `mihomo` | Generated Query/SDK and local presentation only | 2 | Clean; AE-01/FP-03 | -| Web API runtime | `web/src/api` excluding generated | Sole Fetch, validation, error normalization and query config | 1 | Clean; AE-02 | -| Web bootstrap/app/realtime | `web/src/main.tsx`, `web/src/app` | React bootstrap, generated auth SDK + one EventSource owner + cache invalidation | 1 | Clean; AE-02 | -| Public HTTP/OpenAPI mapper | `internal/api/httpapi`, `api/openapi.yaml` | Application calls, auth/middleware and type mapping | 2 | No lower-layer bypass; C-01 | -| `application/auth` | package | Store/verifier ports | 0 | Clean | -| `application/calls` | package | Repository + Line topology port; Simulator state machine | 1 | Clean; peer service port only | -| `application/contacts` | package | Repository port | 0 | Clean | -| `application/euicc` | package | Repository port; Simulator-only composition | 0 | Clean | -| `application/health` | package | Store probe + passive build info | 0 | Clean | -| `application/inventory` | package | Typed Agent snapshot/probe to normalized hardware topology | 2 | Clean; AE-03/FP-04 | -| `application/line` | package | Repository, inventory/modem records, optional typed VoWiFi number source | 2 | Clean; stable-ID resolution remains here | -| `application/lineegress` | package | Store, inventory and Mihomo status ports | 2 | Clean; peer application DTO coupling is interface-bound | -| `application/messaging` | package | Repository, Line source, typed native/VoWiFi SMS transports | 3 | Clean; unique eligible transport, no fallback (`service.go:583-603`) | -| `application/mihomo` | package | Artifact download/config validation and typed/local supervisor | 3 | V-04; artifact work otherwise AE-04 | -| `application/modem` | package | Repository, inventory and narrow typed Agent RF/identity adapters | 2 | Clean; no model/path branch | -| `application/notification` | package | Repository/secrets and bounded Webhook/Feishu provider HTTP | 2 | V-05; Feishu typed-port path is clean | -| `application/realtime` | package | In-memory bounded topic hub | 0 | Clean | -| `application/resourcelease` | package | Dormant topology/repository service | 1 | C-02; no production caller | -| `application/setup` | package | Setup stores plus directory/crypto/certificate behavior | 1 | V-03 | -| `application/vowifi` | package | Store, inventory, egress, Mihomo and typed supervisor ports | 3 | Clean; no RF/model/path decision and no direct-egress fallback | -| Passive domain | `internal/domain/**` | Validation and records only | 2 | Clean; FP-02/FP-03 | -| SQLite persistence | `internal/storage/sqlite` excluding generated | SQLite/migrations/domain conversion | 1 | Clean; no upward import | -| Filesystem adapter | `internal/storage/filesystem` | Private directory validation/preparation | 1 | Clean as adapter; its hidden construction is V-03 | -| Agent typed protocol | `internal/agentapi` | Fixed HTTP-over-Unix operations, peer checks, bounded validation | 3 | Clean active surface; AE-05/FP-04 | -| Mihomo supervisor | `internal/mihomosupervisor` | Fixed status/start/stop; constrained artifact paths; process owner | 2 | Clean; AE-04/AE-06 | -| Host VoWiFi supervisor and IMS runtime | `internal/vowifisupervisor`, `internal/vowifihil` | Typed Line/SMS operations; fixed Agent SIM-auth, process/network/XFRM/VICI/SIP worker | 5 | Clean production path; AE-06. Historical model-specific HIL wrapper is tool-only | -| strongSwan SIM-AKA bridge | `components/strongswan-simplus-simaka` production `.c`/headers | Fixed typed Agent challenge over AF_UNIX and fixed `vowifi-ims` APN hook | 2 | Clean; AE-09 | -| Hardware discovery | `internal/hardwareprobe` | sysfs/device discovery, adapter selection, typed orchestration | 2 | Clean; AE-05 | -| Model adapters/drivers | `internal/modemadapter/**` | Model match, fixed commands/parsers, QDC507 SMS recovery | 2 | Clean; AT/APDU/vendor strings confined here | -| Generic AT transport | `internal/attransport` | Bounded tty lifecycle/framing | 1 | Clean; no command constants | -| Supporting runtime | `internal/config`, `control`, `security`, `smscodec`, `command`, `buildinfo` | Typed config/control, crypto, codec and metadata | 2 | Clean; no hardware/model bypass | -| `cmd/simplusd` | `main.go`, `web.go` | Composition, HTTP/control lifecycle, static Web; background policies | 3 | V-02; other composition allowed | -| `cmd/simplus-agent` | `main.go` | Agent composition and fixed registry-backed driver registration | 2 | Clean; AE-05 | -| `cmd/simplus-netd` | `main.go` | Supervisor composition and private worker entry | 2 | Clean; AE-06 | -| `cmd/simplusctl` | package | Typed public/control/Agent/supervisor clients | 2 | Clean; fixed inspection/control CLI, no generic device command | -| Compose/container runtime | `Dockerfile`, `compose.yaml`, `containers/**` | Three-image privilege split, fixed entrypoints, netd preflight | 3 | Clean; AE-07 | -| Native release fallback | `scripts/release/**` | Host installation and systemd lifecycle | 2 | V-01; other host mutation remains installer-owned | -| strongSwan plugin packaging | `packaging/strongswan-plugins/**`, `scripts/dev/build-{simplus-simaka,strongswan-p-cscf}-plugin.sh` | Locked Debian inputs; builds/packages product SIM-AKA bridge and upstream `p-cscf` separately | 2 | Clean build boundary; AE-09 | -| Generated boundaries | OpenAPI Go, sqlc Go, Web generated SDK | Generated contract/persistence output | 1 | Excluded internals; source/consumption checked, AE-08 | - -## Confirmed violations - -### V-01 — Native production fallback bypasses the model registry for a hardware driver write - -- **Class/severity:** Confirmed violation / High. -- **Caller layer → target layer:** Native release/systemd packaging → kernel USB serial driver hardware boundary. -- **Anchors:** - - `scripts/release/bind-ml307a.sh:4-15` - - `scripts/release/build-debian-bundle.sh:21` - - `scripts/release/install-debian.sh:6,61,81-118` - - Correct comparison path: `containers/agent-entrypoint.sh:33`, `cmd/simplus-agent/main.go:73-75,290-345`, `internal/modemadapter/registry.go:30-43,181-207,245-251` -- **Call chain:** native bundle copies `bind-ml307a.sh` as `helpers/bind-ml307a` → installer installs it under `/usr/local/libexec/simplus` and makes `simplus-agent.service` require a oneshot bind service → helper loads `option`, selects the literal model ID pair and writes it directly to the fixed `new_id` sysfs attribute. In contrast, container Agent entrypoint calls `simplus-agent register-option-driver` → `DefaultRegistry().USBSerialIDs()` → validated, unique adapter-owned IDs → fixed writer. -- **Violated rule:** dynamic USB IDs and model selection belong to `modemadapter` registry/runtime hardware ownership; a production release path must not maintain a second model registry. See `docs/architecture.md:80-82,90-101` and `.trellis/spec/core/infra/containers-and-privileges.md:27-38`. -- **Impact:** native fallback behavior can drift from the reviewed registry when models or IDs change, can bind a no-longer-supported ID, and cannot bind a newly supported ID. The helper also treats every failed write at `bind-ml307a.sh:13-15` as success, not only the documented already-registered case, so systemd can continue after an unclassified hardware-boundary failure. -- **Minimum remediation:** remove the duplicated helper or have the native oneshot invoke a fixed `simplus-agent` subcommand that obtains IDs exclusively from the registry. If native and container paths need different fixed sysfs mount points, select only between compiled-in fixed targets inside the hardware binary; do not accept a caller-provided path or VID/PID. -- **Safe validation:** add/extend non-HIL unit/contract tests proving both native bundle and container entrypoints reach the registry-backed command, all registry IDs are registered once, invalid/duplicate IDs fail closed, and a write error other than already-exists returns non-zero. Shell syntax and package tests are sufficient; no device is needed. - -### V-02 — `cmd/simplusd` owns background business, notification and realtime publication policy - -- **Class/severity:** Confirmed violation / Medium. -- **Caller layer → target layer:** Composition root → application messaging/notification/realtime and Agent event semantics. -- **Anchors:** `cmd/simplusd/main.go:281-283,394-463,470-505,533-553`; tests at `cmd/simplusd/main_test.go:53-99` demonstrate that the policy is intentionally owned by `main`. -- **Call chains:** - 1. `run:281` → `runSMSSync` → `messaging.Service.SyncInbound` → inspect persisted/already-known/report counters → decide Message topic and `sms.received` attention → synthesize notification content → call notification service → publish Notification topic → choose retry/backoff policy. - 2. `run:283` → `runAgentChanges` → Agent snapshot/change loop → compare Agent instance/generation → select Inventory/Modems/Lines topic set → choose retry/backoff policy. -- **Violated rule:** `cmd/**` owns dependency construction and lifecycle; business/event decisions belong to application/domain packages (`.trellis/spec/core/backend/directory-structure.md:22-33`). Polling goroutine lifetime is legitimate executable lifecycle, but deciding durable-result meaning, notification event, attention and topic mapping is not mere wiring. -- **Impact:** changes to SMS durability semantics, notification behavior or resource invalidation require editing the executable root and coupling concrete application services there. The policy cannot be reused or tested at its owning application boundary, and future binaries can silently diverge. -- **Minimum remediation:** move SMS synchronization policy into a narrow application coordinator with `Syncer`, `Notifier` and `Publisher` ports; move Agent-change-to-topic mapping into an inventory/realtime coordinator. Leave only construction, `go coordinator.Run(ctx)`, intervals/config and shutdown in `cmd/simplusd`. -- **Safe validation:** migrate current deterministic backoff/publication tests to the new application package; add fake-port tests for partial progress, attention, notification failure, generation/instance changes and cancellation. No network or hardware is required. - -### V-03 — Setup application constructor silently assembles concrete lower-layer adapters - -- **Class/severity:** Confirmed violation / Medium. -- **Caller layer → target layer:** Application setup → concrete filesystem and security implementations. -- **Anchors:** - - Imports and port type: `internal/application/setup/service.go:23-27,58-97` - - Hidden construction: `internal/application/setup/service.go:218-248` - - Runtime use: `internal/application/setup/service.go:503-547,586-605,711-717` - - Active assembly caller: `cmd/simplusd/main.go:69-77` -- **Call chain:** `cmd/simplusd` constructs SQLite set and calls `setup.New(stores, stores)` → `setup.New` type-asserts the same object into optional roles → creates the default password hasher, binds `storagefs.PreparePrivateDirectory`, creates a secretbox opener using a concrete filesystem path, and binds the concrete local CA generator → later setup mutations perform filesystem probing, key opening/encryption and certificate generation through those hidden defaults. -- **Violated rule:** lower implementations are selected in the composition root; application packages consume explicit narrow ports. `DirectoryPreparer` also returns the storage-owned `storagefs.DirectoryIdentity`, preserving concrete adapter vocabulary at the application seam. -- **Impact:** filesystem/crypto behavior and key-path policy are implicit consequences of the store's dynamic type. The application constructor both discovers capabilities and chooses implementations, making alternate deployment/tests and failure review depend on hidden wiring. -- **Minimum remediation:** define application-owned input/result types and require an explicit dependency/options object. Construct `storagefs`, password, secretbox and certificate implementations in `cmd/simplusd`, then inject them. Keep optional feature availability explicit rather than inferred from type assertions on one catch-all store. -- **Safe validation:** preserve current setup tests using injected fakes and add a composition test that all production dependencies are present. Run setup/httpapi tests against temporary storage only; no HIL. - -### V-04 — Mihomo application constructor creates the concrete local process supervisor - -- **Class/severity:** Confirmed violation / Medium. -- **Caller layer → target layer:** Application Mihomo runtime → concrete supervisor/process implementation. -- **Anchors:** - - `internal/application/mihomo/runtime.go:37-53,92-111,162-188` - - `cmd/simplusd/main.go:236-247` - - Concrete process implementation: `internal/mihomosupervisor/local.go:29-105` -- **Call chain:** when no supervisor socket is configured, `cmd/simplusd` calls `mihomo.NewRuntimeManager` → the application constructor calls `mihomosupervisor.NewLocal(root)` (and discards its error) → later `RuntimeManager.Start` invokes its `mihomosupervisor.API` field → local supervisor validates paths and starts the process. The socket-backed branch correctly constructs a client in `cmd/simplusd` and injects it through `NewRuntimeManagerWithSupervisor`. -- **Violated rule:** application may call a typed supervisor port, but concrete local/client implementation choice belongs to the composition root. This is the same boundary regardless of whether the selected runtime is an unprivileged Simulator/development fallback. -- **Impact:** an environment/runtime implementation decision is hidden inside business construction and the no-socket path has different assembly semantics from the socket path. Ignoring the local-constructor error also weakens explicit startup failure handling. -- **Minimum remediation:** remove or narrow `NewRuntimeManager`; construct `mihomosupervisor.NewLocal` in `cmd/simplusd` for the explicitly supported local mode and inject it through the single constructor that requires a non-nil API. Keep actual process policy in `internal/mihomosupervisor`. -- **Safe validation:** composition tests for local and socket-backed modes plus existing supervisor fake tests; assert constructor failures stop startup. No process needs to be launched in the test. - -### V-05 — Notification application service bypasses a typed provider port for Webhook delivery - -- **Class/severity:** Confirmed violation / Medium. -- **Caller layer → target layer:** Application notification use case → raw external HTTP transport/provider protocol. -- **Anchors:** - - Concrete field/default: `internal/application/notification/service.go:48-62` - - Active delivery: `internal/application/notification/service.go:203-263` - - Typed contrast: `internal/application/notification/feishu.go:60-80`, `binding.go:58-66` - - Active assembly: `cmd/simplusd/main.go:213-218` -- **Call chain:** `cmd/simplusd` calls `notification.New(stores, secretKeyring)` → `New` constructs a concrete redirect-blocking `*http.Client` → `Service.Notify/Test` selects a stored Webhook channel → `deliverWebhook` decrypts provider material, constructs provider-specific JSON/signature and an HTTP request, calls `Client.Do`, interprets the provider response, and persists delivery state. The Feishu flow instead demonstrates the intended boundary: application state machine consumes injected `FeishuRegistrar` and `FeishuMessenger` ports, while `FeishuClient` is the concrete adapter assembled in `cmd/simplusd`. -- **Violated rule:** an application use case should express delivery intent through its smallest consumer-owned typed port and must not construct its lower transport implementation. This active path combines channel business state, provider wire format, HTTP transport and persistence outcome in one service. -- **Impact:** Webhook provider/HTTP behavior cannot be replaced independently from notification business logic; retries/failure mapping/provider parsing are coupled to the store-owning service, and adding another delivery implementation encourages more transport branches in the use case. -- **Minimum remediation:** introduce an application-owned typed Webhook delivery port carrying bounded provider/destination/secret/message inputs and a typed result. Move request/signature/response handling into a concrete adapter selected in `cmd/simplusd`; keep delivery-state transitions in the service. Do not generalize it into arbitrary HTTP. -- **Safe validation:** retain current synthetic `httptest` cases at the adapter boundary and add service tests with a fake typed deliverer for success, rejection and network failure. No external endpoint is required. - -## Architecture concerns - -### C-01 — HTTP boundary still stores four adjacent application services as concrete pointers - -- **Class/severity:** Architecture concern / Low. -- **Anchors:** `internal/api/httpapi/server.go:163-185,254-288`; concrete method calls include `:436,669-926,1226,2844,3031`. -- **Call chain:** `cmd/simplusd:285-304` injects health/setup/inventory/realtime implementations → `httpapi.Server` stores `*health.Service`, `*setup.Service`, `*inventory.Service` and `*realtime.Hub` directly, while its other dependencies use consumer-owned interfaces. -- **Rule/impact:** this does not bypass application behavior or touch storage/hardware directly, but it is inconsistent with the consumer-owned port rule and makes the HTTP package compile against complete concrete services. -- **Minimum remediation:** add narrow HTTP-owned interfaces for the methods actually used, including a realtime subscription/publish-facing view as appropriate. Do not create a shared catch-all interface. -- **Safe validation:** compile-time fake implementations plus existing `httptest` router tests. - -### C-02 — Dormant resource-lease application contract exports SQLite-owned types - -- **Class/severity:** Architecture concern / Low. -- **Anchors:** `internal/application/resourcelease/service.go:12-15,42-51,69-152`. -- **Call chain:** application service defines a `Repository`, but its signatures, request validation, return values and constants use `storage/sqlite.ResourceLease*` directly. Repository-wide search found no non-test caller of `resourcelease`; current production binaries do not activate it. -- **Rule/impact:** this is concrete persistence vocabulary in an application contract, but absence of an active call chain prevents a confirmed runtime-bypass verdict. Future activation would make storage representation the application source of truth. -- **Minimum remediation:** before reactivation, move lease command/record/kind types to `application/resourcelease` or a protocol-neutral domain package and map them in SQLite. If the documented simplification makes the package obsolete, remove it in a separately approved task. -- **Safe validation:** package tests with an interface fake and SQLite adapter mapping tests; no HIL. - -## Allowed exceptions and traced clean candidates - -### AE-01 — Web sensitive read uses the generated boundary without caching - -`web/src/pages/Modems.tsx:140-148` directly calls the generated equipment-identity SDK rather than a generated mutation helper. It does not call Fetch, build a path string or define a payload; generated validation/transport remains authoritative and the sensitive value stays in controlled component state. This is the documented no-cache behavior, not a layer bypass. - -### AE-02 — Raw browser network primitives have exactly two owners - -A production-only scan excluding generated/tests found only `globalThis.fetch` at `web/src/api/runtime.ts:55` and `new EventSource` at `web/src/app/RealtimeBridge.tsx:48`. Pages/components contain neither. Realtime events are decoded by `web/src/api/events.ts:20-34` and only invalidate active HTTP queries. - -### AE-03 — Application typed Agent/supervisor adapters are narrow and fail closed - -`inventory.AgentSource`, modem RF/identity adapters, SMS gateways, Line's VoWiFi phone source and the VoWiFi service import typed protocol packages, but callers pass stable IDs/typed intents. Messaging chooses exactly one eligible transport and returns ambiguity/unavailability (`internal/application/messaging/service.go:583-603`); it does not fall back between native and Host VoWiFi. VoWiFi constructs only `LineID`, opaque current hardware Line ID, typed egress mode and country (`internal/application/vowifi/service.go:319-337`). - -Peer-application imports (`inventory.Topology`, Mihomo status and Line-egress views) are used behind consumer-defined interfaces. They are compile-time coupling but do not select a lower implementation or bypass behavior, so they are not hard findings in this audit. - -### AE-04 — Mihomo artifact HTTP/filesystem/exec operations and bounded supervisor paths are explicitly owned - -`internal/application/mihomo/core.go:39-61,79-248` downloads, verifies, probes with fixed `-v`, and atomically installs an official candidate. `config.go:58-70,82-147,313-319` owns immutable artifact creation and validation with fixed `-t/-f/-d`. `subscriptions.go:64-77,287-339,520-547` owns the bounded, SSRF-filtered subscription fetch. ADR 0005 decision 3 explicitly defines official-release download, staged verification, fixed version probing and atomic activation as the Mihomo-management use case; ADR 0007 decisions 1-5 likewise make immutable artifact construction, current-core validation and publication part of subscription state transitions. On that accepted, narrow contract, these managers co-locate the concrete artifact adapter with its application owner. Inputs are constrained official release metadata or validated subscription URLs, not caller-selected executables, arguments or artifact paths, and long-running process ownership remains the supervisor. This is an explicit current architecture exception, not a general precedent for unrelated application services. It differs from V-04 because `mihomosupervisor.Local` is a separately owned runtime/process implementation whose selection is not part of those artifact decisions and should occur in the composition root. - -The internal Mihomo `StartRequest` contains binary/config paths (`internal/mihomosupervisor/types.go:16-20`), but `local.go:156-177` accepts only exact installed-version and immutable generated-config shapes under the fixed root, with no arguments/shell input. This is the explicit ADR 0008 exception, not an arbitrary path surface. V-04 concerns where the concrete supervisor is constructed, not these fixed operations. - -### AE-05 — Hardware discovery, registry and transport divide responsibilities correctly - -- `hardwareprobe.Scanner` reads sysfs and builds endpoint observations (`scanner.go:68-111,294-365`). -- The registry owns model matches, endpoint roles and unique dynamic USB IDs (`modemadapter/registry.go:160-251`). -- `hardwareprobe.atRuntime` opens a registry-selected endpoint and delegates a `Query`; it obtains the command plan/capabilities from the adapter (`at_runtime.go:25-84`). -- `attransport.Query` is a compiled-in adapter seam (`attransport/transport.go:18-29`); production command constants were found in `modemadapter/**`/model-owned drivers, not generic transport or upper control layers. -- `cmd/simplus-agent`'s fixed `register-option-driver` executable helper derives every ID from the registry and rejects any target other than the compiled-in container mount (`main.go:290-345`). This executable-only hardware-runtime helper is allowed; V-01 is the separate shell path that bypasses it. - -### AE-06 — netd owns process/network/IMS behavior behind bounded protocols - -Mihomo and VoWiFi handlers expose fixed routes with bounded JSON (`internal/mihomosupervisor/http.go`, `internal/vowifisupervisor/http.go`). VoWiFi public supervisor start accepts only Line ID, opaque hardware Line ID, egress enum and country (`types.go:49-57`); executable paths, runtime directories, link addresses, namespace/interface names, routing and SIP material are derived inside netd/private worker composition. - -Despite its historical name, `internal/vowifihil` is partly a production runtime owner: `internal/vowifisupervisor/worker.go:21,85-175` imports it for the active netd worker. The production path calls model-independent `InspectHostVoWiFiLine` (`internal/vowifihil/preflight.go:59-143`), builds a fixed private strongSwan configuration whose Agent sockets and plugin set are constants (`config.go:16-25,83-172`), loads a fixed VICI connection (`vici.go:13-23,65-95,145-184`), and owns bounded SIP/XFRM behavior (`session.go`, `sms_session.go`, `xfrm.go:35-47,156-265`). The model-specific `InspectML307AVOXI` wrapper at `preflight.go:21-57` is called by historical HIL tools, not the production worker. Process/network hits in these packages, `cmd/simplus-netd`, and `containers/netd-preflight.sh` therefore match the documented netd owner. - -### AE-07 — Compose/container privilege and shell boundaries are fixed - -`compose.yaml:20-178` keeps app, Agent and netd separated; Agent has no network and only the reviewed device/sysfs mounts, app has no capabilities, and netd alone receives the network capability set on a bridge. `containers/agent-entrypoint.sh:9-59` validates mounts/GID, calls the registry-backed driver command and drops to UID 10002. `containers/netd-entrypoint.sh:9-24` uses fixed arguments; `netd-preflight.sh:24-64` creates PID-named disposable fixed test objects and cleans them. No shell `eval`, caller-supplied command or host-network path was found. - -### AE-08 — Storage, generated output and integration-test assembly - -SQLite/filesystem packages import domain/OS primitives but no HTTP/application behavior. Generated ownership is registered at `Makefile:25,67-92`. Tests in messaging/calls/notification/mihomo/eUICC/httpapi intentionally assemble temporary real SQLite; these are integration-test dependencies, not production imports. - -### AE-09 — Product-owned strongSwan C bridge is a fixed typed Agent client - -`components/strongswan-simplus-simaka` is production source, not third-party or HIL-only output. Its `simaka_card_t` implementation accepts only a 3G AKA quintuplet call and delegates to `simplus_simaka_agent_authenticate` (`simplus_simaka_card.c:63-120`). The client validates the generated target fence, sends one fixed `POST /v1/sim/aka/authenticate` with bounded RAND/AUTN and correlation fields, uses bounded AF_UNIX I/O, validates the typed result, and clears sensitive buffers (`simplus_simaka_agent.c:115-128,256-345,348-517`). The additional listener only inserts fixed `fqdn:ims` for the first `vowifi-ims` IKE_AUTH request (`simplus_simaka_apn.c:18-54`). It contains no AT/APDU text, model selector, generic Agent route or shell execution. - -The plugin reads its socket/fence settings at `simplus_simaka_plugin.c:89-151`, but the production generator fixes the Agent socket to `/run/simplus-agent/sim-aka.sock` and derives the other private values from the typed Agent inspection (`internal/vowifihil/config.go:16-25,83-172`); no Web, supervisor request or operator config supplies that path. ADR 0020 decisions 1 and 7 explicitly retain this thin external-Go-Agent architecture. `packaging/strongswan-plugins/build-deb.sh:218-277,317-333` builds the product plugin and separately builds upstream `p-cscf` from locked Debian source; the upstream implementation is not copied into or claimed as product-owned source. - -## Test, tool and generated-only observations - -### TO-01 — HIL-only process/network/protocol code - -The six `cmd/simplus-vowifi-hil-*` mains and `scripts/dev/run-ml307a-vowifi-hil.sh` are documented HIL tools and were not executed. Two historical entry paths, `cmd/simplus-vowifi-hil-ims/main.go:71` and `cmd/simplus-vowifi-hil-prepare/main.go:26`, call `internal/vowifihil.InspectML307AVOXI`; the other HIL mains have their own narrower helper roles. The rest of that shared package is also used by the production netd worker and is covered under AE-06; it is not excluded wholesale. The HIL entry points remain non-product evidence and do not authorize live execution. - -### TO-02 — Synthetic AT/device fixtures - -AT strings and `/dev/fixture-*` paths occur extensively in adapter/transport/hardwareprobe tests. They call injected functions or synthetic sessions and prove fixed commands/parsers. No test helper was found wired into a production Web/API or Unix route as a generic command runner. - -### TO-03 — Dormant fixed radio ledger - -`internal/agentapi/command_service.go` and `outcome_store.go` retain the fixed `radio.ensure-off` ledger. `cmd/simplus-agent` constructs `NewManagedHardwareHandler` without a `CommandService` (`cmd/simplus-agent/main.go:197-208`), and no production caller constructs `NewCommandService` or `OpenOutcomeStore`. This matches `docs/architecture.md:258`; it is dormant historical infrastructure, not an active generic command route. - -Generated OpenAPI/sqlc/Web files were not judged as handwritten owners. Their sources, generated registry and callers were checked. Ignored `.umi*` and `web/dist` trees are build output, not runtime source owners. - -## False-positive rationale for high-risk scans - -| Candidate | Why it is not a finding | -| --- | --- | -| `AT/QMI` in `internal/application/messaging/service.go:125-127` | A prohibition comment; command text is not constructed or passed. | -| `QMI/APDU`, VID/PID and interface vocabulary in `internal/domain/hardware/**` | Passive normalized capability/validation vocabulary. No I/O or model command selection exists in domain imports. | -| Relative USB address and VID:PID in OpenAPI/Web | Explicitly permitted read-only candidate metadata (`docs/architecture.md:74,97`); mutation sends only opaque `candidateId`. Absolute sysfs and device nodes are rejected/omitted. | -| Agent `Endpoint.Node`, interface number and probe endpoint observations | Output-only internal Agent discovery evidence. `application/inventory/agent_source.go:52-170` maps capabilities and relative display metadata and does not use endpoint nodes/interfaces for business behavior; no public OpenAPI mapper exposes them. | -| `os/exec`, filesystem and HTTP in Mihomo application | Fixed official artifact probe/config-validation policy assigned by ADRs; arbitrary binaries/args are not accepted, and runtime process ownership is supervisor-bound. V-04 separately records the concrete-construction leak. | -| Network/process/sysfs keywords in netd/container entrypoints | Located in the designated Agent/netd runtime owners and use fixed, validated inputs. The native duplicated ID writer is the sole confirmed exception (V-01). | -| AF_UNIX, socket path and RAND/AUTN in the product C plugin | The path is fixed by the private netd-generated configuration, the route and challenge shape are compiled in, and responses are fenced/correlated. No public or supervisor request supplies a command, path, model, AT or APDU payload (AE-09). | -| Non-adjacent imports from `cmd/**` | Composition roots necessarily construct multiple layers. Only behavior beyond construction/lifecycle is V-02. | - -## Explicit clean-layer assertions - -- No `internal/api/httpapi` production import of storage, Agent, hardwareprobe, modemadapter, attransport or supervisor packages was found. -- No lower owner under domain, storage, Agent, hardwareprobe, modemadapter, attransport or the supervisor packages imports upward into `internal/api/httpapi` or `internal/application`. This assertion is about lower-to-upper dependency direction; reverse application imports are separately classified as typed-client use (AE-03), confirmed construction leaks (V-03/V-04), or the dormant SQLite-type concern (C-02). -- Public OpenAPI contains no command, AT/QMI, device-path, interface-number, shell-command, network-command or vendor-payload request field. RF is a boolean typed operation; Modem/Line mutations use stable IDs. -- Raw Fetch/EventSource ownership is exactly the two documented Web owners; pages do not construct either. -- Production AT/APDU command strings are model-adapter/model-driver owned. Generic AT transport has no model command constants. -- Discovery selects adapter/endpoints but obtains command plans from capability interfaces; generic transport owns framing/I/O only. -- Storage/filesystem adapters do not call upward into application/HTTP or own modem/network protocol behavior. -- Active typed Agent/Mihomo/VoWiFi handlers expose fixed operations and bounded bodies; no generic command runner route exists. -- The production strongSwan plugin emits only the fixed, fenced SIM-AKA request and fixed IMS APN hook; it exposes no AT/APDU/model/command surface and does not obtain its socket path from public or supervisor input. -- Hardware-backend assembly does not silently fall back to Simulator; messaging rejects zero, multiple or unavailable transports. - -## Prioritized remediation - -1. **P0 / V-01:** eliminate the native duplicate VID/PID writer and route every production driver binding through the adapter registry; preserve fail-closed write errors. -2. **P1 / V-02:** move background SMS/Agent event semantics out of `cmd/simplusd` into application coordinators. -3. **P1 / V-03:** make setup's filesystem/crypto/certificate dependencies explicit composition-root inputs. -4. **P1 / V-04:** construct local or socket Mihomo supervisors only in `cmd/simplusd` and inject one required typed API. -5. **P1 / V-05:** separate Webhook transport/provider protocol from notification use-case and persistence policy through a typed delivery port. -6. **P2 / C-01:** finish HTTP consumer-owned ports for health/setup/inventory/realtime. -7. **P2 / C-02:** before resourcelease is reactivated, remove SQLite-owned types from its application contract or retire the dormant package in a separate task. - -These should be separate scoped implementation tasks. None requires HIL to establish the architectural repair; V-01's final real deployment behavior may later require separately authorized validation after its static/unit contract is corrected. - -## Static-analysis limitations and residual risk - -- Import and lexical analysis cannot prove behavior loaded through reflection, plugins, environment-dependent function injection or code generated only under unreviewed build tags. Linux and portable source files were text-scanned, while `go list` reflects the current Linux toolchain/package set. -- A fixed typed request can still be misused by an incorrect runtime implementation. This audit followed constructors and current concrete calls but did not dynamically instrument them. -- External provider, Mihomo, kernel, USB and network behavior was not contacted. “Clean” means no statically proven bypass in reviewed source, not proof of every runtime state. -- Generated and third-party internals were not re-audited; trust rests on checked sources/generator registry and locked provenance contracts. -- Ignored build output was not treated as source. A deployment made from stale/untracked artifacts could differ from this commit. -- No private runtime configuration was inspected, so environment-specific socket/path/capability drift remains outside this report. -- Line anchors are current for the recorded commit and will move after repairs. - -## Validation record - -Safe evidence commands used: - -```text -git rev-parse HEAD -git status --short -GOTOOLCHAIN=local GOPROXY=off go list -f '{{.ImportPath}}|{{join .Imports ","}}|{{join .TestImports ","}}|{{join .XTestImports ","}}' ./cmd/... ./internal/... -git ls-files / rg --files inventories for cmd, internal, web/src, components, containers, scripts/release, production plugin build helpers and packaging -targeted rg scans for raw Fetch/EventSource, imports, AT/QMI/APDU, tty/dev/sysfs/USB/interface/VID/PID, process/shell/network primitives, public/Unix payload fields and generated ownership -bash scripts/dev/test-simplus-simaka-c.sh -``` - -Final results: - -| Check | Result | -| --- | --- | -| `GOTOOLCHAIN=local GOPROXY=off go list ... ./cmd/... ./internal/...` | Pass; production/test import inventory produced without dependency download. | -| `GOTOOLCHAIN=local GOPROXY=off go vet ./cmd/... ./internal/...` | Pass. | -| `GOTOOLCHAIN=local GOPROXY=off make lint` | Partial: the `go vet` stage passed; locked `actionlint` could not start because its module was not cached and network/module lookup was deliberately disabled. No dependency was downloaded. | -| `GOTOOLCHAIN=local GOPROXY=off go test ./cmd/... ./internal/...` | Pass; all Go unit/integration packages, including compile/unit-only HIL command packages, ran without hardware or external runtime actions. | -| `COREPACK_ENABLE_NETWORK=0 corepack pnpm --dir web typecheck` | Pass (`tsc --noEmit`). | -| `COREPACK_ENABLE_NETWORK=0 corepack pnpm --dir web test` | Pass; 19 files / 70 tests. jsdom emitted only its documented non-fatal pseudo-element `getComputedStyle` notices. | -| `bash scripts/dev/test-simplus-simaka-c.sh` | Pass; dependency-free protocol/parser test compiled in a temporary directory and accessed no Agent socket or hardware. | -| `make check-container-files` plus syntax checks for the remaining release scripts | Pass. Only parsers were run; no script action was executed. | -| `GOTOOLCHAIN=local GOPROXY=off go test ./internal/containercontract` | Pass. | -| Focused raw Fetch/EventSource, upward import, lower-layer HTTP import and OpenAPI command/path-field assertions | Pass with only the two documented browser owners. | -| `git diff --check`, untracked report whitespace check and `git status --short -uall` scope assertion | Pass; every reported worktree path is under `.trellis/tasks/08-14-audit-layer-boundaries/**`. | diff --git a/.trellis/tasks/archive/2026-08/08-14-audit-layer-boundaries/check.jsonl b/.trellis/tasks/archive/2026-08/08-14-audit-layer-boundaries/check.jsonl deleted file mode 100644 index 9a54b91..0000000 --- a/.trellis/tasks/archive/2026-08/08-14-audit-layer-boundaries/check.jsonl +++ /dev/null @@ -1,10 +0,0 @@ -{"file":".trellis/spec/core/backend/directory-structure.md","reason":"Independently verify package ownership, composition-root treatment, generated exclusions and concrete implementation leaks."} -{"file":".trellis/spec/core/backend/application-boundaries.md","reason":"Challenge every reported application, Line, Agent and model boundary conclusion against the executable typed-port contract."} -{"file":".trellis/spec/core/backend/api-contracts.md","reason":"Verify public and Unix protocol surfaces contain only bounded typed operations and no arbitrary command or path seam."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Apply source-backed evidence standards, false-positive checks and safe validation rules without turning hardware into a test dependency."} -{"file":".trellis/spec/core/infra/hardware-and-hil-safety.md","reason":"Verify modem/device protocol ownership, approval boundaries, privacy constraints and absence of prohibited runtime actions."} -{"file":".trellis/spec/core/infra/build-and-generated-files.md","reason":"Verify the product-owned strongSwan C component, locked upstream input, plugin build helpers and Debian packaging boundary."} -{"file":".trellis/spec/web/frontend/directory-structure.md","reason":"Verify every Web page/runtime boundary and direct Fetch/EventSource classification."} -{"file":".trellis/spec/web/frontend/type-safety.md","reason":"Verify generated contract ownership and absence of duplicated/cast payload boundaries in handwritten Web code."} -{"file":".trellis/spec/web/frontend/quality-guidelines.md","reason":"Use the prescribed architectural regression searches and treat search results as supporting evidence rather than verdicts."} -{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Check that the final report traces complete flows, validation owners and neighbor boundaries rather than relying on directory names."} diff --git a/.trellis/tasks/archive/2026-08/08-14-audit-layer-boundaries/design.md b/.trellis/tasks/archive/2026-08/08-14-audit-layer-boundaries/design.md deleted file mode 100644 index 75c77ca..0000000 --- a/.trellis/tasks/archive/2026-08/08-14-audit-layer-boundaries/design.md +++ /dev/null @@ -1,84 +0,0 @@ -# Design: Layer-boundary audit - -## 1. Audit model - -This is a static, evidence-backed architecture audit. Directory distance is a candidate signal, not the verdict. A hard violation requires a production behavior path that bypasses the next typed boundary, leaks a concrete lower-layer implementation into an upper-layer decision, or performs device/protocol work outside its owner. - -The report uses four result classes: - -1. **Confirmed violation** — a production call chain crosses a disallowed behavior boundary. -2. **Architecture concern** — a concrete type/import or responsibility leak increases coupling but does not itself prove a runtime bypass. -3. **Allowed exception** — composition, passive shared types, generated ownership, OS/runtime ownership, or deliberate integration-test assembly matches a documented rule. -4. **False positive / non-product evidence** — lexical hits, generated output, fixtures, archived code references, or comments do not create a product behavior path. - -Severity is based on impact: - -- **Critical**: Web/API or another external boundary exposes arbitrary modem, device, shell, network, AT/QMI/APDU, or path control. -- **High**: production code outside the reviewed hardware/runtime owner directly selects or executes modem/device protocol behavior. -- **Medium**: a production layer bypasses its consumer-owned port, constructs a lower concrete implementation outside a composition root, or moves model/path decisions upward. -- **Low**: compile-time concrete-type leakage or responsibility coupling without a demonstrated behavior bypass. - -## 2. Runtime paths and allowed edges - -The audit treats the repository as several typed paths rather than one artificial linear stack. - -| Caller/owner | Allowed next behavior boundary | Notes | -| --- | --- | --- | -| Web pages/components | generated Query/SDK helpers and narrow handwritten API helpers | Pages must not own raw Fetch/EventSource, payload contracts, device paths, model commands, or backend state. | -| Browser API runtime | authenticated public HTTP/SSE contract | Raw Fetch and EventSource are allowed only in their documented boundary owners. | -| `internal/api/httpapi` | consumer-owned application service interfaces | OpenAPI/domain types may be referenced for mapping; handlers must not call storage, Agent implementations, supervisors, or device protocols directly. | -| `internal/application/**` | consumer-owned repository/service ports, stable domain types, typed Agent/supervisor clients | Application code may express business intent but must not select models, endpoints, commands, device paths, SQL implementations, or fallback transports. | -| Persistence adapters | SQLite/sqlc/filesystem primitives owned by the adapter | They implement application-facing contracts and do not call upward into HTTP/application behavior. | -| Typed Agent/supervisor clients and servers | fixed protocol requests/handlers | Wire boundaries may validate and translate typed operations, but may not expose arbitrary command/path payloads. | -| Hardware discovery/runtime | registry-selected capability interfaces | Discovery may inspect bounded sysfs/device metadata and resolve endpoints; it must not invent model commands or expose runtime targets upward. | -| Model adapters and model-owned business drivers | bounded protocol transport | Only these owners select fixed AT/QMI/APDU/vendor semantics and parse model responses. | -| Generic protocol transports | OS/device I/O | Transport owns framing, limits, timeouts and lifecycle; it must not select model commands or business behavior. | -| netd runtime workers | bounded process/network primitives | Only netd-owned workers may construct the reviewed temporary network/SIP runtime from typed stable inputs. | -| strongSwan SIM-AKA C bridge | fixed Agent SIM-AKA Unix request and fixed IMS APN hook | The product plugin may translate strongSwan's typed AKA call to the reviewed fenced Agent route; it must not own AT/APDU/model selection or accept a public/runtime-selected command or socket path. | - -`cmd/**` files are composition roots. They may import several layers to construct the object graph, but the audit will still flag business policy, model branching, raw user-provided protocol parameters, or lower-layer behavior implemented there instead of merely wired there. - -`internal/domain/**` is treated as passive shared business vocabulary, not a callable runtime layer. HTTP or storage references to domain records are not automatically layer skips; behavior implemented in domain packages must remain protocol- and persistence-neutral. - -## 3. Evidence collection - -The primary audit combines independent evidence sources: - -- enumerate hand-written production Go, Web, product-owned C and production shell/build source separately from tests, generated output, vendored/upstream source, tool caches, build artifacts, task history and private data paths; -- generate production and test Go import graphs with `go list`, then group edges by architectural owner; -- enumerate TypeScript imports and search pages/components for raw network, duplicated payload, device/model and protocol use; -- search for concrete lower-layer imports and constructors in HTTP/application/domain packages; -- search for AT/QMI/APDU/vendor command strings, tty/serial/termios, `/dev`, sysfs, USB/interface/VID/PID, shell/process/network primitives and generic command surfaces; -- inspect OpenAPI plus typed Unix request types/routes for raw command, device path or arbitrary vendor payload exposure; -- trace every candidate from caller through interfaces, constructor wiring and concrete implementation until the behavior owner is proven; -- compare the proven path to the allowed-edge table and accepted architecture/ADR contracts. - -Lexical scans only create candidates. Every confirmed finding requires current `file:line` anchors and a summarized call chain. - -## 4. Coverage and report contract - -The final `.trellis/tasks/08-14-audit-layer-boundaries/audit.md` must contain: - -1. executive conclusion and finding counts by class/severity; -2. scope, exclusions and safety statement; -3. the final allowed-edge matrix; -4. a coverage matrix with every runtime owner—including the product-owned strongSwan SIM-AKA C bridge and Compose/container/release/packaging owners—its inspected source/package set, actual outbound behavior boundary, candidate count and conclusion; -5. confirmed violations ordered by severity; -6. architecture concerns and allowed exceptions, including composition roots and integration tests; -7. test/tool-only observations; -8. false-positive rationale where a high-risk keyword was dismissed; -9. static-analysis limitations and prioritized, minimal remediation recommendations. - -Each confirmed violation or concern records: ID, severity/class, caller layer, target layer, source anchors, call chain, violated rule, impact, minimum remediation direction and suggested non-HIL verification. - -## 5. Safety, compatibility and rollback - -- No product code, spec, generated output, configuration or dependency is changed by the audit. -- Do not run deployment, Compose startup, host preparation, hardware probe, HIL runner, real SMS/call, RF, SIM/eUICC, network mutation or arbitrary device command. -- Do not inspect `data/**`, private runtime stores, device nodes, raw logs, captures or screenshots. Do not quote identities, endpoints, topology or raw protocol evidence into the report. -- Safe analysis is limited to repository source inspection, import metadata, textual/AST-like searches and non-executing repository metadata commands. -- Rollback is deletion or revision of task-local planning/report artifacts only; no application state exists to roll back. - -## 6. Independent review - -The primary audit should be performed by a Trellis research/check agent constrained to read-only product inspection and task-local evidence writing. A separate Trellis check pass must verify that every layer is represented, every finding is call-chain-backed, every allowed exception is justified, no product file was modified, and no prohibited hardware/private action occurred. diff --git a/.trellis/tasks/archive/2026-08/08-14-audit-layer-boundaries/implement.jsonl b/.trellis/tasks/archive/2026-08/08-14-audit-layer-boundaries/implement.jsonl deleted file mode 100644 index 1efab7f..0000000 --- a/.trellis/tasks/archive/2026-08/08-14-audit-layer-boundaries/implement.jsonl +++ /dev/null @@ -1,9 +0,0 @@ -{"file":".trellis/spec/core/backend/directory-structure.md","reason":"Defines composition-root exceptions, package ownership, generated boundaries and the prohibition on concrete lower-layer implementations in application services."} -{"file":".trellis/spec/core/backend/application-boundaries.md","reason":"Defines consumer-owned typed ports, stable business identity, Agent/model isolation and the expected application-to-hardware dependency direction."} -{"file":".trellis/spec/core/backend/api-contracts.md","reason":"Defines OpenAPI ownership, bounded HTTP and Unix protocol surfaces, fixed typed operations and forbidden arbitrary command/path payloads."} -{"file":".trellis/spec/core/infra/hardware-and-hil-safety.md","reason":"Defines the typed device chain, allowed hardware/runtime owners, no-debug-escape-hatch rule and the no-HIL safety boundary for this audit."} -{"file":".trellis/spec/core/infra/build-and-generated-files.md","reason":"Defines the separately licensed product C component, locked strongSwan inputs, build-helper ownership and packaging verification contract."} -{"file":".trellis/spec/web/frontend/index.md","reason":"Defines the browser-to-generated-client-to-HTTP architecture and the rule that Web code exposes only typed business APIs."} -{"file":".trellis/spec/web/frontend/directory-structure.md","reason":"Defines ownership of pages, API runtime, generated clients and the prohibition on direct Fetch/EventSource calls from pages."} -{"file":".trellis/spec/web/frontend/type-safety.md","reason":"Defines generated payload ownership, runtime validation and the prohibition on duplicated or cast network contracts."} -{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Provides the neighbor-only contract and full-flow tracing checklist used to distinguish leaks from valid boundary conversions."} diff --git a/.trellis/tasks/archive/2026-08/08-14-audit-layer-boundaries/implement.md b/.trellis/tasks/archive/2026-08/08-14-audit-layer-boundaries/implement.md deleted file mode 100644 index 9ed081c..0000000 --- a/.trellis/tasks/archive/2026-08/08-14-audit-layer-boundaries/implement.md +++ /dev/null @@ -1,105 +0,0 @@ -# Audit Execution Plan - -## Phase A — Freeze scope and build the dependency baseline - -- [x] Record the current commit and clean/product-file baseline without reading private runtime data. -- [x] Inventory hand-written production Go, Web, product-owned C and production shell/build source; classify tests, generated/build output, HIL/development tools, locked upstream source, third-party/tool caches and task history separately. -- [x] Build production and test Go import graphs independently and a hand-written Web import/network-ownership inventory. -- [x] Materialize the allowed behavior-edge matrix from `design.md` into the working audit report. - -## Phase B — Audit runtime layer behavior - -- [x] Review Web pages/components → generated client/runtime → public HTTP and prove raw Fetch/EventSource/payload ownership is confined to allowed boundaries. -- [x] Review public HTTP → application services and identify any direct storage, Agent/supervisor implementation, process, network or hardware behavior. -- [x] Review every `internal/application/**` package for consumer-owned ports, concrete storage/Agent leakage, cross-application coupling, model/path branching and fallback behavior. -- [x] Review storage/filesystem adapters for upward calls or business/protocol ownership, while treating generated sqlc as output. -- [x] Review typed Agent, Mihomo and VoWiFi client/server protocols plus the strongSwan SIM-AKA C bridge for bounded operations, stable inputs and absence of arbitrary command/path payloads. -- [x] Review `cmd/simplusd`, `cmd/simplus-agent` and `cmd/simplus-netd` as composition roots, distinguishing dependency construction from business or protocol implementation. - -## Phase C — Audit modem and privileged runtime boundaries - -- [x] Search Go/Web/C and production shell/build source for AT/QMI/APDU/vendor strings, tty/termios/serial I/O, `/dev`, sysfs, USB/interface/VID/PID, Unix/socket, shell/process and network primitives. -- [x] Trace every hit outside `internal/modemadapter/**`, model-owned drivers, `internal/attransport`, `internal/hardwareprobe`, and netd-owned workers to determine whether it is allowed runtime ownership or a real bypass. -- [x] Verify discovery/registry code selects adapters and endpoints but does not manufacture commands; verify generic transports frame I/O but do not choose model semantics. -- [x] Inspect OpenAPI and typed Unix route/request definitions for arbitrary command, device path, interface, topology or vendor-payload exposure. -- [x] Scan tests and development/HIL tools separately for reusable escape hatches or architecture drift, without executing them or treating deliberate fixtures as production behavior. - -## Phase D — Produce the evidence-backed report - -- [x] Create `audit.md` with executive summary, scope, allowed-edge matrix and one coverage row for every runtime owner. -- [x] For each candidate, record current `file:line` anchors and a summarized call chain, then classify it as confirmed violation, architecture concern, allowed exception, test/tool-only observation or false positive. -- [x] Give confirmed issues a severity, impact, minimum remediation direction and safe suggested validation; do not implement repairs. -- [x] Record clean layers explicitly and state static-analysis limits and residual risk. -- [x] Redact or omit any identity, private endpoint/topology, raw HIL/protocol/log material or data-path content encountered incidentally. - -## Phase E — Independent verification - -- [x] Dispatch a separate Trellis check pass in report-only mode; prohibit product/spec/generated edits and all hardware/runtime actions. -- [x] Verify all production packages and Web runtime owners map to exactly one coverage row, all candidate classes are mutually clear, and every confirmed claim has a valid source anchor plus call chain. -- [x] Re-run safe import/search assertions needed to challenge the report, including direct Fetch/EventSource ownership and modem/device keyword confinement. -- [x] Run `git diff --check` and confirm changed files are confined to `.trellis/tasks/08-14-audit-layer-boundaries/**`. -- [x] Incorporate only task-report corrections, then present the final audit result and any proposed follow-up tasks to the user. - -## Phase F — Close the remediation program - -- [x] Verify all seven approved child tasks are completed and archived, and - map every V-01–V-05 / C-01–C-02 finding to its functional commit. -- [x] Add a dated remediation-closure section to `audit.md` while preserving - the original baseline, evidence classes and historical source anchors. -- [x] Run cumulative source-only checks for all repaired boundaries plus the - supported Go, lint, generated, documentation and container-contract gates. -- [x] Complete an independent final Trellis check that the closure table and - current source agree and no audit finding remains open. -- [x] Commit the closure record, archive the parent task and record the session - journal. - -### Phase F validation evidence — 2026-08-18 - -- All seven child task records are completed under - `.trellis/tasks/archive/2026-08/**`; their listed functional commits are - ancestors of the current `HEAD` and match the closure table. -- Focused current-source assertions passed for registry-backed driver - registration, application-owned background coordinators, explicit Setup and - Mihomo dependencies, the bounded legacy-Webhook port, HTTP-owned adjacent - service ports, and absence of ResourceLease vocabulary from application - source. -- The retained SQLite ResourceGroup lease repository, focused test and - `00005_resource_group_leases.sql` have identical SHA-256 values at the audit - baseline, immediately before C-02 and in the current worktree. No migration - or protected-file diff exists across the remediation program. -- `make check-format`, `go test -count=1 ./cmd/... ./internal/...`, `go vet - ./cmd/... ./internal/...`, `make lint`, `make verify-generated`, `make - check-docs`, `make check-container-files`, `go test -count=1 - ./internal/containercontract`, Web typecheck, task validation and `git diff - --check` passed without external access. -- During independent review, a stricter `GOPROXY=off make lint` repetition - reached and passed `go vet`, then its pinned `go run ...@v1.7.12` attempted a - cached-module metadata lookup. The exact cached Actionlint v1.7.12 binary - passed all workflow files, so the lint contract was still independently - completed without network access. -- No service, Compose, provider endpoint, private runtime state, hardware, - modem, RF, SMS/call, SIM/eUICC, network mutation or HIL action was used. -- The closure record was committed as `dce8356`; parent-task archival and the - session journal are completed by the Trellis finish-work flow. - -## Validation commands - -Only safe, non-HIL variants are permitted. The exact search expressions may be refined during execution, but the command classes are: - -```bash -go list -f '{{.ImportPath}}|{{join .Imports ","}}|{{join .TestImports ","}}|{{join .XTestImports ","}}' ./cmd/... ./internal/... -rg --files cmd internal web/src components containers scripts/release packaging -rg -n '<boundary candidate patterns>' cmd internal web/src components containers scripts/release packaging api/openapi.yaml -git diff --check -git status --short -``` - -Do not run hardware/deployment Make targets, Compose, HIL commands, device discovery, real communications, RF/SIM/eUICC mutations or commands that read private runtime data. - -## Risk and rollback points - -- Keyword matches are expected to have a meaningful false-positive rate; no candidate is a finding until the call chain is inspected. -- Composition roots and passive domain types can look like skipped layers in an import graph; classify them through responsibility and behavior. -- Reflection, function injection, build tags and generated wrappers can hide the concrete call target; record any unresolved target as a limitation rather than asserting compliance. -- If an analysis command would touch hardware, network services, private stores or host configuration, stop and replace it with source inspection. -- Since only task-local artifacts are written, rollback is limited to correcting or removing those artifacts. Product code must remain untouched. diff --git a/.trellis/tasks/archive/2026-08/08-14-audit-layer-boundaries/prd.md b/.trellis/tasks/archive/2026-08/08-14-audit-layer-boundaries/prd.md deleted file mode 100644 index d516d4f..0000000 --- a/.trellis/tasks/archive/2026-08/08-14-audit-layer-boundaries/prd.md +++ /dev/null @@ -1,51 +0,0 @@ -# Audit layer boundaries - -## Goal - -对 Simplus 代码库进行一次静态架构审查,确认生产运行时各层只通过允许的下一层公开、类型化接口协作,识别跨层调用、边界绕过和非硬件适配层直接操作模组的代码,并形成可复核的证据清单与整改建议。 - -## Repository Evidence - -- `docs/architecture.md:90-100` 将硬件控制依赖固定为 `Web/API -> application/Line -> typed service port -> Agent capability -> model adapter -> device protocol`,并规定型号、VID/PID、interface、sysfs、`/dev`、AT/QMI 只能在发现、registry、adapter 或运行时硬件边界参与实现选择。 -- `docs/architecture.md:334-346` 规定 Web/API 不得提交任意 AT/QMI/设备路径、netd 独占底层网络对象,并要求每个控制层只依赖下一级类型化能力。 -- `.trellis/spec/core/backend/directory-structure.md:8-19` 将 `cmd/**` 定义为依赖装配根,允许其同时构造 API、application、storage、Agent/supervisor client 与硬件实现;装配根的多层 import 因而不能仅凭路径距离判为运行时越层。 -- `.trellis/spec/core/backend/directory-structure.md:69-80` 禁止 application service 在 consumer-owned port 足够时依赖具体 SQLite/Agent 实现,也禁止 domain 持有 SQL、HTTP 映射、模组命令或文件系统路径。 -- 初步 Go import 图显示 HTTP 层同时引用 application、OpenAPI 和用于响应映射的 domain 类型;application 层主要引用 domain 与 typed Agent/supervisor contracts,但已有需在正式审查中核实的具体依赖候选:`internal/application/resourcelease/service.go:14,46-50` 暴露 SQLite-owned 类型,`internal/application/setup/service.go:27,84,233-235` 装配具体 filesystem/secret 默认实现。 -- Web 手写页面使用生成的 Query/SDK 契约,原始 HTTP/SSE 机制集中在 `web/src/api/runtime.ts:55` 与 `web/src/app/RealtimeBridge.tsx:48` 等边界。`web/src/pages/Modems.tsx:140-148` 直接使用生成 SDK 完成不进入 mutation cache 的敏感读取,是必须通过责任和调用链而非关键词判断的代表候选。 -- 初步关键词扫描把底层 AT 命令定位在 `internal/modemadapter/**`,把 sysfs/设备节点访问定位在 `cmd/simplus-agent/main.go` 与 `internal/hardwareprobe/**`;这只是候选分布,尚不能证明不存在别名、封装或间接越层。 -- 现有规范有意允许部分跨包集成测试使用临时真实 SQLite;测试依赖需与生产依赖分开定性。 -- `internal/api/openapi/generated.go`、`internal/storage/sqlite/generated/**`、`web/src/api/generated/**` 和 `web/dist/**` 是生成或构建输出,不是手写责任边界。 - -## Scope and Judgment - -- 硬性合规范围是产品自有的手写生产 Go、TypeScript/React、strongSwan SIM-AKA C 组件与生产入口/构建脚本,包括进程装配、Web、公开 HTTP、application、domain、storage/filesystem、typed Unix client/server、Agent/netd、hardware discovery、model adapter/driver、protocol transport、Compose/container/release/packaging 边界和产品 C 插件;锁定输入中的上游 `p-cscf` 实现仍按第三方源码处理。 -- 测试与开发/HIL 工具作为次级范围单列结论;生成输出只审查来源、调用方式与公开契约;第三方依赖、`.tools/**`、构建产物和私有运行数据不做内部实现审查。 -- 非相邻 import、共享 domain 类型和具体类型泄漏只产生候选。只有调用链证明绕过下一层类型化能力、在非装配层构造具体实现、直接访问更底层存储/协议,或让上层根据型号、端点、路径、命令等实现细节分支时,才判为运行时越层违规。 -- `cmd/**` 装配根、OS-specific runtime、生成边界、共享被动 domain 类型和有意的集成测试不会自动视为违规,但仍检查其中是否混入业务策略、底层参数上浮或任意命令入口。 -- 本任务只生成任务内审查报告,不修改产品代码、规格、生成输出、配置或依赖,也不执行部署、硬件探测、真实通信、RF/SIM/eUICC 变更、HIL-1/HIL-2 或任何具有设备/网络副作用的操作。 - -## Requirements - -- R1. 依据规范和实际装配代码建立可审计的层级、包职责与允许行为边清单,而不是仅凭目录名推断分层。 -- R2. 按上述范围逐层检查实际依赖和行为调用,覆盖所有产品运行时 owner;没有发现问题的层也必须留下可复核记录。 -- R3. 专项检查 AT/QMI/APDU、串口/tty、`/dev`、sysfs、USB/interface、VID/PID、shell/process、底层网络参数和任意 vendor payload 是否只存在于其合法 owner,且未通过 Web/API 或 typed Unix 边界暴露。 -- R4. 每个候选必须经过接口、构造和具体调用链核实;结论区分已确认违规、架构气味/具体类型泄漏、允许例外、测试/工具发现和误报。 -- R5. 每项已确认违规或架构气味记录严重度、当前 `file:line`、调用方层、目标层、调用链、违反的规则、影响、最小整改方向及不依赖 HIL 的建议验证方式。 -- R6. 输出覆盖矩阵,列出每个运行时 owner 的允许边、实际出站行为、候选数量和结论。 -- R7. 在 `.trellis/tasks/08-14-audit-layer-boundaries/audit.md` 交付执行摘要、允许边矩阵、覆盖矩阵、分类发现、静态审查限制、剩余风险和优先整改建议。 - -## Acceptance Criteria - -- [x] AC1. 分层与允许行为边模型同时有规范和实际装配代码证据,并覆盖全部产品运行时 owner。 -- [x] AC2. 每个产品层均有审查状态,其直接行为依赖已与允许边比对;无发现的层也出现在覆盖矩阵中。 -- [x] AC3. 所有跨层或直接模组操作候选均完成调用链复核,并以当前 `file:line` 证据归入一个明确类别。 -- [x] AC4. 每个已确认问题均有严重度、风险、最小整改方向和安全验证建议,且审查没有自动实施修复。 -- [x] AC5. `audit.md` 明确静态审查限制与剩余风险,不把未发现静态引用等同于运行时绝对不存在违规。 -- [x] AC6. 审查只改动本任务目录,未读取私有运行数据,也未触发部署、真实 SMS/通话、RF、SIM/eUICC、模组持久写入、HIL-1/HIL-2 或任意直接硬件/网络操作。 -- [x] AC7. 报告将生产违规、架构气味/具体类型泄漏、允许例外、测试/工具发现和误报分栏记录,读者无需重新执行扫描即可理解每项结论的证据链。 - -## Out of Scope - -- 自动修复发现的问题,或因审查结果更新产品规范、公开文档和架构决策。 -- 动态插桩、运行时流量观测、部署环境探测、真实硬件验证或私有证据采集。 -- 对第三方依赖、生成文件内部实现、缓存、构建产物和私有数据做架构合规审查;只有产品代码如何调用这些边界属于范围。 diff --git a/.trellis/tasks/archive/2026-08/08-14-audit-layer-boundaries/task.json b/.trellis/tasks/archive/2026-08/08-14-audit-layer-boundaries/task.json deleted file mode 100644 index 6cc7d7b..0000000 --- a/.trellis/tasks/archive/2026-08/08-14-audit-layer-boundaries/task.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "id": "audit-layer-boundaries", - "name": "audit-layer-boundaries", - "title": "Audit layer boundaries", - "description": "Static layer-boundary audit and coordinated closure of all five violations and two architecture concerns.", - "status": "completed", - "dev_type": null, - "scope": null, - "package": "core", - "priority": "P2", - "creator": "Leon", - "assignee": "Leon", - "createdAt": "2026-08-14", - "completedAt": "2026-08-18", - "branch": null, - "base_branch": "main", - "worktree_path": null, - "commit": null, - "pr_url": null, - "subtasks": [], - "children": [ - "08-14-retire-native-deployment", - "08-14-move-background-policy-to-application", - "08-14-make-setup-dependencies-explicit", - "08-15-inject-mihomo-supervisor-explicitly", - "08-15-extract-notification-delivery-ports", - "08-16-narrow-http-application-service-ports", - "08-17-resolve-resourcelease-storage-coupling" - ], - "parent": null, - "relatedFiles": [], - "notes": "", - "meta": {} -} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-08/08-14-make-setup-dependencies-explicit/check.jsonl b/.trellis/tasks/archive/2026-08/08-14-make-setup-dependencies-explicit/check.jsonl deleted file mode 100644 index bde81d8..0000000 --- a/.trellis/tasks/archive/2026-08/08-14-make-setup-dependencies-explicit/check.jsonl +++ /dev/null @@ -1,7 +0,0 @@ -{"file":".trellis/spec/core/backend/index.md","reason":"Check the completed multi-package Go change against the backend ownership and full quality gate."} -{"file":".trellis/spec/core/backend/directory-structure.md","reason":"Verify application Setup owns only ports/policy and cmd/simplusd owns all production concrete assembly."} -{"file":".trellis/spec/core/backend/application-boundaries.md","reason":"Verify explicit dependency validation, application-owned result types and fail-closed optional capabilities."} -{"file":".trellis/spec/core/backend/storage-and-migrations.md","reason":"Verify no storage/schema/path/secret persistence behavior drift or new database opening path was introduced."} -{"file":".trellis/spec/core/backend/api-contracts.md","reason":"Verify Setup endpoint/state/error/privacy behavior remains unchanged at HTTP and generated boundaries."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Check constructor, composition, failure, integration, race and privacy-safe synthetic coverage."} -{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Trace the final dependency graph end to end and ensure V-03 was removed rather than relocated."} diff --git a/.trellis/tasks/archive/2026-08/08-14-make-setup-dependencies-explicit/design.md b/.trellis/tasks/archive/2026-08/08-14-make-setup-dependencies-explicit/design.md deleted file mode 100644 index d47b179..0000000 --- a/.trellis/tasks/archive/2026-08/08-14-make-setup-dependencies-explicit/design.md +++ /dev/null @@ -1,102 +0,0 @@ -# Design: Make Setup dependencies explicit - -## 1. Boundary and compatibility decision - -`internal/application/setup` remains the owner of the Setup state machine, validation, side-effect ordering and domain-facing configuration. It stops selecting concrete password, filesystem, keyring and certificate implementations. `cmd/simplusd` becomes the production composition owner for those adapters. - -This is an internal constructor refactor, not a Setup redesign. The existing OpenAPI, HTTP handlers, Web flow, SQLite stores, encryption labels, paths, password parameters, certificate generation and setup transitions remain unchanged. - -The constructor must support two evidenced shapes without hidden discovery: - -1. full production/integration assembly, with every persistence and security capability supplied explicitly; -2. explicit reduced test assembly, commonly StateStore-only for unrelated ready-instance HTTP routing tests. - -Nil optional fields mean the named capability is unavailable. The constructor never inspects another dependency's dynamic type to discover it. - -## 2. Application dependency contract - -Replace the positional constructor with an application-owned structure shaped as follows (final field names may be tightened without changing the contract): - -```go -type Dependencies struct { - StateStore StateStore - AuthorizationStore AuthorizationStore - AdministratorStore AdministratorStore - PasswordHasher PasswordHasher - StorageStore StorageStore - DirectoryPreparer DirectoryPreparer - ManagementTLSStore ManagementTLSStore - SecretProtectorOpener SecretProtectorOpener - LocalCAGenerator LocalCAGenerator - HardwareReviewStore HardwareReviewStore - CompletionStore CompletionStore - - Random io.Reader - Now func() time.Time -} - -func New(Dependencies) (*Service, error) -``` - -`StateStore` is always required. Optional implementation pairs are all-or-none: - -- `AdministratorStore` + `PasswordHasher`; -- `StorageStore` + `DirectoryPreparer`; -- `SecretProtectorOpener` + `LocalCAGenerator`. - -The Local CA pair requires `ManagementTLSStore`. Invalid construction wraps one stable `ErrDependenciesInvalid` sentinel with a bounded missing/inconsistent role description. Other optional fields remain independently explicit; their methods retain existing unavailable errors. Add a nil-Administrator guard to `BeginAdministratorSetup` so a deliberately reduced dependency set fails closed rather than dereferencing an absent capability. - -`Random` and `Now` are deterministic seams, not lower-layer business capabilities. Nil values select the existing `crypto/rand.Reader` and `time.Now` defaults. Bootstrap/session durations remain internal constants at ten/thirty minutes. - -## 3. Application-owned boundary values - -Replace lower concrete values with the exact data Setup consumes: - -```go -type DirectoryIdentity struct { - Path string - Device uint64 - Inode uint64 -} -type DirectoryPreparer func(string) (DirectoryIdentity, error) - -type LocalCABundle struct { - CACertificatePEM []byte - CAPrivateKeyPEM []byte - LeafCertificatePEM []byte - LeafPrivateKeyPEM []byte - RootFingerprint string - LeafNotAfter time.Time - SANs []string -} -type LocalCAGenerator func(time.Time, []string) (LocalCABundle, error) -``` - -These shapes retain every currently consumed field and no concrete adapter metadata. Setup continues encrypting the two private keys with the same labels, clearing plaintext key buffers, persisting the same `managementtls.Configuration`, and using the same directory identity range/preflight checks. - -## 4. Production composition - -Add a focused helper in the `cmd/simplusd` package so `main.go` remains readable while composition remains executable-owned. Its inputs are the concrete SQLite Set and the fixed instance secret-key path. It builds `setup.Dependencies` by: - -- assigning the same SQLite Set separately to every persistence role; -- selecting `password.NewDefaultHasher()`; -- wrapping `storagefs.PreparePrivateDirectory` and copying Path/Device/Inode into `setup.DirectoryIdentity`; -- supplying a fixed closure that calls `secretbox.Open(instanceSecretKeyPath)`; -- wrapping `managementcert.GenerateLocalCA` and copying every consumed bundle field into `setup.LocalCABundle`. - -`main.go` derives `instanceSecretKeyPath` once from the configured database root, calls the Setup assembly helper and handles its construction error before continuing. The existing general `secretbox.Open` for notification/instance secrets uses exactly the same variable. No environment/request/caller supplies this path. - -A command-package test constructs a temporary SQLite Set, invokes the production helper with a synthetic absolute temp path and proves the full dependency set is accepted. Concrete adapter packages retain their own behavior tests; Setup application tests use fakes. - -## 5. Caller and test migration - -- Setup same-package tests build `Dependencies` with deterministic clock/random, fake password hasher, fake directory identity, fake secret protector and synthetic Local CA bundle. A mutable clock variable replaces later writes to `service.now`. -- `internal/control` bootstrap tests explicitly supply the persistence roles they exercise instead of relying on SQLite dynamic type assertions. -- `internal/api/httpapi` test helpers provide either StateStore-only dependencies for ready/unready gate fixtures or the full dependency set for Setup integration tests. Test-only composition may use temp filesystem/security adapters, but application production code remains concrete-free. -- All constructor errors are handled at the call site; no `MustNew`, panic, service locator or default concrete application constructor is added. - -## 6. Validation and rollback - -Targeted validation covers Setup, `cmd/simplusd`, control bootstrap and HTTP API under race detection, followed by the supported `./cmd/... ./internal/...` test/vet scope, formatting/lint, dependency scans and task validation. Generated/OpenAPI verification is a no-diff assertion because their sources are not changed. - -No real service, host, private data, network, hardware or HIL action is needed. The change has no data migration. Rollback is the constructor/type/caller diff as one unit; do not retain a compatibility constructor that reintroduces hidden concrete defaults. diff --git a/.trellis/tasks/archive/2026-08/08-14-make-setup-dependencies-explicit/implement.jsonl b/.trellis/tasks/archive/2026-08/08-14-make-setup-dependencies-explicit/implement.jsonl deleted file mode 100644 index 052bb2f..0000000 --- a/.trellis/tasks/archive/2026-08/08-14-make-setup-dependencies-explicit/implement.jsonl +++ /dev/null @@ -1,7 +0,0 @@ -{"file":".trellis/spec/core/backend/index.md","reason":"Load the backend ownership map, pre-development checklist and validation entry points for Setup/cmd/storage changes."} -{"file":".trellis/spec/core/backend/directory-structure.md","reason":"Keep concrete adapter selection in cmd/simplusd and consumer-owned ports in internal/application/setup."} -{"file":".trellis/spec/core/backend/application-boundaries.md","reason":"Apply explicit narrow dependency ports, stable application values and fail-closed constructor behavior."} -{"file":".trellis/spec/core/backend/storage-and-migrations.md","reason":"Preserve SQLite Set ownership, directory security and sensitive key/password persistence contracts without migration."} -{"file":".trellis/spec/core/backend/api-contracts.md","reason":"Preserve the active Setup HTTP state machine, stable errors and secret/privacy boundaries while changing only assembly."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Use deterministic fakes/temp storage, co-located tests and targeted-to-broad safe validation."} -{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Trace cmd composition through Setup ports to concrete filesystem/security adapters and ensure the bypass is removed."} diff --git a/.trellis/tasks/archive/2026-08/08-14-make-setup-dependencies-explicit/implement.md b/.trellis/tasks/archive/2026-08/08-14-make-setup-dependencies-explicit/implement.md deleted file mode 100644 index 99ee329..0000000 --- a/.trellis/tasks/archive/2026-08/08-14-make-setup-dependencies-explicit/implement.md +++ /dev/null @@ -1,43 +0,0 @@ -# Implementation plan: Make Setup dependencies explicit - -## Phase 1 — Application boundary - -- [x] Add application-owned dependency, directory identity and Local CA bundle types plus stable constructor validation. -- [x] Replace dynamic StateStore capability assertions with direct dependency assignment and preserve random/clock defaults. -- [x] Remove concrete security/storage imports and make reduced optional capabilities fail closed without nil dereference. - -## Phase 2 — Production composition - -- [x] Add focused `cmd/simplusd` Setup assembly adapters for password, directory identity, secretbox and Local CA translation. -- [x] Derive the fixed instance secret path once, inject every SQLite role explicitly, handle constructor failure and reuse the unchanged path for the existing keyring. -- [x] Add a synthetic temp-directory composition test proving the production dependency set is complete. - -## Phase 3 — Caller and test migration - -- [x] Refactor Setup tests to inject clock/random/fakes through `Dependencies` and remove all direct private Service-field assignments. -- [x] Update control bootstrap tests with explicit exercised persistence capabilities. -- [x] Update HTTP tests with explicit State-only and full-Setup helpers while preserving existing integration assertions. - -## Phase 4 — Validation and review - -- [x] Run formatting plus targeted race tests for Setup, `cmd/simplusd`, control and HTTP API. -- [x] Run focused import/type-assertion/private-field/key-path scans, supported full Go tests/vet/lint components, task validation and `git diff --check` without runtime side effects. -- [x] Dispatch an independent Trellis check for dependency completeness, optional-shape failure behavior, concrete-type removal and observable Setup compatibility; incorporate only in-scope fixes. -- [x] In Phase 3.3, capture the explicit Setup dependency/adapter contract in backend specs if the final implementation establishes reusable guidance not already present. - -### Independent check evidence (2026-08-15) - -- No product or test defect was found; no reviewer code fix was required. -- Uncached race tests passed for `internal/application/setup`, `cmd/simplusd`, `internal/control` and `internal/api/httpapi`. -- Uncached supported Go tests passed for `./cmd/... ./internal/...`; Go vet, the locally cached Actionlint binary, Go formatting and Web TypeScript type-check all passed. -- Task validation, `git diff --check`, constructor-caller/import/type-assertion/private-field/key-path scans and API/generated/schema/Web scope scans passed; an offline same-version OpenAPI/sqlc/Web regeneration preserved the complete worktree content manifest byte-for-byte. -- Validation used synthetic temporary storage only. No service/Compose, external endpoint, private-data, hardware/HIL, communication, RF/SIM or network action was performed. - -## Risk and rollback points - -- Do not accidentally change the fixed secret-key path while deduplicating its construction. -- Directory and Local CA adapter mapping must copy every field consumed by Setup; private key slices must still be cleared after encryption. -- Status-only HTTP fixtures must remain explicit and valid, while production assembly must never omit a capability. -- Constructor validation must reject incomplete adapter pairs without forbidding the evidenced State-only fixture shape. -- No compatibility overload may keep runtime type assertions or concrete application defaults alive. -- No migration exists. If a phase regresses Setup behavior, revert the boundary and all callers together rather than weakening a test or API contract. diff --git a/.trellis/tasks/archive/2026-08/08-14-make-setup-dependencies-explicit/prd.md b/.trellis/tasks/archive/2026-08/08-14-make-setup-dependencies-explicit/prd.md deleted file mode 100644 index 7c482ef..0000000 --- a/.trellis/tasks/archive/2026-08/08-14-make-setup-dependencies-explicit/prd.md +++ /dev/null @@ -1,47 +0,0 @@ -# Make Setup dependencies explicit - -## Goal - -Resolve layer-audit finding V-03 by making every Setup application dependency explicit, moving concrete filesystem/password/secret/certificate assembly to `cmd/simplusd`, and replacing lower-layer result types at the application seam without changing the active Setup product flow. - -## Background - -- The layer audit classified V-03 as a confirmed Medium violation. Current anchors are `internal/application/setup/service.go:23-27,58-97,218-248,503-547,586-605,711-717` and production assembly at `cmd/simplusd/main.go:69-77`. -- `setup.New(stateStore, authorization)` currently type-asserts `stateStore` into Administrator, Storage, Management TLS, Hardware Review and Completion roles, then constructs the default password hasher, concrete filesystem preparer, concrete secretbox path/opener and concrete Local CA generator inside the application package. -- `DirectoryPreparer` returns `storage/filesystem.DirectoryIdentity` and `LocalCAGenerator` returns `managementcert.Bundle`, so concrete adapter vocabulary also crosses the application boundary. -- Setup is active through the public OpenAPI/HTTP/Web bootstrap and first-run flow. ADR 0001 retains existing Setup/Auth behavior even though further expansion is not an MVP priority; deleting or redesigning the product flow is not this repair. -- Current production supplies one SQLite `Set` implementing all persistence roles. Tests also use intentionally minimal state-only services for unrelated ready-instance HTTP gates; those reduced capabilities must remain explicit and deterministic rather than inferred from a dynamic store type. - -## Requirements - -- R1. Remove production imports of `internal/security/managementcert`, `internal/security/password`, `internal/security/secretbox` and `internal/storage/filesystem` from `internal/application/setup`. -- R2. Replace `setup.New(stateStore, authorization)` with an explicit application-owned `Dependencies` input. `StateStore` is mandatory; Authorization, Administrator, Storage, Management TLS, Hardware Review and Completion roles are named fields rather than runtime type assertions. -- R3. Keep reduced test/service capability shapes explicit. A nil optional capability remains unavailable through the existing method-level configuration errors, while incomplete implementation pairs—Administrator without PasswordHasher, Storage without DirectoryPreparer, or SecretProtectorOpener without LocalCAGenerator—are rejected at construction with a stable configuration error. Local-CA adapters must not be accepted without a Management TLS store. -- R4. Define application-owned `DirectoryIdentity` and `LocalCABundle` values containing only the fields Setup consumes. `DirectoryPreparer` and `LocalCAGenerator` use those types; concrete filesystem and certificate bundles must not leak into the application contract. -- R5. Keep `SecretProtector`, password hashing, directory preparation and Local CA generation behind the smallest Setup-owned ports. Preserve all current labels, key clearing, SAN/fingerprint/certificate fields, directory identity checks and durable integer bounds. -- R6. Move concrete production selection to the `cmd/simplusd` composition root, preferably in a focused executable helper: pass the SQLite set explicitly for each persistence role, construct the default Argon2id hasher, translate `storage/filesystem` identity, open `secretbox` at the same fixed instance key path, and translate `managementcert.GenerateLocalCA` output into the application bundle. -- R7. Derive the instance secret-key path once in `cmd/simplusd` and use the same path for Setup protection and the existing notification/instance keyring. Preserve startup failure behavior and do not create a caller-selected path surface. -- R8. Make construction failure explicit to callers and handle it as a startup/configuration failure. Optional deterministic `io.Reader` and clock inputs may be supplied through `Dependencies`; production defaults remain `crypto/rand.Reader` and `time.Now`, with unchanged 10-minute bootstrap and 30-minute session lifetimes. -- R9. Update every constructor caller. Full Setup integration tests receive explicit fakes/adapters; status-only HTTP fixtures receive an explicit StateStore-only dependency set. Setup application tests must no longer mutate private Service fields to replace clock, randomness, password, filesystem, secret or certificate behavior. -- R10. Add constructor validation tests and a `cmd/simplusd` composition test proving the production dependency set is complete. Preserve existing Setup service, control bootstrap and HTTP integration coverage using synthetic/temp-directory inputs only. -- R11. Do not change OpenAPI, generated clients, Setup endpoints, cookies, state machine, SQLite schemas/data, password parameters, secretbox format/path, certificate contents/lifetimes, directory security checks, setup completion behavior or Web UI. -- R12. Do not start services/Compose, inspect private state, contact a network endpoint, access hardware/HIL, send SMS/place calls, or mutate RF/SIM/eUICC/network state during validation. - -## Acceptance Criteria - -- [x] AC1. `internal/application/setup` imports no concrete security/storage adapter package, performs no dynamic store capability assertion, and exposes only application-owned dependency/result types. -- [x] AC2. `Dependencies` explicitly represents every Service field required by active Setup behavior; mandatory and paired dependency validation returns a stable error and valid reduced capability shapes remain intentional. -- [x] AC3. `cmd/simplusd` is the sole production owner of password/filesystem/secretbox/Local-CA implementation selection and uses the unchanged fixed instance secret-key path for both current consumers. -- [x] AC4. Setup storage identity, HTTPS Local CA field/label/order and format behavior, bootstrap/session timing, hardware review and completion behavior remain compatible at their existing observable boundaries. -- [x] AC5. All production/test constructor callers compile; Setup tests inject deterministic dependencies without assigning private Service fields, and focused tests cover missing/partial dependencies plus full production assembly. -- [x] AC6. No API/generated/storage migration/container/Web or unrelated audit-finding change is introduced. -- [x] AC7. Formatting, targeted race tests, supported full Go tests/vet/lint components, task validation, focused dependency scans and `git diff --check` pass without service, external or hardware side effects. -- [x] AC8. An independent Trellis check confirms V-03 is removed rather than relocated into HTTP, persistence or another application package. - -## Out of Scope - -- Removing or simplifying the active Setup/OpenAPI/Web flow, Local CA mode, hardware review or legacy Setup tables. -- Changing cryptographic algorithms, parameters, key/certificate formats, secret labels, lifetimes or filesystem security policy. -- Refactoring `httpapi.Server`'s concrete Setup pointer (audit concern C-01) or other application constructors (V-04/V-05). -- Adding a generic dependency container, service locator, adapter registry or shared catch-all setup package. -- Deployment, HIL, real private-data or external-network validation. diff --git a/.trellis/tasks/archive/2026-08/08-14-make-setup-dependencies-explicit/task.json b/.trellis/tasks/archive/2026-08/08-14-make-setup-dependencies-explicit/task.json deleted file mode 100644 index 2d8ce87..0000000 --- a/.trellis/tasks/archive/2026-08/08-14-make-setup-dependencies-explicit/task.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "id": "make-setup-dependencies-explicit", - "name": "make-setup-dependencies-explicit", - "title": "Make Setup dependencies explicit", - "description": "Resolve audit V-03 by injecting explicit Setup persistence/security/filesystem dependencies from cmd/simplusd through application-owned types without changing Setup behavior.", - "status": "completed", - "dev_type": null, - "scope": null, - "package": "core", - "priority": "P1", - "creator": "Leon", - "assignee": "Leon", - "createdAt": "2026-08-14", - "completedAt": "2026-08-15", - "branch": null, - "base_branch": "main", - "worktree_path": null, - "commit": null, - "pr_url": null, - "subtasks": [], - "children": [], - "parent": "08-14-audit-layer-boundaries", - "relatedFiles": [], - "notes": "", - "meta": {} -} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-08/08-14-move-background-policy-to-application/check.jsonl b/.trellis/tasks/archive/2026-08/08-14-move-background-policy-to-application/check.jsonl deleted file mode 100644 index 32b5397..0000000 --- a/.trellis/tasks/archive/2026-08/08-14-move-background-policy-to-application/check.jsonl +++ /dev/null @@ -1,6 +0,0 @@ -{"file":".trellis/spec/core/backend/index.md","reason":"Check the completed Go change against the backend ownership map and full quality gate."} -{"file":".trellis/spec/core/backend/directory-structure.md","reason":"Verify cmd/simplusd contains construction, lifecycle and logging only after the extraction."} -{"file":".trellis/spec/core/backend/application-boundaries.md","reason":"Verify all new dependencies are mandatory, narrow, consumer-owned ports with no concrete lower-layer construction."} -{"file":".trellis/spec/core/backend/api-contracts.md","reason":"Verify exact durable-change topics, SMS attention and privacy-bounded SSE semantics are unchanged."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Check deterministic co-located tests cover failure, partial progress, retry, cancellation and concurrency-sensitive behavior."} -{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Trace both coordinator call chains end to end and ensure V-02 was removed rather than relocated."} diff --git a/.trellis/tasks/archive/2026-08/08-14-move-background-policy-to-application/design.md b/.trellis/tasks/archive/2026-08/08-14-move-background-policy-to-application/design.md deleted file mode 100644 index c2a80fa..0000000 --- a/.trellis/tasks/archive/2026-08/08-14-move-background-policy-to-application/design.md +++ /dev/null @@ -1,97 +0,0 @@ -# Design: Move background policy into application coordinators - -## 1. Boundary decision - -The repair uses two owner-specific coordinators rather than a shared background framework: - -- `internal/application/messaging` owns interpretation of `InboundSyncResult`, notification intent and message-related realtime publication. -- `internal/application/inventory` owns interpretation of typed Agent snapshot/change envelopes and inventory-related realtime invalidation. -- `cmd/simplusd` constructs both coordinators, supplies concrete services/Hub, starts their goroutines, supplies configured intervals and renders operational logs. - -This keeps dependencies next to their consumers and avoids turning a local extraction into a generic scheduler or event bus. Both application packages may name their own small publisher interface; that deliberate interface duplication preserves consumer ownership while the existing `*realtime.Hub` implements both structurally. - -## 2. SMS synchronization coordinator - -Add an owner-specific coordinator file under `internal/application/messaging/`. Its intended contract is: - -```go -type InboundSyncer interface { - SyncInbound(context.Context) (InboundSyncResult, error) -} - -type NotificationSender interface { - Notify(context.Context, string, string) error -} - -type RealtimePublisher interface { - Publish([]realtime.Topic, realtime.Attention) -} -``` - -The coordinator constructor requires all three ports and returns an explicit configuration error for a missing dependency. A typed cycle report/callback carries only the synchronization result plus synchronization/notification errors needed for operational logging. The application package does not import `log/slog`, know the process logger or decide an exit code. - -One cycle preserves this order: - -1. create a 20-second synchronization context from the run context; -2. call `SyncInbound` and retain both partial result and error; -3. classify a durable message-state change from exactly `Persisted`, `AlreadyKnown`, `OutboundSent`, `OutboundFailed`, and `OutboundUnconfirmed`; -4. publish `TopicMessages`, adding `AttentionSMSReceived` only when `Persisted > 0`; -5. when `Persisted > 0`, deliver event `sms.received` with `[Simplus] 收到 %d 条新短信` under a 15-second `context.WithoutCancel` deadline, then publish `TopicNotifications` regardless of delivery success; -6. expose the typed cycle report to the composition-root logger; -7. use only the `SyncInbound` error to select normal interval versus retry delay. - -`Acknowledged` and `OutboundReportsAcknowledged` remain operational counters but do not independently cause publication. Notification failure remains observable but never causes an SMS re-poll/retry. - -`Run` performs a cycle immediately, then waits. Intervals below one second normalize to two seconds. Failed synchronization begins at `max(15s, interval*4)`, doubles, caps at five minutes and resets after a successful synchronization. Cancellation must stop a pending wait promptly. Small unexported cycle/classification/delay seams may be used for deterministic same-package tests; no production clock abstraction is required unless it is the smallest way to eliminate real waits. - -## 3. Agent change coordinator - -Add an owner-specific coordinator file under `internal/application/inventory/`. It defines a separate consumer-owned source rather than widening the existing inventory `AgentClient`, whose current consumers need only Snapshot/Probe: - -```go -type AgentChangeSource interface { - Snapshot(context.Context, bool) (agentapi.Snapshot, error) - Changes(context.Context, string, uint64, int) (agentapi.ChangeResponse, error) -} - -type AgentChangePublisher interface { - Publish([]realtime.Topic, realtime.Attention) -} -``` - -The constructor requires both ports. `Run` accepts a typed failure callback that distinguishes snapshot initialization from long-poll failure so `cmd/simplusd` can preserve its existing log messages without importing `slog` into application code. - -The state machine remains unchanged: - -1. obtain `Snapshot(ctx, false)`; -2. after a reconnect, publish Inventory/Modems/Lines together when instance ID or generation differs from the prior successful snapshot; -3. call `Changes` with current instance ID/generation and a 25-second bounded wait; -4. update the current/previous snapshot and reset retry to one second after every successful response; -5. publish the same three topics when `Changed` is true; -6. on source error, wait using one-second exponential retry capped at 30 seconds; -7. exit promptly when the context is cancelled. - -The coordinator does not probe hardware, interpret devices, change the Agent protocol or expose snapshot data to SSE. - -## 4. Composition-root change - -After the concrete messaging, notification and realtime services exist, `cmd/simplusd` constructs the SMS coordinator and treats an impossible missing-dependency configuration as startup failure. It starts only `go coordinator.Run(ctx, configuredInterval, reportCallback)`. - -When a hardware Agent client exists, the root similarly constructs and starts the Agent change coordinator. Simulator assembly remains without an Agent watcher. Report callbacks preserve current warning/info content; construction, logger selection, goroutine ownership and shutdown context remain executable responsibilities. - -Delete `runSMSSync`, `publishSMSSyncResult`, the command-local Agent source interface, `runAgentChanges`, both retry helpers and the shared wait helper from `cmd/simplusd/main.go`. Remove their policy tests from `cmd/simplusd/main_test.go` after equivalent owner-package tests exist. - -## 5. Compatibility and safety - -- No OpenAPI, generated source, HTTP/SSE schema, SQLite migration/query, Agent protocol, configuration key or container file changes. -- Publication remains advisory metadata only; no message, identity or Agent snapshot is added to SSE. -- No notification endpoint, service, Compose stack or hardware is contacted during validation. -- The refactor is rollback-safe as one source change: restoring command-local functions and their tests returns the prior ownership without data migration. - -## 6. Verification design - -Messaging fake-port tests assert call order and exact outputs for no change, acknowledgement-only, persisted inbound, outbound-only, partial result plus sync error and notification error. Pure delay tests prove initial delay, interval influence, doubling and cap; a cancelled context proves prompt exit. - -Inventory fake-port tests assert explicit change, instance restart, generation change, unchanged reconnect, long-poll failure classification, retry reset/cap and cancellation. Test data remains synthetic. - -The composition check proves `cmd/simplusd` only constructs/runs coordinators and retains operational log callbacks. Focused scans ensure no V-02 helper remains in `cmd/**` and neither application coordinator imports concrete implementation packages or `log/slog`. diff --git a/.trellis/tasks/archive/2026-08/08-14-move-background-policy-to-application/implement.jsonl b/.trellis/tasks/archive/2026-08/08-14-move-background-policy-to-application/implement.jsonl deleted file mode 100644 index 07ef514..0000000 --- a/.trellis/tasks/archive/2026-08/08-14-move-background-policy-to-application/implement.jsonl +++ /dev/null @@ -1,6 +0,0 @@ -{"file":".trellis/spec/core/backend/index.md","reason":"Load the backend ownership map, pre-development checklist and validation entry points for the affected Go packages."} -{"file":".trellis/spec/core/backend/directory-structure.md","reason":"Place business policy in application packages while keeping cmd/simplusd limited to composition and lifecycle."} -{"file":".trellis/spec/core/backend/application-boundaries.md","reason":"Define the two coordinators through the smallest consumer-owned typed ports and preserve side-effect ordering."} -{"file":".trellis/spec/core/backend/api-contracts.md","reason":"Preserve realtime topic/attention privacy and the required application/background publication tests."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Use co-located deterministic fake-port tests, cancellation coverage and the targeted-to-broad validation ladder."} -{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Check the complete cmd to application to typed Agent/realtime data flow instead of moving the skip elsewhere."} diff --git a/.trellis/tasks/archive/2026-08/08-14-move-background-policy-to-application/implement.md b/.trellis/tasks/archive/2026-08/08-14-move-background-policy-to-application/implement.md deleted file mode 100644 index a2f34cb..0000000 --- a/.trellis/tasks/archive/2026-08/08-14-move-background-policy-to-application/implement.md +++ /dev/null @@ -1,42 +0,0 @@ -# Implementation plan: Move background policy into application coordinators - -## Phase 1 — Messaging coordinator - -- [x] Add the consumer-owned inbound sync, notification and realtime publisher ports beside `internal/application/messaging`. -- [x] Implement required-dependency construction, one-cycle interpretation/publication/notification ordering, typed operational reporting and the existing immediate-run/interval/retry loop. -- [x] Add same-package fake-port tests for durable-change classification, attention, notification conditions/failure, partial progress with synchronization error, exact ordering, backoff and cancellation. - -## Phase 2 — Inventory Agent-change coordinator - -- [x] Add a narrow Agent change source and realtime publisher beside `internal/application/inventory` without widening the existing Snapshot/Probe `AgentClient`. -- [x] Implement snapshot/reconnect/change topic mapping, typed failure reporting, retry reset/backoff and cancellation with unchanged watch parameters. -- [x] Add deterministic fake-source tests for changed, restarted, generation-changed, unchanged, error, retry-cap/reset and cancellation paths. - -## Phase 3 — Composition root - -- [x] Construct both coordinators in `cmd/simplusd`, preserve current startup/backend conditions and translate their typed reports into the existing operational log messages. -- [x] Delete command-local SMS/Agent policy functions, ports and delay helpers only after equivalent application tests pass. -- [x] Remove migrated policy tests from `cmd/simplusd/main_test.go`; retain unrelated executable-helper and hardware-feature tests. - -## Phase 4 — Validation and review - -- [x] Run formatting and targeted tests for `internal/application/messaging`, `internal/application/inventory` and `cmd/simplusd`. -- [x] Run focused dependency/ownership scans, `go vet`/locked lint components available offline, the relevant broader Go suite, `git diff --check` and Trellis task validation. -- [x] Dispatch an independent Trellis check for behavior preservation, cancellation/concurrency, port ownership and V-02 removal; incorporate only in-scope corrections. -- [x] In Phase 3.3, synchronize backend specs with the implemented coordinator contract if the final code establishes reusable guidance not already explicit. - -## Independent check evidence - -- Reviewed the new coordinators against the pre-extraction `cmd/simplusd` implementations and confirmed partial-result publication, attention classification, notification ordering/isolation, timeout, retry and cancellation behavior are preserved. -- Confirmed Agent snapshot/reconnect/generation state, 25-second watch arguments, three-topic mapping, retry reset/cap and cancellation are preserved. -- Confirmed `cmd/simplusd` now owns construction, goroutine lifecycle and operational log rendering only; the application packages own narrow ports and do not import `log/slog` or concrete notification/realtime/storage implementations. -- Passed `make check-format`, `make lint`, targeted tests and race tests, `go test -count=1 ./cmd/... ./internal/...`, Web Vitest/typecheck, the worktree-manifest regression, `git diff --check` plus untracked-file whitespace inspection, and `task.py validate`. -- The raw `go test ./...` traversal could not enter the local `data/agent` runtime directory due filesystem permissions; the repository-supported Go package scope above passed. `make test` was not run because its simulator-supervisor step starts a service, which this task explicitly forbids. - -## Risk and rollback points - -- The highest-risk behavior is partial progress with a non-nil synchronization error: durable state must still publish/notify, while the next cycle must use retry delay. -- Notification failure must remain isolated from synchronization retry; `TopicNotifications` still publishes after the attempt. -- Reconnect comparison must retain the previous successful Agent snapshot until the next snapshot is evaluated. -- Tests must not sleep through production backoff windows or use external/network/hardware dependencies. -- No migration or persistent state exists. If a phase regresses behavior, revert only its new coordinator/composition edits before continuing; do not change API, storage, Agent or container contracts to accommodate it. diff --git a/.trellis/tasks/archive/2026-08/08-14-move-background-policy-to-application/prd.md b/.trellis/tasks/archive/2026-08/08-14-move-background-policy-to-application/prd.md deleted file mode 100644 index ad49588..0000000 --- a/.trellis/tasks/archive/2026-08/08-14-move-background-policy-to-application/prd.md +++ /dev/null @@ -1,46 +0,0 @@ -# Move background policy into application coordinators - -## Goal - -Resolve layer-audit finding V-02 by moving SMS synchronization semantics and hardware-Agent change semantics out of `cmd/simplusd` into narrow application-owned coordinators, leaving the executable root responsible only for dependency construction, lifecycle, configuration and operational logging. - -## Background - -- The layer audit classified V-02 as a confirmed Medium violation. The active anchors are `cmd/simplusd/main.go:281-283,394-553`, with policy tests at `cmd/simplusd/main_test.go:53-99`. -- `runSMSSync` currently interprets `messaging.InboundSyncResult`, chooses realtime topics and attention, synthesizes the inbound-SMS notification, and owns synchronization retry/backoff. -- `runAgentChanges` currently interprets Agent instance/generation changes, chooses the Inventory/Modems/Lines invalidation topics, and owns watch retry/backoff. -- `.trellis/spec/core/backend/directory-structure.md` assigns executable roots only composition and lifecycle; `.trellis/spec/core/backend/api-contracts.md` requires application/background tests for durable realtime publication semantics. -- The user approved the recommended repair and required current behavior to remain unchanged. - -## Requirements - -- R1. Remove SMS result interpretation, notification decisions, realtime-topic selection, Agent-change interpretation and retry/backoff policy from `cmd/simplusd`. -- R2. Add an application-owned SMS synchronization coordinator beside `internal/application/messaging`. It must consume the smallest interfaces for inbound synchronization, notification delivery and realtime publication; it must not depend on `slog`, HTTP, storage implementations or concrete notification/realtime implementations. -- R3. Preserve the current SMS cycle semantics and ordering: run immediately; bound `SyncInbound` to 20 seconds; publish the Messages topic for `Persisted`, `AlreadyKnown`, `OutboundSent`, `OutboundFailed` or `OutboundUnconfirmed`; attach `sms.received` attention only when `Persisted > 0`; do not publish for acknowledgement-only results; notify only for newly persisted inbound messages with the existing event and text; bound notification delivery to 15 seconds using a context detached from parent cancellation; publish the Notifications topic after that delivery attempt; and let only the synchronization error control retry. -- R4. Preserve the current SMS scheduling policy: normalize intervals below one second to two seconds, reset retry delay after a successful synchronization, otherwise begin at `max(15 seconds, interval * 4)`, double, and cap at five minutes. -- R5. Add an application-owned Agent change coordinator beside `internal/application/inventory`. It must consume a narrow Agent change-source interface plus a narrow realtime publisher and must not depend on `slog` or a concrete Agent client/Hub. -- R6. Preserve the current Agent watch semantics: initialize from a non-probing snapshot; long-poll changes for 25 seconds using the current instance ID and generation; invalidate Inventory, Modems and Lines together for an explicit change or for an instance/generation change observed after reconnect; start retries at one second, double to a 30-second cap, reset after a successful change response, and stop on context cancellation. -- R7. Keep operational log rendering in `cmd/simplusd` through typed coordinator reports/callbacks. Application coordinators decide business/event behavior but do not choose log handlers or process exit behavior. -- R8. Require mandatory coordinator dependencies explicitly at construction. Keep interfaces next to their consumers; do not introduce a generic `background`, `common`, event bus or catch-all service package. -- R9. Move deterministic policy tests from `cmd/simplusd/main_test.go` to the owning application packages and add fake-port coverage for partial progress, notification failure, Agent reconnect/generation changes, backoff caps and cancellation. -- R10. Do not change the public API, SSE payload schema, persistence schema, container/runtime configuration, notification channel behavior, Agent protocol, hardware support or user-visible SMS/Realtime behavior. -- R11. Validation must be synthetic and non-HIL. Do not start services/Compose, inspect private state, contact notification endpoints, access hardware, send SMS/place calls, or perform RF/SIM/eUICC/network mutation. - -## Acceptance Criteria - -- [x] AC1. `cmd/simplusd` constructs and starts the two coordinators and contains no SMS/Agent business-policy helpers, topic mapping or retry-delay functions from V-02. -- [x] AC2. The SMS coordinator owns the exact durable-change, attention, notification, publication-order, timeout and retry semantics listed in R3-R4 through consumer-owned interfaces. -- [x] AC3. The Agent change coordinator owns the exact snapshot/change, invalidation-topic, reconnect, retry and cancellation semantics listed in R5-R6 through consumer-owned interfaces. -- [x] AC4. Application-package fake tests cover success, partial progress with error, acknowledgement-only results, notification failure, Agent changed/restarted/generation-changed results, retry caps and cancellation without real waits, network or hardware. -- [x] AC5. Existing operational log messages remain available from the composition root, and notification failure still does not cause SMS synchronization retry. -- [x] AC6. No public/API/generated/storage/protocol/container contract changes, generic background package or new lower-layer concrete dependency is introduced. -- [x] AC7. Targeted Go tests, formatting, vet/lint components available offline, full relevant package tests, task-context validation and `git diff --check` pass. -- [x] AC8. An independent Trellis check confirms V-02 is removed without moving policy into another composition or transport boundary. - -## Out of Scope - -- Changing SMS synchronization, retry or notification product behavior. -- Generalizing all background jobs or refactoring the existing VoWiFi reconciliation callback and Feishu binding callback. -- Repairing audit findings V-03 through V-05 or Low concerns C-01/C-02. -- Changing `realtime.Hub`, SSE transport/authentication/backpressure, notification provider delivery, or Agent wire validation. -- Hardware, deployment, HIL or external-provider validation. diff --git a/.trellis/tasks/archive/2026-08/08-14-move-background-policy-to-application/task.json b/.trellis/tasks/archive/2026-08/08-14-move-background-policy-to-application/task.json deleted file mode 100644 index bb1611b..0000000 --- a/.trellis/tasks/archive/2026-08/08-14-move-background-policy-to-application/task.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "id": "move-background-policy-to-application", - "name": "move-background-policy-to-application", - "title": "Move background policy into application coordinators", - "description": "Resolve audit V-02 by moving SMS synchronization and Agent change publication policy from cmd/simplusd into application-owned coordinators without changing behavior.", - "status": "completed", - "dev_type": null, - "scope": null, - "package": "core", - "priority": "P1", - "creator": "Leon", - "assignee": "Leon", - "createdAt": "2026-08-14", - "completedAt": "2026-08-14", - "branch": null, - "base_branch": "main", - "worktree_path": null, - "commit": null, - "pr_url": null, - "subtasks": [], - "children": [], - "parent": "08-14-audit-layer-boundaries", - "relatedFiles": [], - "notes": "", - "meta": {} -} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-08/08-14-retire-native-deployment/check.jsonl b/.trellis/tasks/archive/2026-08/08-14-retire-native-deployment/check.jsonl deleted file mode 100644 index 78b8421..0000000 --- a/.trellis/tasks/archive/2026-08/08-14-retire-native-deployment/check.jsonl +++ /dev/null @@ -1,9 +0,0 @@ -{"file":".trellis/spec/core/infra/containers-and-privileges.md","reason":"Verify the final source preserves the three-image privilege boundary, registry-only driver write and legacy-service conflict checks."} -{"file":".trellis/spec/core/infra/build-and-generated-files.md","reason":"Verify the repair did not remove native development or container-required strongSwan packaging."} -{"file":".trellis/spec/core/infra/hardware-and-hil-safety.md","reason":"Verify no hardware/HIL action was used and no arbitrary VID/PID/path surface was introduced."} -{"file":".trellis/spec/core/docs/documentation-ownership.md","reason":"Check canonical docs, active plan and ADR status are synchronized without duplicated or unsupported claims."} -{"file":"docs/decisions/0021-container-production-deployment.md","reason":"Confirm the implementation note follows the accepted decision and does not rewrite historical evidence."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Apply targeted-to-broad validation, trusted fixture and no-hardware testing rules to the retirement change."} -{"file":".github/workflows/strongswan-plugins.yml","reason":"Check deleted native paths are removed while plugin package build triggers and job remain intact."} -{"file":"scripts/release/check-container-host.sh","reason":"Confirm legacy native units still fail the container host preflight despite removal of the installer/uninstaller."} -{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Review that documentation, CI, release scripts and container runtime all reflect one production deployment owner."} diff --git a/.trellis/tasks/archive/2026-08/08-14-retire-native-deployment/design.md b/.trellis/tasks/archive/2026-08/08-14-retire-native-deployment/design.md deleted file mode 100644 index 1fe07d9..0000000 --- a/.trellis/tasks/archive/2026-08/08-14-retire-native-deployment/design.md +++ /dev/null @@ -1,51 +0,0 @@ -# Design: container-only production retirement - -## 1. Retirement boundary - -The repair removes an obsolete deployment implementation rather than adapting it to the current registry. The deleted production entry points are: - -- `scripts/release/bind-ml307a.sh` -- `scripts/release/build-debian-bundle.sh` -- `scripts/release/install-debian.sh` -- `scripts/release/uninstall-debian.sh` - -The remaining `scripts/release/prepare-container-host.sh` and `check-container-host.sh` belong to Compose. The check keeps rejecting legacy service names because contention is still unsafe even after the repository stops supplying those units. - -## 2. Runtime and build boundaries preserved - -Container startup remains: - -```text -containers/agent-entrypoint.sh - -> simplus-agent register-option-driver - -> modemadapter.DefaultRegistry().USBSerialIDs() - -> fixed mapped option1/new_id attribute -``` - -No host-path selector or caller-provided VID/PID is added. `packaging/strongswan-plugins`, its product C bridge, Docker build stages and release artifacts remain because netd images consume them. Native Go/Node development and `simplus-agent-dev` are development/HIL workflows, not production deployment fallbacks. - -## 3. Documentation ownership - -- `README.md` and `docs/README.md` are entry points and link to the canonical installation guide. -- `docs/installation.md` becomes the container-only operational guide. It continues to state current clean-VM/evidence limitations and that legacy state is not automatically migrated. -- `docs/architecture.md` owns the production process shape and removes the obsolete native systemd fallback. -- `docs/handoff.zh-CN.md` and `docs/plans/active/mvp.md` align current status and remaining milestones. -- ADR 0021 already decided that Compose becomes the sole production deployment after container HIL; add an implementation-status note instead of rewriting its historical rationale. -- Trellis documentation/infra specs are synchronized in Phase 3.3 so future sessions do not revive the fallback. - -“Native” references are changed only when they describe host/systemd deployment. QDC507 native cellular SMS and native Linux development remain valid, distinct concepts. - -## 4. Compatibility and safety - -Removal intentionally breaks the ability to create a new native bundle. It does not alter Compose configuration or application APIs. Existing legacy private state is neither read nor deleted; migration remains unsupported. No runtime validation starts Compose, touches hardware or creates network objects. - -Rollback is source-level restoration of deleted scripts and documentation only. The already completed host uninstall is not automatically rolled back and private state remains available for an explicitly authorized recovery decision. - -## 5. Verification strategy - -- scan tracked source for deleted script names and native-fallback claims; -- scan production driver binding for hardcoded/out-of-registry writes; -- run shell syntax checks for remaining release/container scripts; -- run documentation and container contract checks; -- validate CI YAML through the existing lint path when the locked tool is available, reporting an offline cache limitation rather than downloading dependencies; -- run `git diff --check` and task context validation. diff --git a/.trellis/tasks/archive/2026-08/08-14-retire-native-deployment/implement.jsonl b/.trellis/tasks/archive/2026-08/08-14-retire-native-deployment/implement.jsonl deleted file mode 100644 index fde4fe1..0000000 --- a/.trellis/tasks/archive/2026-08/08-14-retire-native-deployment/implement.jsonl +++ /dev/null @@ -1,9 +0,0 @@ -{"file":".trellis/spec/core/infra/containers-and-privileges.md","reason":"Defines the production Compose shape, exact Agent sysfs ownership, host checks and legacy native-service conflict boundary that must remain fail closed."} -{"file":".trellis/spec/core/infra/build-and-generated-files.md","reason":"Separates native development from production containers and protects the strongSwan package/build inputs that remain required."} -{"file":".trellis/spec/core/infra/hardware-and-hil-safety.md","reason":"Constrains validation to non-HIL source checks and protects the registry-backed typed device boundary."} -{"file":".trellis/spec/core/docs/documentation-ownership.md","reason":"Identifies canonical owners for deployment status, installation, ADRs, active plans and public evidence claims."} -{"file":"docs/decisions/0021-container-production-deployment.md","reason":"Already decides that Compose becomes the sole production deployment after container HIL and preserves native development separately."} -{"file":"docs/installation.md","reason":"Canonical operational guide currently advertises the obsolete native fallback and must become container-only without overstating evidence."} -{"file":"docs/architecture.md","reason":"Normative production process map still describes the native systemd fallback and must align with the selected deployment owner."} -{"file":".github/workflows/strongswan-plugins.yml","reason":"Contains path filters for obsolete bundle/install scripts while the underlying plugin package job remains required by containers."} -{"file":"scripts/release/check-container-host.sh","reason":"Must remain and continue rejecting legacy native-service contention after native installation tooling is removed."} diff --git a/.trellis/tasks/archive/2026-08/08-14-retire-native-deployment/implement.md b/.trellis/tasks/archive/2026-08/08-14-retire-native-deployment/implement.md deleted file mode 100644 index 1ec8c17..0000000 --- a/.trellis/tasks/archive/2026-08/08-14-retire-native-deployment/implement.md +++ /dev/null @@ -1,45 +0,0 @@ -# Retire native production deployment: execution plan - -## Phase A — Remove obsolete production entry points - -- [x] Delete the native bundle builder, installer, uninstaller and model-specific driver bind helper. -- [x] Remove deleted paths from strongSwan workflow filters without changing the package build job. -- [x] Verify remaining container host/release scripts retain their current ownership and syntax. - -## Phase B — Align canonical product documentation - -- [x] Update README and the documentation map to describe container-only production. -- [x] Convert `docs/installation.md` from a transition guide to the sole Compose production guide while retaining clean-VM and migration limitations. -- [x] Remove the native systemd fallback from architecture and handoff status. -- [x] Update the active MVP plan and ADR 0021 implementation status without rewriting native-cellular-SMS meanings or inventing evidence. - -## Phase C — Synchronize Trellis contracts - -- [x] In Phase 3.3, update documentation ownership and infrastructure specs so production is Compose-only and legacy service checks remain fail-closed. -- [x] Confirm native development/HIL and strongSwan packaging contracts remain present. - -## Phase D — Verify and review - -- [x] Prove deleted script names have no live references outside task/history evidence. -- [x] Prove production dynamic USB IDs have one registry-backed path and no native hardcoded writer remains. -- [x] Run `make check-docs`, `make check-container-files`, remaining release-script syntax checks and `go test ./internal/containercontract`. -- [x] Run lint/doc checks available offline, record any locked-tool cache limitation, and run `git diff --check` plus task context validation. -- [x] Dispatch an independent Trellis check pass and incorporate only in-scope fixes. - -The retired file names remain only in current task/audit and archived task evidence. -`simplus-ml307a-bind.service` remains intentionally in the container host conflict -guard, its static contract test and the installation guide so active or enabled -legacy units fail closed. - -## Validation commands - -```bash -rg -n 'bind-ml307a|build-debian-bundle|install-debian|uninstall-debian|simplus-ml307a-bind' . -rg -n '2ecc[ :]+3012|option1/new_id' cmd internal containers scripts compose.yaml Dockerfile -make check-docs -make check-container-files -go test ./internal/containercontract -git diff --check -``` - -Do not start Compose or services, run host preparation, access hardware/private state, or execute HIL/communication/network mutations. diff --git a/.trellis/tasks/archive/2026-08/08-14-retire-native-deployment/prd.md b/.trellis/tasks/archive/2026-08/08-14-retire-native-deployment/prd.md deleted file mode 100644 index 87f6774..0000000 --- a/.trellis/tasks/archive/2026-08/08-14-retire-native-deployment/prd.md +++ /dev/null @@ -1,40 +0,0 @@ -# Retire native production deployment - -## Goal - -Resolve audit finding V-01 by retiring the unsupported native Debian production deployment and making Docker Compose the repository's only supported production installation path. - -## Context - -- The owner confirmed that current deployments are container-based and authorized removal of the native production path. -- Read-only host inspection found a legacy native installation. The repository's existing non-purge uninstaller was run successfully before source removal; native units, binaries, host plugins, Web files and sysctl configuration were removed while private state directories were preserved. -- The container Agent already registers reviewed dynamic USB serial IDs through `modemadapter.DefaultRegistry()` and remains the sole production owner of the `option1/new_id` write. -- `packaging/strongswan-plugins/**` is still required to build the netd container and is not part of the obsolete native deployment. - -## Requirements - -- R1. Remove the native production bundle builder, installer, uninstaller and ML307A-specific bind helper from `scripts/release/`. -- R2. Remove workflow, documentation and planning references that advertise or validate the native Debian bundle as a supported production fallback. -- R3. State that Docker Compose is the only supported production deployment without claiming that pending clean-VM lifecycle evidence has already passed. -- R4. Preserve the three-image container privilege model, registry-backed driver registration, container host preparation/check scripts, strongSwan package build, native Linux development workflow and `simplus-agent-dev` HIL workflow. -- R5. Preserve references where “native” means QDC507 native cellular SMS rather than native host deployment. -- R6. Keep container host checks fail-closed when obsolete native service names are found, so an old installation cannot silently contend with Compose. -- R7. Do not purge legacy private state, inspect it, migrate it into Compose, start services/Compose, or execute hardware/HIL/network/communication actions as part of repository validation. -- R8. Keep canonical docs, accepted decision 0021, active plan, Trellis specs and CI path filters consistent with the container-only production contract. - -## Acceptance Criteria - -- [x] AC1. The four obsolete native release scripts are deleted and no supported command can build or install the native Debian production bundle. -- [x] AC2. No production path hardcodes the ML307A VID/PID and writes the host `option1/new_id` outside the registry-backed container Agent command. -- [x] AC3. StrongSwan package/container build inputs and the remaining container host scripts are intact and their checks pass. -- [x] AC4. README, architecture, installation, handoff, active plan and ADR/status language consistently describe Compose as the only supported production deployment while retaining current evidence limitations. -- [x] AC5. Historical/native-cellular-SMS references are not incorrectly removed or rewritten. -- [x] AC6. Workflow path filters and canonical documentation checks contain no stale references to deleted scripts. -- [x] AC7. All repository changes are scoped to this retirement and task artifacts; no private data, container runtime, modem, RF, SIM/eUICC, SMS/call or HIL action is used for validation. - -## Out of Scope - -- Purging or migrating preserved legacy `/var/lib` state. -- Removing native Linux development, Simulator, HIL build tools, `simplus-agent-dev`, QDC507 native cellular SMS, or strongSwan packaging. -- Changing container privileges, mounts, UIDs, protocols, hardware support or public application behavior. -- Claiming new clean-VM, release, Runtime or hardware evidence. diff --git a/.trellis/tasks/archive/2026-08/08-14-retire-native-deployment/task.json b/.trellis/tasks/archive/2026-08/08-14-retire-native-deployment/task.json deleted file mode 100644 index 6dbcf77..0000000 --- a/.trellis/tasks/archive/2026-08/08-14-retire-native-deployment/task.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "id": "retire-native-deployment", - "name": "retire-native-deployment", - "title": "Retire native production deployment", - "description": "Resolve audit V-01 by removing the unsupported native Debian production deployment path and aligning container-only documentation and CI references.", - "status": "completed", - "dev_type": null, - "scope": null, - "package": "core", - "priority": "P0", - "creator": "Leon", - "assignee": "Leon", - "createdAt": "2026-08-14", - "completedAt": "2026-08-14", - "branch": null, - "base_branch": "main", - "worktree_path": null, - "commit": null, - "pr_url": null, - "subtasks": [], - "children": [], - "parent": "08-14-audit-layer-boundaries", - "relatedFiles": [], - "notes": "", - "meta": {} -} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-08/08-15-extract-notification-delivery-ports/check.jsonl b/.trellis/tasks/archive/2026-08/08-15-extract-notification-delivery-ports/check.jsonl deleted file mode 100644 index 078ec02..0000000 --- a/.trellis/tasks/archive/2026-08/08-15-extract-notification-delivery-ports/check.jsonl +++ /dev/null @@ -1,5 +0,0 @@ -{"file":".trellis/tasks/08-15-extract-notification-delivery-ports/research.md","reason":"Independently verify the current V-05 call chain is closed without changing preserved semantics."} -{"file":".trellis/spec/core/backend/directory-structure.md","reason":"Verify concrete HTTP/provider behavior and assembly reside in the intended owners."} -{"file":".trellis/spec/core/backend/api-contracts.md","reason":"Verify public/Feishu/legacy Webhook behavior and privacy remain compatible."} -{"file":".trellis/spec/core/backend/storage-and-migrations.md","reason":"Verify ciphertext labels/data and schema remain unchanged."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Verify synthetic tests, credential-safe errors and full safe validation ladder."} diff --git a/.trellis/tasks/archive/2026-08/08-15-extract-notification-delivery-ports/design.md b/.trellis/tasks/archive/2026-08/08-15-extract-notification-delivery-ports/design.md deleted file mode 100644 index f767120..0000000 --- a/.trellis/tasks/archive/2026-08/08-15-extract-notification-delivery-ports/design.md +++ /dev/null @@ -1,165 +0,0 @@ -# Design: Typed legacy Webhook delivery boundary - -## 1. Ownership and data flow - -The repaired create/update path is: - -```text -HTTP -> notification Service business validation - -> WebhookPort.ValidateTarget(provider, supplied URL) - -> Service encrypts normalized URL/signing secret - -> Store persists the unchanged legacy channel row -``` - -The repaired delivery path is: - -```text -Service selects enabled/event-matching channel - -> decrypts existing Webhook URL and optional signing secret - -> WebhookPort.Deliver(bounded typed request) - -> concrete notificationwebhook adapter validates + signs + POSTs + parses - -> typed outcome - -> Service records success/network-failed/rejected business state -``` - -The adapter owns no Store, SecretCipher, channel iteration, event policy or -delivery-state persistence. The Service owns no raw HTTP/provider wire shape. - -## 2. Application contract - -Use application-owned values and one consumer port: - -```go -type WebhookProvider string - -const ( - WebhookProviderWeCom WebhookProvider = "wecom" - WebhookProviderFeishu WebhookProvider = "feishu" -) - -type WebhookTarget struct { - URL string - Hint string -} - -type WebhookDeliveryRequest struct { - Provider WebhookProvider - URL string - SigningSecret string - Message string - Timestamp int64 -} - -type WebhookDeliveryOutcome string - -const ( - WebhookDelivered WebhookDeliveryOutcome = "delivered" - WebhookNetworkFailed WebhookDeliveryOutcome = "network_failed" - WebhookRejected WebhookDeliveryOutcome = "rejected" -) - -type WebhookDeliveryResult struct { - Outcome WebhookDeliveryOutcome -} - -type WebhookPort interface { - ValidateTarget(WebhookProvider, string) (WebhookTarget, error) - Deliver(context.Context, WebhookDeliveryRequest) (WebhookDeliveryResult, error) -} - -type Dependencies struct { - Store Store - Secrets SecretCipher - Webhooks WebhookPort -} - -func New(Dependencies) (*Service, error) -``` - -`ErrDependenciesInvalid` is the stable constructor sentinel. Store, Secrets -and Webhooks are mandatory, including typed-nil rejection. `time.Now` remains -the default clock seam; Feishu binding ports remain explicitly configured -after construction. - -The exact exported names may be tightened during implementation, but the -single required dependency shape, bounded fields and typed outcomes are -contractual. - -## 3. Concrete adapter contract - -`internal/notificationwebhook.Client` implements the application port and owns -the production `http.Client`. Construction sets a 15-second timeout and refuses -redirects. A compile-time assertion proves the client implements the port. - -`ValidateTarget` preserves current normalization and restrictions: - -- HTTPS, no userinfo and no fragment; -- WeCom: `url.Hostname()` equals `qyapi.weixin.qq.com`, exact - `/cgi-bin/webhook/send`, non-empty `key`, with existing extra query values - preserved; -- Feishu: `open.feishu.cn`, non-empty suffix below - `/open-apis/bot/v2/hook/`, and no query; -- explicit URL ports retain the legacy `url.Hostname()` validation semantics; - this boundary refactor does not silently reject existing authenticated rows; -- normalized URL plus hostname-only public hint; no credential in the hint or - error. - -`Deliver` revalidates the provider/target and bounded inputs before any HTTP, -then preserves the current protocol: - -- WeCom JSON: `msgtype=text`, `text.content=<message>`; -- Feishu JSON: `msg_type=text`, `content.text=<message>`, with optional decimal - timestamp and existing HMAC-SHA256/base64 signature calculation; -- POST with the exact content type and user agent; -- read at most 64 KiB plus one byte; -- require HTTP 2xx and explicit numeric `errcode=0` for WeCom or `code=0` for - Feishu. Missing/malformed/nonzero codes fail closed. - -The adapter returns stable credential-safe errors. It never returns or wraps a -raw `url.Error`, provider response body or URL-bearing redirect error. - -## 4. Outcome and persistence matrix - -| Adapter result | Service persistence | Returned behavior | -| --- | --- | --- | -| `delivered`, nil | record `success / ""`; update returned view | success-record failure remains visible | -| `network_failed`, bounded error | best-effort `failed / DELIVERY_NETWORK_FAILED` | return bounded delivery error | -| `rejected`, bounded error | best-effort `failed / DELIVERY_REJECTED` | return bounded delivery error | -| zero/unknown outcome or contradictory result | no invented delivery status | stable invalid-result error | -| target validation, decryption or request preflight error | no delivery record | return bounded error; public mapping remains existing behavior | - -Failure-state persistence errors remain secondary to the primary external -failure, matching current behavior. A successful external delivery followed by -status persistence failure remains an error and is never blindly retried by -this task. - -## 5. Composition and package direction - -`cmd/simplusd` constructs `notificationwebhook.NewClient()`, injects it through -`notification.Dependencies`, handles constructor failure, then configures the -existing Feishu registrar/messenger and realtime callback. - -The concrete adapter imports the application-owned value contract solely to -implement it. It does not invoke Service methods or depend on storage. This is -the intentional dependency-inversion edge; `cmd` remains the only concrete -assembly owner. - -## 6. Compatibility and privacy - -- No OpenAPI, HTTP route/error code, domain Channel field, SQLite migration, - ciphertext label, Web view or generated file changes. -- Existing legacy Webhook ciphertext decrypts and is delivered through the new - adapter without migration. Create/update continue to persist normalized URLs - and hostname-only hints. -- Feishu application binding and delivery continue through - `FeishuRegistrar`/`FeishuMessenger`; only Service construction call sites - change. -- Plaintext URL/signing material exists only for the current delivery call. It - must not be logged, formatted into returned errors, stored in task evidence - or asserted verbatim in failure output. - -## 7. Rollback and safety - -Rollback is a source-only revert. There is no data migration or runtime state -to undo. Automated checks use fake ports, injected transports and temporary -SQLite only; they do not contact official provider hosts or send a message. diff --git a/.trellis/tasks/archive/2026-08/08-15-extract-notification-delivery-ports/implement.jsonl b/.trellis/tasks/archive/2026-08/08-15-extract-notification-delivery-ports/implement.jsonl deleted file mode 100644 index f1e894c..0000000 --- a/.trellis/tasks/archive/2026-08/08-15-extract-notification-delivery-ports/implement.jsonl +++ /dev/null @@ -1,7 +0,0 @@ -{"file":".trellis/tasks/08-15-extract-notification-delivery-ports/research.md","reason":"Curated current V-05 call chain, exact compatibility semantics, test gaps and planning conclusions."} -{"file":".trellis/spec/core/backend/index.md","reason":"Root Go dependency direction and pre-development checklist."} -{"file":".trellis/spec/core/backend/directory-structure.md","reason":"Application consumer ports, concrete adapter placement, cmd assembly and test ownership."} -{"file":".trellis/spec/core/backend/api-contracts.md","reason":"Legacy Webhook compatibility and Feishu binding/public privacy contracts."} -{"file":".trellis/spec/core/backend/storage-and-migrations.md","reason":"Existing Webhook/signing ciphertext ownership and no-migration constraint."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Synthetic provider transport, privacy and targeted-to-broad validation requirements."} -{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Cross-layer request/result and validation ownership checklist."} diff --git a/.trellis/tasks/archive/2026-08/08-15-extract-notification-delivery-ports/implement.md b/.trellis/tasks/archive/2026-08/08-15-extract-notification-delivery-ports/implement.md deleted file mode 100644 index d954fe4..0000000 --- a/.trellis/tasks/archive/2026-08/08-15-extract-notification-delivery-ports/implement.md +++ /dev/null @@ -1,95 +0,0 @@ -# Implementation Plan - -## Phase A — Define the application-owned boundary - -- [x] Add bounded provider/target/request/result/outcome values and the - `WebhookPort` consumer interface. -- [x] Replace `notification.New(store, secrets)` with one validated dependency - constructor and stable missing/typed-nil configuration error. -- [x] Refactor create/update to delegate exact target validation and refactor - delivery to translate typed outcomes into the unchanged persistence policy. -- [x] Remove Webhook HTTP, signing, JSON and provider-response code from the - Service while retaining secret labels, encryption/decryption and state - ownership. - -## Phase B — Implement the concrete provider adapter - -- [x] Add `internal/notificationwebhook` with the redirect-blocking 15-second - client, exact target rules, both payload/signature shapes, bounded response - parser and credential-safe stable errors. -- [x] Revalidate every delivery request and return typed delivered/network/ - rejected outcomes without exposing URLs, keys, secrets or provider bodies. -- [x] Move/expand synthetic transport tests to cover exact allowed/rejected - targets, payloads, headers, redirects, transport/read/size/status/JSON/code - failures and per-delivery revalidation. - -## Phase C — Compose and update callers - -- [x] Construct the concrete adapter in `cmd/simplusd`, inject it through the - dependency object and handle constructor failure before later assembly. -- [x] Update notification service, binding, integration and any HTTP callers to - the explicit constructor without changing Feishu configuration behavior. -- [x] Add application fake-port tests for constructor validation, target - normalization handoff, decrypted bounded request contents, event filtering, - all typed outcomes, invalid adapter results and persistence failures. - -## Phase D — Independent verification and contract capture - -- [x] Run focused notification/adapter/cmd/http/storage tests, then focused race - coverage and supported broad Go tests/vet/lint. -- [x] Run ownership/privacy scans proving the Service has no raw Webhook HTTP - or provider protocol and adapter errors cannot include credential material. -- [x] Confirm no OpenAPI, migration or generated drift; validate task context, - formatting and `git diff --check`. -- [x] Complete an independent Trellis check and update backend - boundary/storage/quality specs with the verified contract. -- [x] Commit the completed task as `04e1770`; archive and record it through the - Trellis finish-work flow. - -## Validation Commands - -Safe commands may include: - -```bash -go test ./internal/application/notification ./internal/notificationwebhook ./cmd/simplusd ./internal/api/httpapi ./internal/storage/sqlite -go test -race ./internal/application/notification ./internal/notificationwebhook ./cmd/simplusd -go test ./cmd/... ./internal/... -make check-format -make lint -make verify-generated -make check-docs -python3 ./.trellis/scripts/task.py validate .trellis/tasks/08-15-extract-notification-delivery-ports -git diff --check -``` - -## Prohibited Validation - -Do not contact provider endpoints, send test notifications, start services or -Compose, read private runtime data, or run hardware discovery, HIL, SMS/calls, -RF, SIM/eUICC or other device/network actions. - -## Implementation Validation Notes - -- The independent check fixed one adapter consistency issue: `Deliver` now - executes the normalized `WebhookTarget.URL` returned by its per-delivery - validation instead of revalidating one string and passing the original to - `net/http`. A synthetic regression covers whitespace normalization without - external delivery. -- Focused compatibility checks passed for notification, Webhook adapter, - `cmd/simplusd`, HTTP API and SQLite packages. -- Uncached focused race passed with - `go test -count=1 -race ./internal/application/notification ./internal/notificationwebhook ./cmd/simplusd`. -- Uncached broad safe Go scope passed with - `go test -count=1 ./cmd/... ./internal/...`. -- Repository gates: `make check-format`, `make lint`, `make verify-generated`, and `make check-docs`. -- Explicit `go vet ./cmd/... ./internal/...` also passed. -- Ownership/privacy scans found no raw Webhook HTTP, provider JSON/signing, or - official target rules in `service.go`; the adapter has no storage dependency - and its parse/request/network/rejection paths return exact stable errors - without wrapping URLs, transport errors, redirect targets or provider bodies. -- The follow-up spec check synchronized the seven-section legacy Webhook - scenario and backend index/directory/storage/testing guidance with the exact - names, bounds, normalized execution target, Feishu signature, v1 - ciphertext/hint preservation, outcome matrix and package direction. -- `python3 ./.trellis/scripts/task.py validate .trellis/tasks/08-15-extract-notification-delivery-ports` and `git diff --check` passed. -- No provider endpoint, service, Compose, private runtime state, hardware, HIL, RF, SIM/eUICC, SMS, or call action was used. diff --git a/.trellis/tasks/archive/2026-08/08-15-extract-notification-delivery-ports/prd.md b/.trellis/tasks/archive/2026-08/08-15-extract-notification-delivery-ports/prd.md deleted file mode 100644 index a3b491f..0000000 --- a/.trellis/tasks/archive/2026-08/08-15-extract-notification-delivery-ports/prd.md +++ /dev/null @@ -1,124 +0,0 @@ -# Extract notification delivery ports - -## Goal - -Resolve audit finding V-05 by keeping notification channel state and delivery -outcome policy in the application service while moving legacy Webhook endpoint -validation, provider signing/payload construction, HTTP execution and provider -response parsing behind one explicit bounded port and a concrete adapter -assembled in `cmd/simplusd`. - -## Background and Evidence - -- The layer audit classified V-05 as a confirmed Medium violation. At the - audited call chain, `internal/application/notification/service.go:48-62` - constructs a redirect-blocking `*http.Client`, while the active Webhook path - at `service.go:203-263` decrypts credentials, builds provider protocol, - performs raw HTTP, parses the response and updates delivery state. -- `internal/application/notification/feishu.go:60-80` and - `binding.go:58-66` provide the intended local precedent: application state - consumes fixed-purpose typed provider ports while `cmd/simplusd` assembles - the concrete client. -- Existing enterprise WeChat and Feishu bot Webhooks are outbound-only. They - support configuration, enable/disable, explicit tests, event filtering and - delivery status; secrets are write-only and must not enter public responses, - SSE, ordinary logs or errors (`docs/decisions/0005-*`). -- Feishu private-message application channels are a separate delivery mode. - Their binding/test-before-persist behavior and concrete client remain intact, - and legacy Webhook rows/credentials must remain compatible - (`docs/decisions/0025-*`). - -## Requirements - -- R1. `internal/application/notification` owns a narrow `WebhookPort` plus - bounded provider, target, delivery-request, result and outcome values. The - port exposes only target validation and text delivery for the two supported - providers; it must not accept caller-selected headers, methods, bodies, - redirects or arbitrary HTTP options. -- R2. Move exact Webhook authority/path validation, URL normalization, - WeCom/Feishu payload and signature generation, redirect refusal, timeout, - headers, HTTP execution, response-size limiting and explicit provider-success - parsing into a concrete `internal/notificationwebhook` adapter. The adapter - must revalidate every delivery target and fail closed. -- R3. Keep provider/name/event business validation, secret labels and - encryption/decryption, channel persistence, event filtering, per-channel - iteration, delivery-state transitions and best-effort failure recording in - the application service. -- R4. Replace the hidden Webhook HTTP default with an explicit notification - dependency contract. `notification.New` must require Store, SecretCipher and - WebhookPort dependencies, reject nil and typed-nil inputs with a stable - configuration error, and never return a partially usable Service. -- R5. `cmd/simplusd` constructs the concrete Webhook adapter, injects it into - the application service and handles constructor failure by logging a bounded - configuration error, closing stores and exiting non-zero. Feishu registrar - and messenger configuration remains the existing separate explicit step. -- R6. Preserve delivery outcome semantics: network/redirect/transport failure - records `failed / DELIVERY_NETWORK_FAILED`; non-2xx, read/size, malformed or - explicit provider rejection records `failed / DELIVERY_REJECTED`; success - requires HTTP 2xx plus an explicit provider zero code and then persists - `success`. Pre-dispatch validation/decryption errors do not invent a delivery - record, failure-recording errors remain secondary, and success-recording - errors remain visible. -- R7. Adapter errors must be bounded and credential-safe. They may classify - invalid request, network failure or provider rejection, but must not contain - a Webhook URL/key, signing secret, raw provider body or redirect target. -- R8. Preserve the existing supported protocol shapes: 15-second timeout; - redirects refused; `Content-Type: application/json`; `User-Agent: Simplus`; - 64 KiB response limit; WeCom official hostname/send path with a non-empty - `key`; Feishu official hostname and bot-hook path with no query; optional - Feishu signing secret up to 512 bytes; messages remain bounded to 4000 runes. - Preserve the legacy parser's explicit-port handling and WeCom extra-query - handling rather than tightening accepted stored credentials in this refactor. -- R9. Preserve OpenAPI/Web behavior, HTTP error mapping, domain/storage rows, - encryption labels/ciphertexts, migrations, event kinds, Feishu application - binding and generated outputs. This task introduces no provider or delivery - feature. -- R10. Move synthetic HTTP/provider assertions to the concrete adapter and use - an application fake port to prove validation, credential handoff, event - filtering, outcome-to-persistence mapping and constructor failures. Tests - must send no external notification. - -## Acceptance Criteria - -- [x] AC1. The notification Service contains no `http.Client`, raw Webhook - request/response handling, provider JSON/signature function or official - endpoint/path validation; those behaviors exist only in the concrete adapter. -- [x] AC2. The application exposes one bounded typed Webhook port and an - error-returning constructor that rejects every missing/typed-nil mandatory - dependency. -- [x] AC3. `cmd/simplusd` explicitly constructs and injects the Webhook adapter, - handles application configuration failure and preserves the existing Feishu - client composition. -- [x] AC4. Application fake-port tests prove target validation, decrypted - credential/message/timestamp handoff, event filtering, success, network - failure, rejection, invalid adapter result and persistence ordering/mapping. -- [x] AC5. Adapter tests prove both provider payloads/signature, exact allowed - target shapes, per-delivery revalidation, headers, redirect/transport - classification, bounded response parsing and credential-safe errors using - synthetic transports only. -- [x] AC6. Existing application integration, HTTP, storage and Feishu tests - preserve public responses, encrypted data, legacy rows and application - binding semantics without API/schema/generated changes. -- [x] AC7. Focused race tests, broad safe Go tests, formatting, lint, ownership - scans, task validation and `git diff --check` pass without external delivery, - services, Compose, private-state access, HIL or hardware/network actions. - -## Out of Scope - -- Adding providers, generic Webhooks, arbitrary HTTP configuration, retries, - queues, delivery scheduling, inbound events, remote control or new UI/API - fields. -- Moving or redesigning the Feishu registration/private-message client beyond - caller changes required by the new Service constructor. -- Database migrations, stored-secret re-encryption, Webhook credential format - changes, live provider probing or real test notifications. - -## Risks and Deferred Items - -- The concrete adapter will implement application-owned request/result types; - this is deliberate dependency inversion, not permission for the adapter to - call application behavior. The boundary must be documented and protected by - focused import/ownership scans. -- Existing authenticated ciphertext is the storage truth. This task does not - rotate or revalidate stored channels in bulk; per-delivery adapter validation - prevents an invalid stored target from becoming arbitrary HTTP. diff --git a/.trellis/tasks/archive/2026-08/08-15-extract-notification-delivery-ports/research.md b/.trellis/tasks/archive/2026-08/08-15-extract-notification-delivery-ports/research.md deleted file mode 100644 index e82684d..0000000 --- a/.trellis/tasks/archive/2026-08/08-15-extract-notification-delivery-ports/research.md +++ /dev/null @@ -1,79 +0,0 @@ -# V-05 planning research - -## Confirmed current call chain - -- `internal/application/notification/service.go:48-62` stores a concrete - `*http.Client`; `New(store, secrets)` creates the 15-second, - redirect-refusing client without an explicit dependency. -- `service.go:75-167` combines application settings/encryption with exact - provider URL/path knowledge. Create persists the normalized URL ciphertext - and hostname hint; Update preserves existing ciphertext when replacement - input is empty. -- `service.go:181-220` owns test/event filtering/channel dispatch. -- `service.go:224-263` decrypts the legacy Webhook URL/signing secret, builds - and sends raw HTTP, interprets the provider result and records delivery - status in one method. -- `service.go:294-355` owns WeCom/Feishu payloads, Feishu signing, explicit - success-code parsing and official authority/path validation. -- `cmd/simplusd/main.go:218-223` constructs the Service without a Webhook - adapter, then correctly constructs/injects `FeishuClient` through the - registrar/messenger ports. -- `internal/application/notification/feishu.go:60-80` and - `binding.go:58-65` are the existing explicit provider-port precedent. - -## Exact behavior to preserve - -- Providers are `wecom` and `feishu`; Webhook messages are outbound-only. -- URL rules: HTTPS/no userinfo/no fragment; WeCom official `Hostname()`/exact - send path/non-empty `key`; Feishu official `Hostname()`/non-empty bot-hook - suffix/no query. The existing parser accepts explicit ports and extra WeCom - query values; this refactor preserves that compatibility. Public views expose - hostname only. -- Feishu signing is optional, limited to 512 bytes, and uses decimal Unix time - plus the existing HMAC-SHA256/base64 formula. -- POST headers are `Content-Type: application/json` and `User-Agent: Simplus`. - Client timeout is 15 seconds and redirects are refused. -- Responses are bounded to 64 KiB and require HTTP 2xx plus an explicit - numeric provider zero code (`errcode` or `code`). -- Transport/redirect failure records `DELIVERY_NETWORK_FAILED`; provider, - body, status and read rejection record `DELIVERY_REJECTED`; both failure - record writes are best effort. Successful delivery requires successful - state persistence. Pre-dispatch decrypt/build/request failures do not record - an attempt. -- Secret labels, ciphertexts, legacy table, public HTTP errors and Feishu app - binding/application delivery remain unchanged. - -## Test evidence and gaps - -- `service_test.go:64-99` injects a raw RoundTripper into the application - Service and asserts both provider payload families. These assertions belong - at the concrete adapter after the split. -- `service_test.go:101-127` proves event filtering and arbitrary-host rejection - but does not exercise network/rejection/persistence outcome mapping. -- Binding tests share an HTTP test helper for the separate Feishu client; that - helper must remain available when Webhook tests move. -- `integration_test.go` proves Webhook/signing material is encrypted in SQLite - and omitted from views. It needs only explicit adapter construction; no - external delivery is necessary. - -## Planning conclusions - -- Secret labels and decryption remain application-owned; the adapter receives - plaintext only in one bounded in-memory request. -- Exact target validation moves with provider protocol and is invoked both at - create/update and again immediately before delivery. -- A typed outcome keeps persistence/status policy in the Service without - requiring it to inspect transport errors. -- The adapter implements application-owned types as a deliberate dependency - inversion. It does not import storage or call application behavior. -- Raw `http.Client.Do` errors may contain the credential-bearing URL. The new - adapter must replace them with bounded stable errors instead of wrapping or - logging raw transport/provider data. - -## Historical context - -`trellis mem search/extract` found the original layer-audit review session and -confirmed V-05 remained a Medium typed-port bypass. No additional product -decision beyond the committed audit report was recovered; part of the stored -inter-agent payload was encrypted and unavailable, so current source, tests, -ADRs and the checked-in audit remain authoritative. diff --git a/.trellis/tasks/archive/2026-08/08-15-extract-notification-delivery-ports/task.json b/.trellis/tasks/archive/2026-08/08-15-extract-notification-delivery-ports/task.json deleted file mode 100644 index 09f6d2d..0000000 --- a/.trellis/tasks/archive/2026-08/08-15-extract-notification-delivery-ports/task.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "id": "extract-notification-delivery-ports", - "name": "extract-notification-delivery-ports", - "title": "Extract notification delivery ports", - "description": "Resolve V-05 by moving raw Webhook HTTP/provider protocol behind an explicit typed notification delivery port without changing notification behavior.", - "status": "completed", - "dev_type": null, - "scope": "internal/application/notification/**, internal/notificationwebhook/**, cmd/simplusd/**, .trellis/spec/core/backend/**, task-local artifacts", - "package": "core", - "priority": "P1", - "creator": "Leon", - "assignee": "Leon", - "createdAt": "2026-08-15", - "completedAt": "2026-08-15", - "branch": null, - "base_branch": "main", - "worktree_path": null, - "commit": null, - "pr_url": null, - "subtasks": [], - "children": [], - "parent": "08-14-audit-layer-boundaries", - "relatedFiles": [], - "notes": "", - "meta": {} -} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-08/08-15-extract-notification-delivery-ports/validation.md b/.trellis/tasks/archive/2026-08/08-15-extract-notification-delivery-ports/validation.md deleted file mode 100644 index 2b6c398..0000000 --- a/.trellis/tasks/archive/2026-08/08-15-extract-notification-delivery-ports/validation.md +++ /dev/null @@ -1,51 +0,0 @@ -# Implementation validation - -Validated on 2026-08-15 without contacting provider endpoints, sending a -notification, starting services/Compose, reading private runtime data, or -performing hardware, HIL, RF, SIM/eUICC, SMS or call actions. - -## Passing gates - -- `go test -count=1 -race ./internal/application/notification ./internal/notificationwebhook ./cmd/simplusd` -- `go test -count=1 ./cmd/... ./internal/...` -- `go test ./internal/api/httpapi ./internal/storage/sqlite` -- `go vet ./cmd/... ./internal/...` -- `make check-format` -- `make lint` -- `make verify-generated` -- `make check-docs` -- active-task context validation -- focused ownership, caller, credential-error and API/schema/generated drift - scans -- `git diff --check` - -## Independent implementation review - -- Confirmed the Service owns typed target/delivery intent, secrets, event - filtering and outcome persistence but no raw legacy-Webhook HTTP/provider - protocol. -- Confirmed the adapter revalidates each request, preserves legacy explicit - ports and extra WeCom query values, executes the returned normalized URL and - returns only exact credential-safe sentinels. -- Fixed the initial implementation so delivery could not validate a normalized - target and then execute the original string. -- Strengthened privacy regressions from `errors.Is` alone to exact sentinel and - private-marker absence checks, preventing a credential-bearing `%w` wrapper - from passing. -- Confirmed every constructor caller, nil/typed-nil rejection, command cleanup, - Feishu application-channel composition, v1 labels/ciphertexts, outcome/state - matrix and public compatibility. - -## Specification review - -- Added and independently corrected the seven-section executable Webhook port - contract, directory ownership, sensitive-persistence compatibility and exact - credential-error testing rule. -- Confirmed no OpenAPI, generated source, migration, public provider or stored - credential format changed. - -## Completion - -- Product, tests, task evidence and specification were committed as - `04e1770` (`refactor: isolate notification webhook delivery`). -- The task is ready for Trellis archival and session-journal recording. diff --git a/.trellis/tasks/archive/2026-08/08-15-inject-mihomo-supervisor-explicitly/check.jsonl b/.trellis/tasks/archive/2026-08/08-15-inject-mihomo-supervisor-explicitly/check.jsonl deleted file mode 100644 index 05a59e1..0000000 --- a/.trellis/tasks/archive/2026-08/08-15-inject-mihomo-supervisor-explicitly/check.jsonl +++ /dev/null @@ -1,4 +0,0 @@ -{"file":".trellis/spec/core/backend/index.md","reason":"Root Go quality and dependency-direction requirements."} -{"file":".trellis/spec/core/backend/directory-structure.md","reason":"Independently verify concrete assembly stays in cmd and tests stay with owners."} -{"file":".trellis/spec/core/backend/application-boundaries.md","reason":"Verify typed required dependency, stable error and fail-closed construction."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Verify focused/race/broad safe checks and meaningful observable assertions."} diff --git a/.trellis/tasks/archive/2026-08/08-15-inject-mihomo-supervisor-explicitly/design.md b/.trellis/tasks/archive/2026-08/08-15-inject-mihomo-supervisor-explicitly/design.md deleted file mode 100644 index e5e4862..0000000 --- a/.trellis/tasks/archive/2026-08/08-15-inject-mihomo-supervisor-explicitly/design.md +++ /dev/null @@ -1,75 +0,0 @@ -# Design: Explicit Mihomo supervisor composition - -## 1. Ownership correction - -The corrected object graph is: - -```text -cmd/simplusd - -> choose local development implementation or Unix client - -> construct mihomosupervisor.API with explicit error handling - -> inject API into application/mihomo.RuntimeManager - -> RuntimeManager expresses typed status/start/stop intent only -``` - -`internal/mihomosupervisor` continues to own local process/filesystem policy and -the Unix transport. `internal/application/mihomo` continues to own selection, -artifact readiness, persistence and restart/rollback semantics. No protocol or -runtime policy moves into the executable. - -## 2. Constructor contract - -Use one application constructor: - -```go -var ErrRuntimeManagerConfiguration = errors.New("Mihomo runtime manager dependencies are invalid") - -func NewRuntimeManager( - root string, - store RuntimeStore, - artifacts ArtifactResolver, - core CoreStatusReader, - supervisor mihomosupervisor.API, -) (*RuntimeManager, error) -``` - -The root must be absolute and every interface is mandatory. Invalid input -returns an error wrapping the stable sentinel; it must never produce a manager -that can later panic. `Now` remains `time.Now` by default and tests may retain -the existing clock seam. - -## 3. Executable composition seam - -Add a small command-owned helper that returns `mihomosupervisor.API`: - -- empty socket path -> `mihomosupervisor.NewLocal(root)`; -- configured socket path -> `mihomosupervisor.NewClient(socketPath)`. - -Both constructors validate paths but neither starts a process nor connects to -the socket. Main handles helper failure before constructing the application -manager, then handles the application constructor error separately. Existing -exit-code and store-close conventions for dependency configuration failures are -retained. - -## 4. Tests - -- Application tests inject an in-memory fake implementing the typed API and - assert observed requests/state, eliminating application-test ownership of a - helper process. -- Constructor tests cover each missing dependency and a relative/empty root, - using `errors.Is` against the stable sentinel. -- Command tests call only the composition helper, assert local/client concrete - selection, and assert invalid paths fail. They do not call `Start`, contact a - Unix socket, or execute Mihomo. -- Existing `internal/mihomosupervisor` tests remain the concrete local/client - behavior boundary. - -## 5. Compatibility and safety - -Compose supplies an absolute socket and absolute data root, so its object graph -is unchanged except for using the unified constructor. An empty socket keeps -the current local development behavior. The change does not touch public data, -runtime stores, devices, network namespaces, RF, SIM state or communications. - -Rollback is a source-only revert of this task. No runtime migration or data -rollback exists. diff --git a/.trellis/tasks/archive/2026-08/08-15-inject-mihomo-supervisor-explicitly/implement.jsonl b/.trellis/tasks/archive/2026-08/08-15-inject-mihomo-supervisor-explicitly/implement.jsonl deleted file mode 100644 index 15db620..0000000 --- a/.trellis/tasks/archive/2026-08/08-15-inject-mihomo-supervisor-explicitly/implement.jsonl +++ /dev/null @@ -1,5 +0,0 @@ -{"file":".trellis/spec/core/backend/index.md","reason":"Root Go ownership, pre-development checklist and validation ladder."} -{"file":".trellis/spec/core/backend/directory-structure.md","reason":"Composition-root and application/supervisor package ownership rules."} -{"file":".trellis/spec/core/backend/application-boundaries.md","reason":"Consumer-owned typed dependency, constructor validation and error contracts."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Co-located fake/composition tests and safe targeted-to-broad validation."} -{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Cross-layer ownership and explicit contract review checklist."} diff --git a/.trellis/tasks/archive/2026-08/08-15-inject-mihomo-supervisor-explicitly/implement.md b/.trellis/tasks/archive/2026-08/08-15-inject-mihomo-supervisor-explicitly/implement.md deleted file mode 100644 index 9c0f2d3..0000000 --- a/.trellis/tasks/archive/2026-08/08-15-inject-mihomo-supervisor-explicitly/implement.md +++ /dev/null @@ -1,36 +0,0 @@ -# Implementation Plan - -## Phase A — Make the application dependency explicit - -- [x] Add the stable runtime-manager configuration error. -- [x] Replace both constructors with one validated constructor requiring the - typed supervisor API. -- [x] Update application tests to use a deterministic fake and cover invalid - dependencies without launching a process. - -## Phase B — Move concrete selection to the composition root - -- [x] Add a command-owned local/socket supervisor selection helper. -- [x] Update `cmd/simplusd/main.go` to handle selection and application - construction errors explicitly before continuing assembly. -- [x] Add non-connecting/non-executing command composition tests for both - branches and invalid paths. - -## Phase C — Validate and preserve the contract - -- [x] Run focused formatting and tests for `internal/application/mihomo`, - `internal/mihomosupervisor`, and `cmd/simplusd`, including race coverage. -- [x] Run supported Go vet/lint and broader non-HIL checks proportional to the - shared constructor change. -- [x] Run focused scans for hidden concrete construction, ignored errors and - stale constructor names; run task validation and `git diff --check`. -- [x] Update the backend boundary/directory specification after independent - review. -- [x] Commit the completed child task as `ffbc69b`; archive it through the - Trellis finish-work flow. - -## Prohibited validation - -Do not start Mihomo, `simplus-netd`, Compose or deployment services; do not -read private runtime stores; do not run hardware discovery, HIL, SMS/call, RF, -SIM/eUICC or other network/device actions. diff --git a/.trellis/tasks/archive/2026-08/08-15-inject-mihomo-supervisor-explicitly/prd.md b/.trellis/tasks/archive/2026-08/08-15-inject-mihomo-supervisor-explicitly/prd.md deleted file mode 100644 index e2c00d3..0000000 --- a/.trellis/tasks/archive/2026-08/08-15-inject-mihomo-supervisor-explicitly/prd.md +++ /dev/null @@ -1,76 +0,0 @@ -# Inject Mihomo supervisor explicitly - -## Goal - -Resolve audit finding V-04 by making `cmd/simplusd` the only owner of the -local-versus-socket Mihomo supervisor implementation choice and requiring the -application runtime manager to receive one explicit typed supervisor API. - -## Current Evidence - -- `internal/application/mihomo/runtime.go` currently imports the typed - supervisor API but its convenience constructor also calls - `mihomosupervisor.NewLocal(root)` and discards the returned error. -- `cmd/simplusd/main.go` constructs and injects a Unix-socket client only when - `SIMPLUS_MIHOMO_SUPERVISOR_SOCKET` is set; the empty-socket branch delegates - concrete local construction to the application package. -- Compose always sets the supervisor socket, so production behavior already - uses the intended netd boundary. The local implementation remains useful for - explicit development/Simulator execution and must not become a production - fallback. -- `internal/mihomosupervisor/local.go` owns bounded process and filesystem - policy. That implementation remains in place; only its assembly location is - changing. - -## Requirements - -- R1. Replace the two runtime-manager constructors with one constructor that - accepts a required `mihomosupervisor.API` and returns a stable configuration - error when any mandatory dependency is absent or the root is invalid. -- R2. Remove all concrete local-supervisor construction and ignored - constructor errors from `internal/application/mihomo` while preserving its - existing typed start/status/stop behavior and application-owned artifact - policy. -- R3. Make `cmd/simplusd` explicitly select and construct either the local - implementation for the existing empty-socket development mode or the Unix - client for a configured socket, then inject the result through the single - application constructor. -- R4. Treat both concrete-supervisor construction failures and application - dependency configuration failures as startup configuration failures: log a - bounded operational error, close the stores and exit non-zero. -- R5. Add command-level composition tests proving local and socket modes select - the expected implementation and reject invalid paths without starting a - Mihomo process or contacting a socket. -- R6. Refactor application runtime tests to inject a deterministic fake - supervisor. Preserve the lower package's existing local process tests as the - owner of concrete process behavior. -- R7. Update the backend architecture specification so future application - constructors cannot reintroduce hidden local/client defaults. - -## Acceptance Criteria - -- [x] AC1. No production code under `internal/application/mihomo` calls - `mihomosupervisor.NewLocal` or selects a concrete supervisor implementation. -- [x] AC2. There is one `NewRuntimeManager` constructor; it requires a typed - supervisor and returns an error for invalid root, store, artifact resolver, - core reader, or supervisor dependencies. -- [x] AC3. `cmd/simplusd` owns both local and socket construction branches, - handles every returned error, and injects the selected API explicitly. -- [x] AC4. Existing runtime start/status/stop/restart semantics and Compose's - socket-backed production path are unchanged. -- [x] AC5. Focused application and command tests cover the two composition - modes, invalid construction, and typed runtime behavior without launching a - real process from the application test. -- [x] AC6. Focused race tests, supported Go vet/lint checks, task validation, - focused ownership scans, and `git diff --check` pass without deployment, - Compose startup, private-state access, HIL or hardware/network side effects. - -## Out of Scope - -- Changing the Mihomo supervisor Unix protocol, request types, path policy, - process lifecycle or listener validation. -- Removing the local development/Simulator supervisor. -- Changing OpenAPI, Web, SQLite schema/data, generated files, container - privileges, Compose settings or Host VoWiFi behavior. -- Starting Mihomo, Compose, netd, hardware probes, HIL or any real network or - modem operation as validation. diff --git a/.trellis/tasks/archive/2026-08/08-15-inject-mihomo-supervisor-explicitly/task.json b/.trellis/tasks/archive/2026-08/08-15-inject-mihomo-supervisor-explicitly/task.json deleted file mode 100644 index 152ae4d..0000000 --- a/.trellis/tasks/archive/2026-08/08-15-inject-mihomo-supervisor-explicitly/task.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "id": "inject-mihomo-supervisor-explicitly", - "name": "inject-mihomo-supervisor-explicitly", - "title": "Inject Mihomo supervisor explicitly", - "description": "Resolve V-04 by assembling local/socket Mihomo supervisors in cmd/simplusd and injecting one required typed API into the application runtime manager.", - "status": "completed", - "dev_type": null, - "scope": "internal/application/mihomo/**, cmd/simplusd/**, .trellis/spec/core/backend/**, task-local artifacts", - "package": "core", - "priority": "P1", - "creator": "Leon", - "assignee": "Leon", - "createdAt": "2026-08-15", - "completedAt": "2026-08-15", - "branch": null, - "base_branch": "main", - "worktree_path": null, - "commit": null, - "pr_url": null, - "subtasks": [], - "children": [], - "parent": "08-14-audit-layer-boundaries", - "relatedFiles": [], - "notes": "", - "meta": {} -} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-08/08-15-inject-mihomo-supervisor-explicitly/validation.md b/.trellis/tasks/archive/2026-08/08-15-inject-mihomo-supervisor-explicitly/validation.md deleted file mode 100644 index b6dcdf5..0000000 --- a/.trellis/tasks/archive/2026-08/08-15-inject-mihomo-supervisor-explicitly/validation.md +++ /dev/null @@ -1,53 +0,0 @@ -# Implementation validation - -Validated on 2026-08-15 without starting Mihomo, netd, Compose, deployment -services, hardware discovery, HIL, communications, or device/network actions. - -## Passing checks - -- `go test ./internal/application/mihomo ./internal/mihomosupervisor ./cmd/simplusd` -- `go test -race ./internal/application/mihomo ./internal/mihomosupervisor ./cmd/simplusd` -- `go vet ./cmd/... ./internal/...` -- `make lint` -- `make check-format` -- `make check-docs` -- `go test ./cmd/... ./internal/...` -- `python3 ./.trellis/scripts/task.py validate .trellis/tasks/08-15-inject-mihomo-supervisor-explicitly` -- focused `rg` scans for stale constructors, hidden concrete application - construction, and ignored local-constructor errors -- `git diff --check` - -## Independent review - -- Confirmed `cmd/simplusd` is the only local-versus-socket Mihomo supervisor - selection point and both constructor failures return a nil API. -- Confirmed the single application constructor rejects invalid roots, nil and - typed-nil dependencies with `ErrRuntimeManagerConfiguration`. -- Confirmed every constructor caller is updated, application tests use an - in-memory supervisor fake, and command composition tests neither connect to - a socket nor start Mihomo. -- Confirmed Compose retains its absolute data root and socket-backed production - composition, and the runtime start/status/stop/restart implementation is - otherwise unchanged. -- No implementation or test defects were found during independent review. - -## Specification review - -- Confirmed the Mihomo composition scenario contains all seven required - sections and matches the constructor, environment, root, dependency, - error/exit, Compose and runtime ownership contracts. -- Clarified that application-owned selection means selected-subscription - intent, not concrete supervisor selection. -- Scoped constructor side-effect claims to supervisor filesystem, process and - socket I/O, and corrected the application-test contract to allow synthetic - artifact files while prohibiting process launch. -- Extended the correct example to handle the application constructor error, - close stores and exit non-zero. -- Reconciled the directory summary with the empty-socket development/Simulator - branch and configured Unix-socket client branch. - -## Completion - -- Product, tests, task evidence and specification were committed as - `ffbc69b` (`refactor: inject Mihomo supervisor explicitly`). -- The task is ready for Trellis archival and session-journal recording. diff --git a/.trellis/tasks/archive/2026-08/08-16-narrow-http-application-service-ports/check.jsonl b/.trellis/tasks/archive/2026-08/08-16-narrow-http-application-service-ports/check.jsonl deleted file mode 100644 index efca57f..0000000 --- a/.trellis/tasks/archive/2026-08/08-16-narrow-http-application-service-ports/check.jsonl +++ /dev/null @@ -1,5 +0,0 @@ -{"file":".trellis/tasks/08-16-narrow-http-application-service-ports/research.md","reason":"Independently verify every converted port is exact, concrete coupling is removed and behavior is preserved."} -{"file":".trellis/spec/core/backend/application-boundaries.md","reason":"Check consumer ownership, smallest method sets and no catch-all interface."} -{"file":".trellis/spec/core/backend/directory-structure.md","reason":"Check interface placement and concrete assembly remain in the intended owners."} -{"file":".trellis/spec/core/backend/api-contracts.md","reason":"Check Setup gates, HTTP mappings and authenticated realtime behavior for compatibility."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Check independent fakes, typed-nil regression coverage and safe validation completeness."} diff --git a/.trellis/tasks/archive/2026-08/08-16-narrow-http-application-service-ports/design.md b/.trellis/tasks/archive/2026-08/08-16-narrow-http-application-service-ports/design.md deleted file mode 100644 index 4963cce..0000000 --- a/.trellis/tasks/archive/2026-08/08-16-narrow-http-application-service-ports/design.md +++ /dev/null @@ -1,111 +0,0 @@ -# Design: HTTP-owned application service ports - -## 1. Boundary and ownership - -The runtime flow remains: - -```text -cmd/simplusd concrete assembly - -> httpapi consumer-owned interface - -> existing application implementation - -> its existing typed lower ports -``` - -`internal/api/httpapi` owns the interfaces because its handlers are the -consumer. The application packages continue to own their request/result values -and behavior. `cmd/simplusd` continues to select and inject concrete services. - -## 2. Port contracts - -Add four roles beside the current HTTP interfaces in `server.go`: - -```go -type HealthReader interface { - Snapshot(context.Context) (health.Snapshot, error) -} - -type SetupManager interface { - Status(context.Context) (setupapp.Status, error) - ConsumeBootstrap(context.Context, string) (setupapp.SessionGrant, error) - ReadSession(context.Context, string) (setupapp.Session, error) - ConfigureAdministrator(context.Context, string, setupapp.AdministratorInput) (setupapp.Session, error) - ConfigureStorage(context.Context, string, setupapp.StorageInput) (setupapp.Session, error) - ConfigureHTTPS(context.Context, string, setupapp.HTTPSInput) (setupapp.Session, error) - ConfirmHTTPS(context.Context, string, string) (setupapp.Session, error) - ReadRootCertificate(context.Context, string) ([]byte, string, error) - ConfirmHardwareReview(context.Context, string, setupapp.HardwareReviewInput) (setupapp.Session, error) - Complete(context.Context, string, setupapp.HardwareReviewInput) (setupapp.Completion, error) - BeginAdministratorSetup(context.Context) (setupapp.SessionGrant, error) -} - -type InventoryReader interface { - Snapshot(context.Context) (inventory.Snapshot, error) - Topology(context.Context) (inventory.Topology, error) -} - -type RealtimeManager interface { - Subscribe() *realtime.Subscription - Publish([]realtime.Topic, realtime.Attention) -} -``` - -The exact names above are part of the planned contract. The Setup method set is -larger than the other ports because HTTP owns the full interactive Setup flow; -it intentionally excludes the control-only `GenerateBootstrap` and -`ProvisionAdministrator` operations. - -## 3. Construction and compatibility - -- Change only the first three `New` parameter types and corresponding `Server` - fields. Preserve argument order, variadic contacts, logger default, return - type, and all call sites. -- Change `WithRealtime` and the `realtime` field to `RealtimeManager`. The - existing `*realtime.Hub` structurally satisfies the port. -- Store interface values unchanged so concrete Health/Setup/Inventory nil - receivers can continue returning their stable configuration errors. Use one - boundary-local typed-nil-aware absence predicate only in Login, realtime - authorization, publication, and stream availability checks. This preserves - pointer-era optional checks without validating or constructing dependencies. -- Keep application-owned DTOs in signatures. This repair removes concrete - implementation coupling, not legitimate typed application vocabulary. - -No OpenAPI, generated code, storage schema/data, environment option, process -lifecycle, handler behavior, or application implementation changes. - -## 4. Test design - -Add a co-located `internal/api/httpapi/server_ports_test.go` (or an equivalent -focused section in the existing test) with: - -- compile-time assertions that the four production implementations satisfy the - HTTP ports; -- independent fake implementations proving `New`/`WithRealtime` do not require - concrete service values; -- bounded calls proving fake realtime Subscribe and Publish are reached; -- raw-nil and typed-nil cases for all four converted dependencies, covering - Setup/realtime availability checks and Health/Setup/Inventory nil-receiver - error dispatch separately. - -Retain the existing `httptest` suite as behavior evidence for Setup gates, -Health/Inventory mapping, authenticated SSE, heartbeat/session expiry, and -publication. Tests must use synthetic channels/stores only. - -## 5. Architecture verification - -Focused scans must show no `*health.Service`, `*setup.Service`, -`*inventory.Service`, or `*realtime.Hub` in production `httpapi` fields or -constructor/option parameters, while every new port method has a live handler -call. `cmd/simplusd` must remain the sole production assembly point and import -no new adapter. - -## 6. Risk and rollback - -The primary risk is typed-nil interface drift: plain interface equality loses -optional absence checks, while normalization to true nil loses deliberate -nil-receiver error dispatch. A boundary-local predicate plus both regression -classes closes it. A secondary risk is widening the Setup port by copying every -exported method; the live-call matrix and an explicit negative scan for the two -excluded methods close that risk. - -Rollback is a source-only revert. There is no migration or external state to -undo. diff --git a/.trellis/tasks/archive/2026-08/08-16-narrow-http-application-service-ports/implement.jsonl b/.trellis/tasks/archive/2026-08/08-16-narrow-http-application-service-ports/implement.jsonl deleted file mode 100644 index 11c6317..0000000 --- a/.trellis/tasks/archive/2026-08/08-16-narrow-http-application-service-ports/implement.jsonl +++ /dev/null @@ -1,7 +0,0 @@ -{"file":".trellis/tasks/08-16-narrow-http-application-service-ports/research.md","reason":"Exact C-01 concrete wiring, live method matrix, compatibility constraints and test gap."} -{"file":".trellis/spec/core/backend/index.md","reason":"Root Go dependency direction and pre-development checklist."} -{"file":".trellis/spec/core/backend/application-boundaries.md","reason":"Consumer-owned typed-port rule and adjacent application/realtime ownership."} -{"file":".trellis/spec/core/backend/directory-structure.md","reason":"HTTP ownership, cmd composition, interface placement and co-located test rules."} -{"file":".trellis/spec/core/backend/api-contracts.md","reason":"Existing HTTP, Setup gate and authenticated realtime behavior that must remain unchanged."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Trusted httptest/realtime patterns and safe targeted-to-broad validation ladder."} -{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Cross-layer ownership and boundary verification checklist."} diff --git a/.trellis/tasks/archive/2026-08/08-16-narrow-http-application-service-ports/implement.md b/.trellis/tasks/archive/2026-08/08-16-narrow-http-application-service-ports/implement.md deleted file mode 100644 index 60a256e..0000000 --- a/.trellis/tasks/archive/2026-08/08-16-narrow-http-application-service-ports/implement.md +++ /dev/null @@ -1,90 +0,0 @@ -# Implementation Plan - -## Phase A — Define the HTTP consumer ports - -- [x] Add `HealthReader`, `SetupManager`, `InventoryReader`, and - `RealtimeManager` with only the researched live method sets. -- [x] Replace the four concrete `Server` fields and the matching `New` / - `WithRealtime` parameter types without changing argument order or behavior. -- [x] Keep interface values intact and use a typed-nil-aware absence predicate - at the four optional Setup/realtime checks, preserving nil-receiver errors. - -## Phase B — Prove compatibility and narrowness - -- [x] Add production structural assertions and independent interface fakes in - the HTTP package. -- [x] Prove fake-only construction plus realtime Subscribe/Publish delegation. -- [x] Cover raw-nil and typed-nil health/setup/inventory/realtime inputs plus - Health/Setup/Inventory nil-receiver error dispatch. -- [x] Retain all existing router/handler/SSE tests unchanged except for any - mechanical fixture typing required by the new seam. - -## Phase C — Validate boundaries and capture the contract - -- [x] Run focused HTTP and command tests, then focused race and the supported - broad Go test/vet/lint scope. -- [x] Run ownership scans for removed concrete pointers, exact live port - methods, unchanged composition, and absence of OpenAPI/generated drift. -- [x] Run formatting, docs/generated checks, task validation, and - `git diff --check`. -- [x] Complete an independent Trellis check, then synchronize the verified - HTTP consumer-port rule into the relevant backend specs. -- [x] Commit, archive the child task, update the parent audit task record, and - record the Trellis session journal. - -## Validation Commands - -Safe commands may include: - -```bash -go test -count=1 ./internal/api/httpapi ./cmd/simplusd -go test -count=1 -race ./internal/api/httpapi ./cmd/simplusd -go test -count=1 ./cmd/... ./internal/... -go vet ./cmd/... ./internal/... -make check-format -make lint -make verify-generated -make check-docs -python3 ./.trellis/scripts/task.py validate .trellis/tasks/08-16-narrow-http-application-service-ports -git diff --check -``` - -## Prohibited Validation - -Do not start services or Compose, contact external endpoints, inspect private -runtime state, access a modem/SIM, send SMS/calls, change RF, write persistent -device state, or run HIL. This source-boundary refactor requires no live -environment evidence. - -## Risky Files and Rollback Points - -- `internal/api/httpapi/server.go`: interface method signatures and the - typed-nil-aware optional-availability predicate are the main compile/behavior - risk. -- `internal/api/httpapi/server_ports_test.go` or `server_test.go`: fakes must - record observable calls rather than copy production behavior. -- `.trellis/spec/core/backend/application-boundaries.md` and - `directory-structure.md`: update only after independent verification so the - recorded contract matches the final code. - -Rollback is a source-only revert; no migration or external cleanup is needed. - -## Implementation Validation Notes - -- Independent review found no attributable defect and made no code/test edit. -- Focused HTTP and `cmd/simplusd` tests plus their uncached race runs passed. -- Uncached `go test -count=1 ./cmd/... ./internal/...`, `go vet`, and - `make lint` (vet plus locked actionlint) passed. -- `make check-format`, `make verify-generated`, `make check-docs`, Trellis task - validation, ownership/method-set/composition scans, and `git diff --check` - passed before final spec synchronization. -- Existing `internal/api/httpapi/server_test.go` and production - `cmd/simplusd/main.go` required no edits. -- Full `make test` and Web E2E were intentionally not run: this backend-only - change has no Web contract, and the repository target starts a development - Simulator service, which the task safety boundary prohibits. The complete - affected Go scope and generated-contract checks passed instead. -- No service, Compose, external endpoint, private runtime state, hardware, HIL, - RF, SIM/eUICC, SMS, or call action was used. -- Functional work was committed as `aa8b41f`; task archival and journal - recording are completed by the Trellis finish-work flow. diff --git a/.trellis/tasks/archive/2026-08/08-16-narrow-http-application-service-ports/prd.md b/.trellis/tasks/archive/2026-08/08-16-narrow-http-application-service-ports/prd.md deleted file mode 100644 index f6331fe..0000000 --- a/.trellis/tasks/archive/2026-08/08-16-narrow-http-application-service-ports/prd.md +++ /dev/null @@ -1,81 +0,0 @@ -# Narrow HTTP application service ports - -## Goal - -Remove the HTTP boundary's compile-time dependency on the complete concrete -health, setup, inventory, and realtime application implementations. The HTTP -server should depend only on consumer-owned ports for the operations it uses, -without changing public API behavior, application behavior, or runtime -composition. - -## Background - -The layer-boundary audit recorded C-01 as a Low architecture concern. The -composition root currently injects `*health.Service`, `*setup.Service`, -`*inventory.Service`, and `*realtime.Hub`; `internal/api/httpapi.Server` stores -those concrete pointers even though its other adjacent application dependencies -are expressed as HTTP-owned interfaces. This is not a direct storage, hardware, -or transport bypass, but it unnecessarily couples the HTTP package to complete -implementations. - -Current evidence is recorded in -`.trellis/tasks/08-14-audit-layer-boundaries/audit.md:198` and the live fields, -constructor, option, and calls in `internal/api/httpapi/server.go`. - -## Requirements - -- R-01: Define HTTP-owned interfaces containing only the health, setup, - inventory, and realtime operations the HTTP server actually invokes. -- R-02: Change `Server`, `New`, and the realtime option/assembly seam to accept - those interfaces instead of concrete pointers. -- R-03: Preserve the existing application DTOs, error mapping, request/response - behavior, authentication/setup gating, realtime topics, heartbeat behavior, - and composition-root implementation choices. -- R-04: Keep each port cohesive and consumer-specific; do not introduce a - shared catch-all interface or move application behavior into HTTP. -- R-05: Add compile-time test fakes or assertions that prove the HTTP server can - be assembled without depending on concrete service values. -- R-06: Keep validation synthetic and offline; no service startup, Compose, - external network, modem, RF, SMS/call, or HIL action is authorized. -- R-07: Preserve both existing nil behaviors when concrete pointers become - interfaces: optional Setup/realtime availability checks must treat typed nil - as absent, while direct Health/Setup/Inventory handler calls must retain the - stable errors returned by their nil-receiver methods. - -## Out of Scope - -- Changing OpenAPI schemas, routes, status codes, payloads, or Web behavior. -- Refactoring the internals of health, setup, inventory, or realtime services. -- Changing stores, hardware adapters, supervisors, deployment, or runtime - lifecycle policy. -- Addressing the separate dormant `resourcelease` SQLite-type concern (C-02). - -## Acceptance Criteria - -- [ ] `internal/api/httpapi.Server` has no concrete `*health.Service`, - `*setup.Service`, `*inventory.Service`, or `*realtime.Hub` field. -- [ ] HTTP construction accepts narrow consumer-owned ports and production - assembly still injects the existing concrete implementations. -- [ ] Every interface method corresponds to a live HTTP call; no unused or - lower-layer operation is exposed through the new ports. -- [ ] Existing HTTP router/handler tests pass, and focused interface-fake tests - cover the changed construction boundary including realtime subscription and - publication. -- [ ] Raw and typed-nil dependencies remain unavailable to optional - Setup/realtime branches, and typed-nil Health/Setup/Inventory implementations - retain their pre-refactor nil-receiver error dispatch. -- [ ] Focused and repository-required formatting, lint/type, generated/docs, - task validation, ownership scans, and diff checks pass without live runtime - or hardware access. - -## Technical Notes - -- Concrete application DTO types may remain in method signatures; the concern - is dependency on complete implementations, not use of application-owned - request/result vocabulary. -- Use four cohesive ports: one each for health, Setup workflow, inventory, and - realtime subscription/publication. The Setup port deliberately excludes - `GenerateBootstrap` and `ProvisionAdministrator`, which HTTP does not call. -- Keep the current `New` argument order/return shape and the optional - `WithRealtime` assembly shape. A dependency-object migration or constructor - validation redesign is not part of this concern. diff --git a/.trellis/tasks/archive/2026-08/08-16-narrow-http-application-service-ports/research.md b/.trellis/tasks/archive/2026-08/08-16-narrow-http-application-service-ports/research.md deleted file mode 100644 index 7e1b94b..0000000 --- a/.trellis/tasks/archive/2026-08/08-16-narrow-http-application-service-ports/research.md +++ /dev/null @@ -1,73 +0,0 @@ -# Research: HTTP application service coupling - -## Baseline - -- Reviewed commit: `19225e92a3f187cf7b2d7b0b428901906441e01c`. -- Parent finding: C-01 in - `.trellis/tasks/08-14-audit-layer-boundaries/audit.md:198`. -- Severity is Low: no direct storage, hardware, supervisor, provider, or raw - transport call was found in `internal/api/httpapi`. - -At the baseline, `internal/api/httpapi/server.go:163-185` stores -`*health.Service`, `*setup.Service`, `*inventory.Service`, and -`*realtime.Hub`. `New` requires the first three concrete pointers at -`server.go:274-293`; `WithRealtime` requires the Hub at `server.go:254-259`. -Production injects those implementations at `cmd/simplusd/main.go:335-354`. - -## Exact live HTTP method surface - -| Proposed consumer role | Live methods called by HTTP | Baseline call anchors | -| --- | --- | --- | -| Health reader | `Snapshot(context.Context) (health.Snapshot, error)` | `server.go:3031` | -| Setup manager | `Status`, `ConsumeBootstrap`, `ReadSession`, `ConfigureAdministrator`, `ConfigureStorage`, `ConfigureHTTPS`, `ConfirmHTTPS`, `ReadRootCertificate`, `ConfirmHardwareReview`, `Complete`, `BeginAdministratorSetup` with their existing `setup` values | `server.go:436,669-926,1018,1145-1152` | -| Inventory reader | `Snapshot(context.Context) (inventory.Snapshot, error)`, `Topology(context.Context) (inventory.Topology, error)` | `server.go:842-869,1226,2844` | -| Realtime manager | `Subscribe() *realtime.Subscription`, `Publish([]realtime.Topic, realtime.Attention)` | `server.go:390,486` | - -Repository search confirms HTTP does not call Setup's `GenerateBootstrap` or -`ProvisionAdministrator`. The former appears only in HTTP integration-test -fixture setup through a concrete local variable, not through `Server`. - -## Existing behavior to preserve - -- `cmd/simplusd` remains the composition root and can inject the existing - concrete values by structural interface satisfaction; no wrapper adapter is - needed. -- Setup status continues to gate business routes, login bootstrap-session - creation, and realtime session revalidation. -- Realtime remains one bounded topic/attention mechanism with one initial - resync subscription and non-blocking publication semantics owned by - `internal/application/realtime`. -- Public OpenAPI, cookies, error/status mapping, handler timeouts, SSE wire - shape, application DTOs, stores, and generated outputs do not change. -- Existing concrete pointer parameters have two distinct nil behaviors: - Login/realtime availability checks treat nil Setup/Hub pointers as absent, - while Health, Setup Status, and Inventory methods deliberately accept nil - receivers and return stable configuration errors. Go interface conversion - can hide nil from equality checks, but replacing the interface with true nil - would also destroy nil-receiver dispatch. The refactor therefore needs a - typed-nil-aware absence predicate only at optional availability branches. - -## Test evidence and gaps - -- `internal/api/httpapi/server_test.go` already exercises the real router, - Setup flow/gates, Inventory/Health mapping, authenticated SSE subscription, - session expiry, and realtime publication using concrete implementations. -- The missing proof is architectural: no test assembles the HTTP server from - independent fakes for these four roles, and no assertion prevents the fields - or parameters from returning to concrete pointers. -- A co-located port test can provide structural assertions for production - implementations, independent fake implementations, bounded fake - subscribe/publish calls, optional raw/typed-nil absence handling, and - nil-receiver error preservation without starting a server or external - runtime. - -## Planning conclusion - -Define exactly four HTTP-owned interfaces beside the existing HTTP consumer -interfaces. Keep one Setup workflow interface even though it has eleven -methods: all eleven are live calls of the same boundary consumer, while -splitting it would require injecting the same concrete service repeatedly and -would not reduce authority. Do not create an adapter package, shared service -interface package, dependency-object migration, or application-layer change. - -No user-owned product, compatibility, UX, scope, or risk decision remains. diff --git a/.trellis/tasks/archive/2026-08/08-16-narrow-http-application-service-ports/task.json b/.trellis/tasks/archive/2026-08/08-16-narrow-http-application-service-ports/task.json deleted file mode 100644 index 6e3c168..0000000 --- a/.trellis/tasks/archive/2026-08/08-16-narrow-http-application-service-ports/task.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "id": "narrow-http-application-service-ports", - "name": "narrow-http-application-service-ports", - "title": "Narrow HTTP application service ports", - "description": "Replace four concrete HTTP application-service pointers with exact consumer-owned ports while preserving public and runtime behavior.", - "status": "completed", - "dev_type": null, - "scope": null, - "package": "core", - "priority": "P2", - "creator": "Leon", - "assignee": "Leon", - "createdAt": "2026-08-16", - "completedAt": "2026-08-17", - "branch": null, - "base_branch": "main", - "worktree_path": null, - "commit": null, - "pr_url": null, - "subtasks": [], - "children": [], - "parent": "08-14-audit-layer-boundaries", - "relatedFiles": [], - "notes": "", - "meta": {} -} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-08/08-17-resolve-resourcelease-storage-coupling/check.jsonl b/.trellis/tasks/archive/2026-08/08-17-resolve-resourcelease-storage-coupling/check.jsonl deleted file mode 100644 index 9863908..0000000 --- a/.trellis/tasks/archive/2026-08/08-17-resolve-resourcelease-storage-coupling/check.jsonl +++ /dev/null @@ -1,9 +0,0 @@ -{"file":".trellis/tasks/08-17-resolve-resourcelease-storage-coupling/research.md","reason":"Independently verify complete reference inventory, selected deletion scope and absence of a production caller."} -{"file":".trellis/tasks/08-14-audit-layer-boundaries/audit.md","reason":"Check the final repair closes C-02 without broadening beyond its minimum remediation."} -{"file":".trellis/spec/core/backend/application-boundaries.md","reason":"Verify no application contract exposes storage-owned lease vocabulary or replacement bypass."} -{"file":".trellis/spec/core/backend/directory-structure.md","reason":"Verify package deletion and retained storage ownership match the repository layout contract."} -{"file":".trellis/spec/core/backend/storage-and-migrations.md","reason":"Verify migration 00005, stored data compatibility and SQLite evidence remain intact."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Check focused SQLite, broad Go and safe offline validation completeness."} -{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Review the full application-to-storage direction and adjacent surfaces for scope drift."} -{"file":"docs/architecture.md","reason":"Confirm current product text distinguishes retired orchestration from retained historical storage."} -{"file":"docs/decisions/0001-product-scope-reset.md","reason":"Confirm the repair follows rather than rewrites the historical product reset."} diff --git a/.trellis/tasks/archive/2026-08/08-17-resolve-resourcelease-storage-coupling/design.md b/.trellis/tasks/archive/2026-08/08-17-resolve-resourcelease-storage-coupling/design.md deleted file mode 100644 index 3c3ace9..0000000 --- a/.trellis/tasks/archive/2026-08/08-17-resolve-resourcelease-storage-coupling/design.md +++ /dev/null @@ -1,85 +0,0 @@ -# Design: retire dormant resource-lease application orchestration - -## 1. Boundary outcome - -The current invalid direction is dormant but explicit: - -```text -application/resourcelease contract - -> storage/sqlite ResourceLease types and kind constants -``` - -The repair removes that unused application contract. It does not replace it -with another port because no production consumer exists: - -```text -production runtime: unchanged (no resourcelease application path) - -storage/sqlite historical repository + embedded runtime v5 migration - -> retained only as isolated storage compatibility/fixture code -``` - -Any future lease feature must start with its own application/domain vocabulary -and map to persistence explicitly. The retained SQLite types are not an -approved application API. - -## 2. Exact source scope - -Delete the complete package: - -- `internal/application/resourcelease/service.go` -- `internal/application/resourcelease/service_test.go` -- `internal/application/resourcelease/umask_test.go` - -Do not add a replacement constructor, facade, alias, command, API route, or -composition-root assembly. No active package currently imports these files, so -the deletion has no call-site migration. - -Retain without behavior changes: - -- `internal/storage/sqlite/resource_leases.go` -- `internal/storage/sqlite/resource_leases_test.go` -- `internal/storage/sqlite/migrations/runtime/00005_resource_group_leases.sql` -- the independent `internal/agentapi` outcome/fencing ledger. - -## 3. Documentation and specification ownership - -Update only current architecture claims that still group all lease code as -available application infrastructure. State precisely that the application -orchestrator is retired while the released runtime schema and SQLite fixture -remain for compatibility. Keep ADR 0001 as historical decision evidence; its -“not immediate deletion” statement remains true and should not be rewritten. - -After implementation and independent checking, capture these durable rules in -the backend specs: - -- application contracts must not expose storage-owned values even when the - repository itself is abstracted; -- the old resource-lease orchestrator is not a supported application package; -- runtime migration 00005 and the SQLite lease repository are dormant - historical storage infrastructure, not a source for new application types; -- released migrations are not edited as part of dead-code retirement. - -## 4. Compatibility and validation - -Static scans must prove the package is absent, no import/caller/replacement was -introduced, and the retained storage/migration files have no implementation -diff. Focused real-SQLite tests preserve the historical repository behavior; -the supported Go package test/vet/lint scope proves the deletion did not hide a -caller. Documentation, generation-drift, task-context, formatting, and diff -checks complete the source-only validation. - -No service, Compose stack, external endpoint, private runtime database, modem, -SIM, RF, SMS/call, persistent device write, or HIL evidence is needed or -authorized. - -## 5. Risks and rollback - -The main risk is deleting a hidden caller. Repository-wide current/import -scans, the initial-release history scan, and broad compilation close that -risk. The second risk is accidentally treating a historical table as safe to -drop; keeping the migration and storage implementation byte-for-byte avoids -schema/data impact. - -Rollback is a source-only restoration of the deleted application package and -documentation. No external or database rollback is required. diff --git a/.trellis/tasks/archive/2026-08/08-17-resolve-resourcelease-storage-coupling/implement.jsonl b/.trellis/tasks/archive/2026-08/08-17-resolve-resourcelease-storage-coupling/implement.jsonl deleted file mode 100644 index b43115a..0000000 --- a/.trellis/tasks/archive/2026-08/08-17-resolve-resourcelease-storage-coupling/implement.jsonl +++ /dev/null @@ -1,9 +0,0 @@ -{"file":".trellis/tasks/08-17-resolve-resourcelease-storage-coupling/research.md","reason":"Complete C-02 live-reference, history, migration, documentation and option inventory."} -{"file":".trellis/spec/core/backend/index.md","reason":"Root Go dependency direction and pre-development checklist."} -{"file":".trellis/spec/core/backend/application-boundaries.md","reason":"Application consumer-owned port/value rules and prohibition on persistence-owned contracts."} -{"file":".trellis/spec/core/backend/directory-structure.md","reason":"Application, storage, composition-root and co-located test ownership."} -{"file":".trellis/spec/core/backend/storage-and-migrations.md","reason":"Released migration immutability, embedded runtime schema and real-SQLite compatibility testing."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Targeted-to-broad Go validation and synthetic no-hardware test constraints."} -{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Cross-layer ownership and boundary verification checklist."} -{"file":"docs/architecture.md","reason":"Canonical current product scope for generic ResourceGroup leases and historical fixture treatment."} -{"file":"docs/decisions/0001-product-scope-reset.md","reason":"Historical decision not to extend lease/fencing infrastructure into current SMS/Call flows."} diff --git a/.trellis/tasks/archive/2026-08/08-17-resolve-resourcelease-storage-coupling/implement.md b/.trellis/tasks/archive/2026-08/08-17-resolve-resourcelease-storage-coupling/implement.md deleted file mode 100644 index 9dc6b80..0000000 --- a/.trellis/tasks/archive/2026-08/08-17-resolve-resourcelease-storage-coupling/implement.md +++ /dev/null @@ -1,107 +0,0 @@ -# Retire dormant resource-lease application orchestration: execution plan - -## Phase A — Remove the invalid dormant contract - -- [x] Delete `internal/application/resourcelease/service.go` and its two - package-local test files. -- [x] Confirm no production/test caller outside the deleted package needs a - replacement and add no compatibility facade or new assembly. -- [x] Confirm the SQLite repository, focused tests, released runtime migration, - and independent Agent outcome ledger are untouched. - -## Phase B — Align current architecture documentation - -- [x] Update `docs/architecture.md` to distinguish the retired application - orchestrator from the retained SQLite/runtime-migration historical fixture. -- [x] Preserve ADR 0001's historical decision text and avoid claiming the - dormant store is an active production capability. - -## Phase C — Validate source, behavior and compatibility - -- [x] Run focused SQLite tests to retain replay/fencing/reopen evidence. -- [x] Run the supported broad Go test/vet/lint scope to prove no hidden caller - or compile dependency remains. -- [x] Run scans for the deleted package/import, forbidden storage-owned - application lease types, retained migration/storage paths, and unchanged - composition/API/generated surfaces. -- [x] Run formatting, generated/docs, task validation, and `git diff --check`. - -## Phase D — Review and capture the rule - -- [x] Complete an independent Trellis check of deletion scope, migration/data - compatibility, documentation accuracy, and validation evidence. -- [x] Synchronize the verified application/storage ownership and historical - fixture contract into the relevant backend specs. -- [x] Commit, archive the child task, update the parent audit task record, and - record the Trellis session journal. - -## Validation commands - -Safe commands may include: - -```bash -test ! -e internal/application/resourcelease -rg -n 'internal/application/resourcelease|resourcelease\.|storage\.(ResourceLease|ResourceLeaseAcquire|ResourceLeaseOperation|ResourceLeaseCall)' cmd internal --glob '*.go' -go test -count=1 ./internal/storage/sqlite -go test -count=1 ./cmd/... ./internal/... -go vet ./cmd/... ./internal/... -make check-format -make lint -make verify-generated -make check-docs -python3 ./.trellis/scripts/task.py validate .trellis/tasks/08-17-resolve-resourcelease-storage-coupling -git diff --check -``` - -Inspect `git diff -- internal/storage/sqlite/resource_leases.go -internal/storage/sqlite/resource_leases_test.go -internal/storage/sqlite/migrations/runtime/00005_resource_group_leases.sql` and -require it to be empty. - -## Prohibited validation - -Do not start services or Compose, contact external endpoints, inspect private -runtime state, access a modem/SIM, send SMS/calls, change RF, write persistent -device state, or run HIL. This is a source-only boundary retirement. - -## Risky files and rollback points - -- `internal/application/resourcelease/`: delete the complete package; leaving a - partial test/helper or moving its policy elsewhere would not complete the - retirement. -- `docs/architecture.md`: describe current state without rewriting the - historical ADR or implying the retained SQLite fixture is production-wired. -- `.trellis/spec/core/backend/application-boundaries.md`, - `directory-structure.md`, and `storage-and-migrations.md`: update only after - independent verification so the durable contract matches the final code. - -Rollback is a source-only revert; no migration, data restoration, service -cleanup, or hardware action is involved. - -## Implementation evidence (2026-08-18) - -- Deleted the complete `internal/application/resourcelease` package and added - no replacement package, adapter, caller, API, command, or composition-root - assembly. -- Updated the current-state ResourceGroup lease paragraph and technical-debt - principle in `docs/architecture.md`; ADR 0001 remains unchanged. -- The retained SQLite source, focused test, and runtime migration 00005 still - have their pre-implementation SHA-256 values `4f39108e...`, `684fa62b...`, - and `30cc22b5...`; their protected `git diff` is empty. The independent - Agent ledger, API/generated sources, and production composition also have no - scoped implementation diff. -- Passed `go test -count=1 ./internal/storage/sqlite`, - `go test -count=1 ./cmd/... ./internal/...`, - `go vet ./cmd/... ./internal/...`, `make check-format`, `make lint`, - `make verify-generated`, `make check-docs`, task-context validation, and - `git diff --check` using only offline/source-local validation. -- Task-context validation reports only informational size warnings for three - curated files and otherwise passes all nine implement and nine check - entries. -- Independent spec follow-up confirmed the new application-boundary scenario - has all seven code-spec sections and quotes the current SQLite signatures. - It removed an unsupported claim that the retained artifacts predate the - product reset, avoided prescribing a speculative future application API, - and synchronized backend-index routing without changing product code. -- Functional work was committed as `1fe33a7`; task archival and journal - recording are completed by the Trellis finish-work flow. diff --git a/.trellis/tasks/archive/2026-08/08-17-resolve-resourcelease-storage-coupling/prd.md b/.trellis/tasks/archive/2026-08/08-17-resolve-resourcelease-storage-coupling/prd.md deleted file mode 100644 index 7df5b45..0000000 --- a/.trellis/tasks/archive/2026-08/08-17-resolve-resourcelease-storage-coupling/prd.md +++ /dev/null @@ -1,80 +0,0 @@ -# Resolve resource lease storage coupling - -## Goal - -Retire the dormant `internal/application/resourcelease` orchestration package, -which exposes SQLite-owned command/record/kind types through an application -contract. Preserve the released runtime-database migration and the isolated -SQLite repository implementation as historical compatibility infrastructure, -without activating a new production capability or changing current runtime -behavior. - -## Background - -The layer-boundary audit recorded C-02 as a Low architecture concern. The -application package defines a repository abstraction but exposes -`internal/storage/sqlite.ResourceLease*` types in its own service contract. -Repository-wide audit evidence found no production constructor or caller, so -this is dormant coupling rather than an active runtime bypass. - -Current evidence starts at -`.trellis/tasks/08-14-audit-layer-boundaries/audit.md:207` and -`internal/application/resourcelease/service.go`. - -## Requirements - -- R-01: Trace every source, test, migration, generated query, documentation, - and production reference to resource leases before selecting removal or - decoupling. -- R-02: Do not reactivate resource leasing, add a new API/command, or change - current product/runtime behavior as part of this boundary repair. -- R-03: Remove the unreachable application package and its package-local tests. - Do not move its policy into another active layer or retain a compatibility - facade for a contract with no caller. -- R-04: Preserve current migration and downgrade compatibility unless evidence - proves a schema artifact is both unreachable and safe to remove under the - project's migration policy. -- R-05: Keep validation offline and synthetic; no service, Compose, external - endpoint, modem, RF, SMS/call, device write, or HIL action is authorized. -- R-06: Keep `internal/storage/sqlite/resource_leases.go`, its focused tests, - and runtime migration 00005 unchanged as a storage-owned historical fixture; - future reactivation requires a separately designed application-owned model - and SQLite mapping rather than reuse of the retired cross-layer contract. - -## Out of Scope - -- Designing or enabling a new resource-leasing product feature. -- Refactoring active Line, Modem, Agent, SMS, Call, Setup, or realtime flows. -- Rewriting historical migrations or deleting persisted user data without a - separately approved migration decision. -- Removing the independent Agent `radio.ensure-off` outcome/fencing ledger, - which has a similarly named private table but is not this application - package or runtime-database repository. - -## Acceptance Criteria - -- [ ] No application contract exposes a concrete SQLite-owned resource-lease - type. -- [ ] `internal/application/resourcelease` and its package-local tests no longer - exist, and no replacement production assembly or caller is introduced. -- [ ] The chosen repair is supported by a complete live-reference and - compatibility inventory. -- [ ] Production behavior, public/internal APIs, generated contracts, and - existing data compatibility remain unchanged. -- [ ] The released runtime migration and isolated SQLite resource-lease - repository/tests remain unchanged and continue to pass their focused tests. -- [ ] Focused tests/scans plus required repository checks pass without live - runtime or hardware access. - -## Decision - -- Retire only the dormant application orchestrator. Repository-wide current and - initial-release scans find no production constructor or caller, and the - canonical product architecture explicitly rejects extending generic - ResourceGroup leases into current SMS/Call flows. -- Retain the SQLite repository, focused tests, and already released runtime v5 - migration. This avoids rewriting migration history or deleting persisted - data while keeping the repair limited to the actual cross-layer contract. -- Do not introduce application-owned lease types speculatively. If a future - feature needs leasing, design its current business contract first and add an - explicit mapping in a new approved task. diff --git a/.trellis/tasks/archive/2026-08/08-17-resolve-resourcelease-storage-coupling/research.md b/.trellis/tasks/archive/2026-08/08-17-resolve-resourcelease-storage-coupling/research.md deleted file mode 100644 index 6da59db..0000000 --- a/.trellis/tasks/archive/2026-08/08-17-resolve-resourcelease-storage-coupling/research.md +++ /dev/null @@ -1,79 +0,0 @@ -# Research: dormant ResourceGroup lease coupling - -## Baseline and finding - -- Reviewed commit: `06316b169f61fd924c30cd46e652f380eb728a2b`. -- Parent finding: C-02 in - `.trellis/tasks/08-14-audit-layer-boundaries/audit.md:207`. -- Severity is Low because the coupling is compile-time dormant: no production - binary constructs or calls the application service. - -`internal/application/resourcelease/service.go` declares a `Repository`, but -the port accepts and returns `storage/sqlite.ResourceLeaseAcquire` and -`storage/sqlite.ResourceLease`, and its validation branches on SQLite-owned -lease-kind constants. This makes persistence vocabulary the application -contract even though the package presents an interface. - -## Complete reference inventory - -| Surface | Evidence | Disposition | -| --- | --- | --- | -| Application orchestration | `internal/application/resourcelease/service.go` owns topology generation/capability policy, purpose rules, lease-ID generation, and the SQLite-typed repository contract. | Delete as unreachable current-product policy. | -| Application tests | `service_test.go` is the only caller of the service and uses a temporary real SQLite set plus Simulator topology; `umask_test.go` exists only for those fixtures. | Delete with the package. | -| Production assembly/callers | Current repository-wide Go/import scans find none outside the package tests. A `git grep` at the initial public release also finds no external application caller. | No runtime replacement or compatibility adapter is needed. | -| SQLite repository | `internal/storage/sqlite/resource_leases.go` owns the historical request/record types and Acquire/Renew/Release/Active SQL. Its only consumers are its own focused tests and the package being retired. | Retain unchanged as an isolated storage-owned historical fixture. | -| SQLite tests | `internal/storage/sqlite/resource_leases_test.go` covers replay, fencing, expiry/reopen, conflicts, and concurrent acquisition against real temporary databases. | Retain as migration/repository compatibility evidence. | -| Released migration | `internal/storage/sqlite/migrations/runtime/00005_resource_group_leases.sql` creates the three runtime tables and has a Down path to v4. It is embedded and applied by normal `OpenSet`. | Retain byte-for-byte; do not rewrite released history or drop user data. | -| Generated/API/Web | No OpenAPI, generated Go/TypeScript, sqlc query, Web, command, or environment surface refers to this application package or its lease types. Runtime migrations are hand-written/embedded, not sqlc inputs. | No generated or public contract change. | -| Product documentation | `docs/architecture.md:252,258,358` rejects a generic lease/fencing platform for current actions, distinguishes the retired application orchestrator from the retained migration/SQLite fixture, and records the same boundary in the current technical-debt principles. ADR 0001 records the historical product reset. | Align every current architecture statement with targeted application-package retirement; preserve ADR history. | -| Similar Agent table | `internal/agentapi/outcome_store.go` has a private `resource_group_fences` table for the dormant `radio.ensure-off` outcome ledger. It is a separate database/schema and does not import or call this package. | Explicitly out of scope. | - -Both `service.go` and the SQLite repository first appear in the initial public -source release and have no later file history. Within the available repository -history, the application capability was therefore never production-wired. - -## Product and migration constraints - -- `docs/decisions/0001-product-scope-reset.md:39` says not to expand multi-layer - ResourceGroup leases/generation/fencing into SMS/Call prerequisites; current - flows use per-Modem serialization and minimal operation state. -- ADR 0001's line 62 deliberately avoided an immediate broad rollback. A later - targeted deletion of one unreachable application package is consistent with - that decision and does not require rewriting its historical rationale. -- `docs/architecture.md:258` allows the old lease store to remain a fixture or - historical mechanism, while the production Agent does not register the old - command. -- `.trellis/spec/core/backend/storage-and-migrations.md` forbids editing an - already released migration and requires real-SQLite evidence for storage - compatibility. - -The application package owns no persisted data itself. Deleting it cannot -remove rows; retaining runtime migration 00005 keeps old databases, downgrade -history, and fresh `OpenSet` behavior unchanged. - -## Options considered - -1. **Delete only the dormant application package (recommended).** This removes - the offending cross-layer contract and dead topology/capability policy with - no production, schema, or data change. Storage code/tests remain a clearly - labeled historical fixture. -2. Move lease types into application/domain and add SQLite mapping. This would - spend code and test surface preserving a capability with no caller and - conflicts with the current product simplification until a real use case - exists. -3. Delete application plus SQLite repository/tests or add a drop migration. - This is broader than the finding; a drop migration changes schema/data, and - removing all storage evidence weakens compatibility coverage without a - current benefit. - -## Planning conclusion - -Delete the three files under `internal/application/resourcelease`, update the -current architecture text so it distinguishes the retired orchestrator from -the retained runtime-database fixture, and synchronize the verified ownership -rule into Trellis backend specs after independent review. Do not edit the -SQLite repository, its test, migration 00005, Agent outcome ledger, OpenAPI, -generated files, or production composition. - -No additional product decision remains before implementation; approval of this -task approves the recommended narrow retirement, not future schema removal. diff --git a/.trellis/tasks/archive/2026-08/08-17-resolve-resourcelease-storage-coupling/task.json b/.trellis/tasks/archive/2026-08/08-17-resolve-resourcelease-storage-coupling/task.json deleted file mode 100644 index 351ae6d..0000000 --- a/.trellis/tasks/archive/2026-08/08-17-resolve-resourcelease-storage-coupling/task.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "id": "resolve-resourcelease-storage-coupling", - "name": "resolve-resourcelease-storage-coupling", - "title": "Resolve resource lease storage coupling", - "description": "Retire the unreachable resourcelease application package while preserving the released SQLite migration and historical storage fixture.", - "status": "completed", - "dev_type": null, - "scope": null, - "package": "core", - "priority": "P2", - "creator": "Leon", - "assignee": "Leon", - "createdAt": "2026-08-17", - "completedAt": "2026-08-18", - "branch": null, - "base_branch": "main", - "worktree_path": null, - "commit": null, - "pr_url": null, - "subtasks": [], - "children": [], - "parent": "08-14-audit-layer-boundaries", - "relatedFiles": [], - "notes": "", - "meta": {} -} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-08/08-20-feishu-sms-content/check.jsonl b/.trellis/tasks/archive/2026-08/08-20-feishu-sms-content/check.jsonl deleted file mode 100644 index 8c46baa..0000000 --- a/.trellis/tasks/archive/2026-08/08-20-feishu-sms-content/check.jsonl +++ /dev/null @@ -1,5 +0,0 @@ -{"file":".trellis/tasks/08-20-feishu-sms-content/research/current-flow.md","reason":"Verify the implementation closes the count-only gap while preserving replay, privacy, and provider compatibility invariants."} -{"file":".trellis/spec/core/backend/application-boundaries.md","reason":"Check messaging coordinator ownership, exact side-effect ordering/retry isolation, and notification delivery boundaries."} -{"file":".trellis/spec/core/backend/directory-structure.md","reason":"Check new types/ports/policy remain in their current owners and cmd stays composition-only."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Check synthetic coverage, privacy-safe failures, race/full validation, and scope attribution."} -{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Trace sender/body data end to end and verify no consumer-specific contract drift or unintended exposure."} diff --git a/.trellis/tasks/archive/2026-08/08-20-feishu-sms-content/design.md b/.trellis/tasks/archive/2026-08/08-20-feishu-sms-content/design.md deleted file mode 100644 index 9642f1a..0000000 --- a/.trellis/tasks/archive/2026-08/08-20-feishu-sms-content/design.md +++ /dev/null @@ -1,85 +0,0 @@ -# Forward SMS content to Feishu notifications: technical design - -## 1. Boundary and data-flow change - -The durable SMS record remains the source of truth. No API, SSE, database, provider wire format, or channel-binding contract changes. - -```text -Inbox transport - -> validate/decode SMS - -> CreateInboundSMS (durable first-persistence decision) - -> InboundSyncResult counters + ordered narrow sender/body notification values - -> messaging.SyncCoordinator singular received-SMS notification operation - -> notification.Service provider-aware fan-out - -> Feishu channels: one sender/body text per SMS - -> non-Feishu channels: existing count summary once per sync cycle -``` - -`internal/application/messaging` continues to own synchronization result interpretation, notification intent, realtime publication order, timeout, and retry isolation. `internal/application/notification` owns channel filtering, provider-specific rendering/fan-out, delivery, and delivery-status persistence. `cmd/simplusd` remains composition/lifecycle only. - -## 2. Messaging result contract - -Extend the internal synchronization result with an ordered collection of narrow, transport-neutral received-SMS notification values containing only `Sender` and `Body`. Capture those two fields from the message returned by `CreateInboundSMS` only when `replayed == false`, and aggregate them in the same Line/message traversal order as the existing counters. - -The collection and `Persisted` counter describe the same first-persistence events, but the collection is only how one poll reports several independent events; it is not a batch-delivery contract. Replayed/already-known messages never enter it. If transport ACK fails after persistence, retain both the counter and narrow notification value in the partial result so the current notify-before-retry behavior remains intact and the subsequent replay does not duplicate notification. - -Do not carry the complete `sms.Message` across the notification boundary: Line ID, message/operation/provider IDs, timestamps, status, and other fields are unnecessary. Keep the sender/body collection inaccessible to composition-root logging where practical; ordinary `SyncReport` logging and realtime publication remain counter-only. - -## 3. Consumer-owned notification operation - -Narrow the messaging coordinator's `NotificationSender` need from generic event/text rendering to two semantic operations: - -- a singular received-SMS content operation accepting only one sender/body value and targeting enabled subscribed Feishu channels; -- a count-summary operation used once per synchronization cycle for enabled subscribed non-Feishu channels, preserving their current behavior. - -The concrete notification service owns the provider filtering for both operations. Its existing generic `Notify(event, message)` method remains unchanged for all other callers. - -The coordinator retains the existing sequence and scheduling semantics: - -1. bounded inbound synchronization; -2. Messages realtime publication, with `sms.received` attention for first persistence; -3. for each newly persisted SMS in discovery order, create a fresh detached 15-second context and invoke the singular content operation exactly once; -4. cancel that per-SMS context, collect a non-sensitive indexed error if present, and continue regardless of success/failure; -5. invoke the non-Feishu count-summary operation once under its own bounded context; -6. publish Notifications after all attempts, including when one or more fail; -7. select retry based only on synchronization error. - -The loop is sequential to preserve discovery order and avoid unbounded external concurrency. Fresh contexts make attempts independent: a slow or failed delivery may delay the cycle, but it cannot consume the next SMS's timeout or suppress its call. Joined report errors may contain channel ID and message index only, never sender/body. - -## 4. Channel-specific fan-out - -For one singular content operation, the notification service applies the existing enabled/event filters for `sms.received` and selects Feishu channels only. - -- Each Feishu channel (`provider == feishu`) receives one plain-text delivery per newly persisted SMS, in input order: - - ```text - [Simplus] 新短信 - 发件人:<RemoteAddress> - 内容: - <Body> - ``` - -- The body is not trimmed, truncated, re-encoded, or interpreted as markup. JSON escaping remains the responsibility of the existing provider adapters. -- Both legacy Feishu bot Webhook and Feishu private-app delivery modes use the same formatted text. -- The separate summary operation selects non-Feishu channels only and sends the current single `[Simplus] 收到 N 条新短信` text once per synchronization cycle; it never receives the SMS body through this change. -- A Feishu failure is scoped to that individual SMS attempt. It does not stop the coordinator from invoking the singular operation for later messages. -- Errors use non-sensitive channel/message-index context. No sender/body, credential, target, or provider response is added to returned error text. - -Existing per-delivery status recording remains authoritative: the channel row describes the latest individual attempt, so a later success may replace an earlier failure and vice versa. There is no aggregate batch status because there is no batch notification transaction. No persistent outbox or notification retry mechanism is introduced. - -## 5. Compatibility, privacy, and rollback - -- No OpenAPI/generated code, Web UI, SQLite schema/query/migration, SSE payload, event-kind list, channel configuration, ciphertext label, or provider endpoint changes. -- No SMS body appears in public API/SSE/logs/errors; it leaves the process only through the user-configured Feishu outbound channel requested by this feature. -- WeCom retains the existing count-only notification. Other event kinds and explicit channel tests retain the generic path. -- Independent fresh deadlines can lengthen one synchronization cycle during a large provider outage; this is accepted in preference to sharing a timeout or starting unbounded goroutines. Failed individual notifications are not automatically replayed. -- The background-coordination scenario in `.trellis/spec/core/backend/application-boundaries.md` currently describes one count-summary delivery under one timeout. Update that executable project contract after implementation to describe the approved independent per-SMS Feishu attempts and preserved non-Feishu summary. -- Rollback is source-only: restore the counter-only result/port and generic summary call. No data migration or external cleanup is needed. -- Verification is synthetic and must not send a real SMS or Feishu message, access hardware, alter RF/SIM/modem state, or inspect private runtime evidence. - -## 6. Verification design - -- Messaging service tests prove first persistence returns the exact narrow sender/body values, replay omits them, multipart completion returns the assembled body, and partial ACK failure retains the notification value without exposing unrelated message fields. -- Coordinator fake-port tests prove two discovered SMS values cause two singular calls with fresh detached deadlines; failure/timeout of the first does not suppress the second; replay/ack-only results do not notify; failures still publish Notifications and do not select retry. -- Notification service recording-fake tests prove each singular SMS operation yields one exact message for Feishu Webhook and Feishu app modes, multiline/Unicode bodies survive intact, the separate non-Feishu operation emits one count summary, filters remain effective, latest-attempt status is preserved, and failures do not leak message content in errors. -- Run targeted package tests first, then race/full safe checks and generated-drift/worktree checks required by the backend quality guide. diff --git a/.trellis/tasks/archive/2026-08/08-20-feishu-sms-content/implement.jsonl b/.trellis/tasks/archive/2026-08/08-20-feishu-sms-content/implement.jsonl deleted file mode 100644 index ed88d65..0000000 --- a/.trellis/tasks/archive/2026-08/08-20-feishu-sms-content/implement.jsonl +++ /dev/null @@ -1,7 +0,0 @@ -{"file":".trellis/tasks/08-20-feishu-sms-content/research/current-flow.md","reason":"Current SMS persistence-to-notification flow, exact anchors, preserved invariants, and provider-aware planning conclusion."} -{"file":".trellis/spec/core/backend/index.md","reason":"Root Go dependency direction, package scope, and backend pre-development checklist."} -{"file":".trellis/spec/core/backend/application-boundaries.md","reason":"Normative SMS background coordinator, notification intent, timeout/order/retry, and typed Webhook delivery ownership contracts."} -{"file":".trellis/spec/core/backend/directory-structure.md","reason":"Consumer-owned port placement, application policy ownership, cmd composition limits, and co-located test rules."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Synthetic test shapes, privacy assertions, failure handling, and targeted-to-full validation ladder."} -{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Data-flow mapping and typed contract checks for carrying newly persisted SMS values across application boundaries."} -{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"Search/reuse discipline when specializing the existing notification path and formatting behavior."} diff --git a/.trellis/tasks/archive/2026-08/08-20-feishu-sms-content/implement.md b/.trellis/tasks/archive/2026-08/08-20-feishu-sms-content/implement.md deleted file mode 100644 index f8a7534..0000000 --- a/.trellis/tasks/archive/2026-08/08-20-feishu-sms-content/implement.md +++ /dev/null @@ -1,39 +0,0 @@ -# Forward SMS content to Feishu notifications: implementation plan - -## 1. Carry newly persisted SMS records through synchronization - -- [x] Extend the internal synchronization result with ordered newly persisted notification values containing only sender/body while retaining existing counters; do not expose the complete `sms.Message` to notification or logging boundaries. -- [x] Capture the narrow value from the repository-returned inbound message only on first persistence and aggregate it through message, Line, and overall synchronization results. -- [x] Preserve partial-progress behavior when acknowledgement fails and ensure replay/already-known records do not re-enter the notification slice. -- [x] Add/adjust co-located messaging service tests for single, multiple, replay, multipart, and partial-ACK cases without hardware. - -## 2. Introduce received-SMS notification intent at the coordinator boundary - -- [x] Replace the coordinator's generic event/rendered-text dependency with a singular received-SMS content operation plus a non-Feishu count-summary operation, leaving provider selection in the notification owner. -- [x] Loop over newly persisted values in discovery order; create and cancel a fresh detached 15-second context for each singular call, continue after every failure, and join only non-sensitive indexed errors. -- [x] Invoke the non-Feishu summary once per synchronization cycle under a separate bounded context. -- [x] Preserve Messages → notification → Notifications ordering, attention, partial-result behavior, error reporting, and sync-only retry policy. -- [x] Update coordinator fakes/tests to assert exact ordered sender/body handoff, distinct per-message deadlines, first-failure/second-attempt behavior, and no replay/ack-only notification. - -## 3. Render and fan out inside the notification owner - -- [x] Add the singular Feishu-content and non-Feishu-summary operations to `internal/application/notification` while leaving generic `Notify` unchanged for all other events/callers. -- [x] Format one bounded plain-text Feishu delivery per SMS as `[Simplus] 新短信`, sender, and complete body; use it for both Feishu Webhook and Feishu private-app channels. -- [x] Preserve one count-only summary per cycle for enabled subscribed non-Feishu channels. -- [x] Keep each SMS attempt independent, join failures without sender/body or credential leakage, and retain last-individual-attempt channel status rather than inventing aggregate batch status. -- [x] Add recording-fake tests for both Feishu delivery modes, sequential independent calls, Unicode/multiline body preservation, filtering, WeCom compatibility, latest-attempt status, and safe failure behavior. - -## 4. Validate compatibility and safety - -- [x] Run `gofmt` on changed Go files and `git diff --check`. -- [x] Run `go test -count=1 ./internal/application/messaging ./internal/application/notification`. -- [x] Run the affected packages under `go test -race`. -- [x] Run `make check-format`, `make verify-generated`, `make lint`, and `make test` as the safe full regression ladder. -- [x] Confirm diffs contain no API/generated/schema/Web/SSE/log-content/provider-endpoint changes and no real communication/HIL evidence. -- [x] Complete an independent Trellis quality review against the PRD/design and address verified findings only. -- [x] Use the Trellis spec-update step to revise `.trellis/spec/core/backend/application-boundaries.md` from the old one-summary/one-timeout contract to the approved independent per-SMS Feishu attempt contract, while preserving the non-Feishu summary and retry-isolation rules. - -## Rollback points - -- The change is source-only. Reverting the messaging result field, specialized coordinator port, notification fan-out, and their tests restores the previous count-only behavior. -- No migration, persistent data rewrite, external provider setup, or hardware action is involved. diff --git a/.trellis/tasks/archive/2026-08/08-20-feishu-sms-content/prd.md b/.trellis/tasks/archive/2026-08/08-20-feishu-sms-content/prd.md deleted file mode 100644 index a228e0c..0000000 --- a/.trellis/tasks/archive/2026-08/08-20-feishu-sms-content/prd.md +++ /dev/null @@ -1,51 +0,0 @@ -# Forward SMS content to Feishu notifications - -## Goal - -Make a Feishu new-SMS notification useful without opening another interface: send one independent Feishu message per newly received SMS containing its sender and complete body instead of only a generic count reminder. - -## Background - -- The current user-visible Feishu notification only indicates that new SMS messages exist. Repository evidence confirms why: `InboundSyncResult` exposes counters only, and `SyncCoordinator` renders one count-only message (`internal/application/messaging/inbound.go:18-26`, `internal/application/messaging/sync_coordinator.go:96-106`). -- At successful first persistence, the application already has the transport-neutral sender, body, Line ID, and receive time; replayed/already-known messages are distinguished from newly persisted messages (`internal/application/messaging/inbound.go:245-278`, `internal/domain/sms/message.go:24-38`). -- One synchronization call may discover and persist multiple messages while walking Lines and inbox references; that collection is an ingestion detail, not one notification transaction (`internal/application/messaging/inbound.go:41-70,97-117`). -- A valid inbound body is non-blank and limited to 1,600 Unicode code points / 6,400 bytes, while notification text is limited to 4,000 Unicode code points (`internal/application/messaging/inbound.go:281-288`, `internal/application/notification/service.go:25-30,242-245`). -- The current notification service broadcasts one supplied text string to every enabled channel subscribed to the event; Feishu Webhook and Feishu private-app delivery share that contract (`internal/application/notification/service.go:242-269,336-359`). -- Historical decisions require notification channels to remain outbound-only and automated validation not to contact real Feishu endpoints (`docs/decisions/0005-management-completion-mihomo-notifications.md:23-34`, `docs/decisions/0025-feishu-private-message-binding.md:17-39`). - -## Requirements - -- **R1 — Feishu content delivery.** Every newly persisted inbound SMS that currently triggers `sms.received` is an independent notification event and must produce one message in each enabled, subscribed Feishu Webhook or Feishu private-app channel. -- **R2 — Agreed text format.** Each Feishu message must identify the transport-neutral sender and preserve the complete SMS body, including valid Unicode and embedded line breaks, in this plain-text shape: - - ```text - [Simplus] 新短信 - 发件人:<sender> - 内容: - <complete body> - ``` - -- **R3 — Independent delivery.** When one synchronization cycle persists multiple SMS messages, schedule one separate Feishu attempt for each in discovery order. Every SMS gets its own detached 15-second delivery context and result; a failure or timeout for one must not suppress later SMS attempts. Do not combine the messages into a shared delivery transaction or truncate their bodies. -- **R4 — Provider compatibility.** Non-Feishu notification providers must retain their existing one-per-cycle `sms.received` summary and must not receive SMS bodies as an incidental side effect. -- **R5 — Replay safety.** Notify only for first persistence. Replayed/already-known transport messages must not create duplicate content notifications, including after a prior transport ACK failure. -- **R6 — Failure isolation and status.** A Feishu delivery failure must not roll back SMS reception/storage, select the SMS synchronization retry schedule, suppress other SMS attempts, or prevent the normal Notifications invalidation after the attempts. Existing channel status remains the result of the latest individual delivery attempt, not an aggregate batch result; failures remain observable in the current synchronization report without including message content. -- **R7 — Privacy and safety.** SMS bodies may leave the process only through the user-configured Feishu outbound delivery requested here. Do not add them to public API, Web, SSE/realtime payloads, ordinary logs, errors, channel views, or unrelated providers; do not expose modem/device controls or private troubleshooting material. - -## Acceptance Criteria - -- [x] **AC1 / R1-R2.** A newly persisted SMS produces one exact-format sender/body message through both Feishu Webhook and Feishu private-app delivery modes. -- [x] **AC2 / R2.** Synthetic Unicode and multiline bodies reach the Feishu delivery port complete and unmodified. -- [x] **AC3 / R3.** Two or more SMS messages first-persisted in one cycle produce the same number of separate Feishu calls with distinct delivery contexts; an injected failure/timeout for the first does not prevent the second call. -- [x] **AC4 / R4.** An enabled subscribed non-Feishu channel receives its existing single count summary and no SMS body. -- [x] **AC5 / R5.** Replayed/already-known records, including an SMS re-read after persistence succeeded but ACK failed, produce no duplicate content notification. -- [x] **AC6 / R6.** SMS persistence remains successful when one delivery fails; later SMS attempts still run, notification failures remain report-only for scheduling, channel status follows the latest individual attempt, and Notifications invalidation still occurs. -- [x] **AC7 / R7.** Focused privacy assertions and diff inspection find no SMS body in API/Web/SSE/log/error surfaces and no unrelated private or hardware material. -- [x] **AC8.** The background-coordination project spec reflects the approved independent-delivery contract, and co-located synthetic tests, affected-package race tests, formatting, generated-drift checks, lint, and the safe full regression suite pass without real SMS, Feishu, hardware, RF, modem-write, or HIL actions. - -## Out of Scope - -- Changing non-Feishu notification behavior beyond preserving its current contract. -- Delivery retry/outbox redesign for an individual Feishu failure after the SMS has already been persisted. -- Public API, Web UI, persistence schema, realtime/SSE payload, event-kind, channel-binding, credential, or provider-endpoint changes. -- Sending test SMS messages, making calls, changing RF state, modem-persistent writes, or performing HIL-1/HIL-2 actions. -- Exposing arbitrary AT/QMI commands, device paths, SIM/device identity, raw HIL evidence, packet captures, screenshots, private endpoints, or topology. diff --git a/.trellis/tasks/archive/2026-08/08-20-feishu-sms-content/research/current-flow.md b/.trellis/tasks/archive/2026-08/08-20-feishu-sms-content/research/current-flow.md deleted file mode 100644 index f7ad629..0000000 --- a/.trellis/tasks/archive/2026-08/08-20-feishu-sms-content/research/current-flow.md +++ /dev/null @@ -1,24 +0,0 @@ -# SMS-to-Feishu current flow - -## Current behavior - -1. `messaging.Service.SyncInbound` polls every ready Line and returns `InboundSyncResult` counters only (`internal/application/messaging/inbound.go:18-70`). -2. `persistAndAcknowledgeInbound` has the complete persisted `sms.Message`, including `RemoteAddress`, `Body`, `LineID`, and receive time, but discards the repository return value and exposes only `Persisted` / `AlreadyKnown` counters (`internal/application/messaging/inbound.go:244-266`). -3. `SyncCoordinator` interprets `Persisted > 0` as `sms.received`, renders `[Simplus] 收到 N 条新短信`, and calls the generic notification port once (`internal/application/messaging/sync_coordinator.go:83-108`). -4. `notification.Service.Notify` sends the same supplied text to every enabled subscribed channel; Feishu bot Webhook and Feishu private-app channels share this call path (`internal/application/notification/service.go:242-359`). - -## Relevant invariants - -- First persistence and transport replay are already distinguished. An ACK failure may return partial progress with `Persisted == 1`; the notification still happens once, while the next poll sees `AlreadyKnown` and must not duplicate it (`internal/application/messaging/inbound.go:244-266`; `.trellis/spec/core/backend/application-boundaries.md`, background-coordination scenario). -- Valid inbound bodies are non-blank and bounded to 1,600 runes / 6,400 bytes; senders are bounded to a 20-character numeric or alphanumeric address. The agreed prefix and labels therefore remain below the existing 4,000-rune notification limit (`internal/application/messaging/inbound.go:277-288`, `internal/application/messaging/service.go:57-61`, `internal/application/notification/service.go:25-30`). -- `cmd/simplusd` may construct and run the coordinator, but must not regain SMS notification rendering or channel-selection policy (`.trellis/spec/core/backend/application-boundaries.md`, background-coordination scenario). -- Notification delivery errors remain report-only for SMS scheduling and must not roll back or retry SMS persistence. -- Realtime remains an invalidation/attention channel; SMS content must not enter SSE, logs, public API, or channel views. -- Automated checks use recording fakes/synthetic transports only. No real Feishu call, SMS, RF, modem write, or HIL action is allowed. - -## Planning conclusion - -- Carry only ordered sender/body notification values alongside the existing counters; do not widen the boundary with complete `sms.Message` records. -- Treat a multi-message polling result as several independent notification events. Invoke a singular Feishu-content operation once per message with a fresh detached deadline, continue after failures, and keep non-Feishu providers on a separate one-per-cycle count summary. -- Let `internal/application/notification` own provider filtering and rendering for both semantic operations; the messaging coordinator owns independent attempt timing/order and failure isolation. -- Keep the existing generic `Notify(event, message)` operation for call, send-failure, degraded-system, HTTP-owned, and test-notification paths. diff --git a/.trellis/tasks/archive/2026-08/08-20-feishu-sms-content/task.json b/.trellis/tasks/archive/2026-08/08-20-feishu-sms-content/task.json deleted file mode 100644 index bf268db..0000000 --- a/.trellis/tasks/archive/2026-08/08-20-feishu-sms-content/task.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "id": "feishu-sms-content", - "name": "feishu-sms-content", - "title": "Forward SMS content to Feishu notifications", - "description": "", - "status": "completed", - "dev_type": null, - "scope": null, - "package": "core", - "priority": "P2", - "creator": "Leon", - "assignee": "Leon", - "createdAt": "2026-08-20", - "completedAt": "2026-08-20", - "branch": null, - "base_branch": "main", - "worktree_path": null, - "commit": null, - "pr_url": null, - "subtasks": [], - "children": [], - "parent": null, - "relatedFiles": [], - "notes": "", - "meta": {} -} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-08/08-21-ghcr-release-install/check.jsonl b/.trellis/tasks/archive/2026-08/08-21-ghcr-release-install/check.jsonl deleted file mode 100644 index 5f18bcc..0000000 --- a/.trellis/tasks/archive/2026-08/08-21-ghcr-release-install/check.jsonl +++ /dev/null @@ -1,5 +0,0 @@ -{"file":".trellis/spec/core/infra/containers-and-privileges.md","reason":"Verify no drift in service topology, UID/capability/socket/mount boundaries, host preparation authorization, or HIL scope."} -{"file":".trellis/spec/core/infra/build-and-generated-files.md","reason":"Verify deterministic bundle/release behavior, Make/CI alignment, tag policy, and source-before-image publication."} -{"file":".trellis/spec/core/docs/documentation-ownership.md","reason":"Verify the canonical install/decision/status documents were updated without duplicated manuals or false evidence claims."} -{"file":".trellis/spec/core/docs/privacy-and-publication.md","reason":"Verify bundle and release assets contain only allowed public material and that publication gates are complete."} -{"file":".trellis/tasks/08-21-ghcr-release-install/research/current-state.md","reason":"Cross-check implementation and final claims against the pre-change repository/GitHub state."} diff --git a/.trellis/tasks/archive/2026-08/08-21-ghcr-release-install/design.md b/.trellis/tasks/archive/2026-08/08-21-ghcr-release-install/design.md deleted file mode 100644 index 617c3ef..0000000 --- a/.trellis/tasks/archive/2026-08/08-21-ghcr-release-install/design.md +++ /dev/null @@ -1,67 +0,0 @@ -# Design: GHCR release installation - -## Architecture - -仓库根 `compose.yaml` 继续作为权限/服务拓扑的唯一源码模板,但镜像 tag 改为必须显式提供的开发模板变量,避免源码 checkout 默认伪装成某个已发布生产版本。Release 构建器只替换这一个受控占位符,生成写死 `vX.Y.Z` 的发行 Compose;端口和设备 GID 仍由用户 `.env` 提供。 - -生产数据继续相对发行 Compose 位于 `./data/core` 和 `./data/agent`。部署包更新只覆盖受管 Compose、脚本、示例和文档,不包含或删除 `.env`、`data/` 或运行 volume。 - -## Deployment bundle contract - -新增 `scripts/release/build-container-release-bundle.sh`,接口为: - -```text -build-container-release-bundle.sh <vX.Y.Z> <40-hex-commit> <source-date-epoch> <output-directory> -``` - -脚本必须: - -1. 严格验证 tag、commit、epoch 和既有输出目录; -2. 在私有临时目录创建 `simplus-compose-<tag>-linux-amd64/`; -3. 将源码 Compose 的唯一 image-tag 占位符替换为字面版本 tag,并断言五个服务只引用预期三张 GHCR 镜像; -4. 复制 `.env.example`、两条宿主脚本、bundle README、VERSION、根许可证和 notices,固定脚本为 `0755`、其余为 `0644`; -5. 使用排序条目、固定 mtime、numeric owner/group 0 和 `gzip -n` 生成确定性归档; -6. 在输出目录写入归档及只引用其 basename 的 `.sha256` 文件。 - -bundle README 仅给出 SHA 校验后的显式命令,不提供 `curl | sh` 或隐式 sudo。`.env.example` 不含秘密或镜像版本,升级时用户保留自己的 `.env`。 - -## GitHub Actions data flow - -1. `metadata/compose-contract` 对所有触发器运行;tag ref 必须匹配 `^v[0-9]+\.[0-9]+\.[0-9]+$`。 -2. PR/手动触发用合成 CI metadata 构建部署包和三个 target,`push: false`。 -3. tag 触发先构建 strongSwan/Mihomo 对应源码和部署包,并上传临时 Actions artifacts。 -4. 发布任务创建或更新同 tag GitHub **Pre-release**,上传部署包、checksum 和全部对应源码;版本化资产存在时只接受内容相同的幂等重跑,不静默覆盖不同内容。 -5. 镜像矩阵在来源资产发布成功后登录 GHCR。同 ref workflow 串行执行;每个 target 若已 - 存在同 tag,只在 commit、`linux/amd64` 和 OCI 标签完全匹配时复用其 digest;若不存在, - 先用 BuildKit 按 digest 推送无 tag 内容,再次确认版本 tag 不存在后用 `imagetools create` - 提升,并核对提升后的 digest 与 staged digest 完全相同。每个矩阵项把最终 digest 写成 - 小型 artifact。 -6. 汇总任务验证三个 target/digest 唯一且完整,生成稳定排序的 `simplus-images-<tag>.json` 并上传到同一 Pre-release。 - -`packages: write` 只授予推送镜像的 job,`contents: write` 只授予发布 Release 资产的 job。PR 来源永不获得发布路径。保留现有 action commit pin、BuildKit cache、SBOM 和 provenance。 - -## First release and visibility - -代码经 PR #5 合并后,给合并提交创建了不可移动的 `v0.1.0`。部署/来源资产成功进入 -Pre-release,但两个 registry manifest HEAD 调用错误使用 `curl --request HEAD`,三个 -矩阵项均在任何镜像构建或推送前以 exit 18 失败,digest manifest 未生成。该 tag 和 -Pre-release 保持不完整,不重跑或补写;修复合并后用新 tag `v0.1.1` 完成首发。 - -`v0.1.1` 实际发布后,三个 package 已可从未登录 registry 的空凭据环境 inspect/pull, -因此没有执行额外的 visibility 修改。未来新 package 仍须逐一验证;若保持 private, -workflow 无法替代所有者将其改为 public 的不可逆 visibility 决策。 - -基础设施瞬时错误可对同一 tag/commit 重跑。此次失败需要改代码,所以不移动或复用 -`v0.1.0`,而是在 main 修复后使用 `v0.1.1`。首发保持 Pre-release,clean-VM 验收由 -独立任务完成。 - -同 ref concurrency 消除 workflow 自身重跑竞争;发布期间其他主体不得持有或使用 -`packages: write` 改写同 tag。GHCR 在本流程中没有 compare-and-set 接口,因此版本 tag -不可移动性还依赖该写权限治理,发布后继续以 digest manifest 和匿名 inspect 核对。 - -## Compatibility and failure boundaries - -- 不改变 Compose 服务、权限、UID、socket 或持久数据布局,现有容器合同测试仍权威。 -- 发布 bundle 引用版本 tag;digest JSON 只用于审计和发布后核对,不改变运行引用。 -- 新 bundle 可替换旧受管文件并重建容器,但数据库只自动向前迁移。降级必须另外确认 schema 兼容或恢复备份,本文不新增 downgrade 接口。 -- Release 发布/镜像 pull 验证不授权 `docker compose up`、宿主内核修改或真实硬件访问。 diff --git a/.trellis/tasks/archive/2026-08/08-21-ghcr-release-install/implement.jsonl b/.trellis/tasks/archive/2026-08/08-21-ghcr-release-install/implement.jsonl deleted file mode 100644 index f6ebe12..0000000 --- a/.trellis/tasks/archive/2026-08/08-21-ghcr-release-install/implement.jsonl +++ /dev/null @@ -1,5 +0,0 @@ -{"file":".trellis/spec/core/infra/containers-and-privileges.md","reason":"Production Compose topology, fixed identities, mounts, capabilities, lifecycle, and safe validation boundaries."} -{"file":".trellis/spec/core/infra/build-and-generated-files.md","reason":"Make target semantics, reproducible release inputs, generated ownership, workflow alignment, and corresponding-source obligations."} -{"file":".trellis/spec/core/docs/documentation-ownership.md","reason":"Canonical owners for installation, ADR, active plan, handoff, and public status wording."} -{"file":".trellis/spec/core/docs/privacy-and-publication.md","reason":"Release asset allowlist, private-data exclusions, redacted secret scanning, and publication review requirements."} -{"file":".trellis/tasks/08-21-ghcr-release-install/research/current-state.md","reason":"Task-specific repository and remote publication evidence gathered before implementation."} diff --git a/.trellis/tasks/archive/2026-08/08-21-ghcr-release-install/implement.md b/.trellis/tasks/archive/2026-08/08-21-ghcr-release-install/implement.md deleted file mode 100644 index 3b58346..0000000 --- a/.trellis/tasks/archive/2026-08/08-21-ghcr-release-install/implement.md +++ /dev/null @@ -1,59 +0,0 @@ -# Implementation: GHCR release installation - -## 1. Bundle source and contracts - -- [x] 将源码 Compose 的 image tag 改为显式开发模板输入,同时保持五服务和全部权限合同不变。 -- [x] 添加 bundle README、`.env.example` 与确定性 `build-container-release-bundle.sh`。 -- [x] 添加 `container-release-bundle` Make target,并把新 shell 文件纳入静态语法检查。 -- [x] 扩展 `internal/containercontract`:严格 tag/commit/epoch、归档文件白名单/模式、双次构建字节一致、checksum、字面 GHCR tag、无 build/latest/private state。 - -## 2. Release workflow - -- [x] 重构 containers workflow metadata 和 trigger gate:仅严格版本 tag 可 push,PR/manual 只 build。 -- [x] 将部署 bundle 并入对应源码资产阶段,以 GitHub Pre-release 先公开来源/安装资产。 -- [x] 在三 target push 后收集 digest,生成并上传 `simplus-images-<tag>.json`。 -- [x] 保持 action SHA pin、最小 job 权限、linux/amd64、SBOM/provenance、缓存和 source-before-image gate;同 tag 重跑复用匹配 digest,不移动既有版本 tag。 -- [x] 更新 workflow path filters 覆盖 bundle source、builder、tests 和文档所有者。 - -## 3. Documentation and project contracts - -- [x] README/installation 改为 Release 下载、checksum、解包、`.env`、宿主检查、pull/up;删除生产本地构建回退。 -- [x] development 保留 `make container-build`/参数化 Compose,明确只作开发验证。 -- [x] 更新 ADR 0021、公开状态/活跃计划中与 GHCR 首发相关的准确口径,不提升 clean-VM 证据。 -- [x] 更新 core infra/docs Trellis spec,记录 bundle 作为正式安装接口、tag-only/pre-release 和 public visibility 首发门禁。 - -## 4. Verification and PR - -- [x] 运行 focused bundle/container tests、`make container-config CONTAINER_IMAGE_TAG=dev` 和 Actions lint。 -- [x] 运行 `make check-container-files`、`go test ./internal/containercontract`、`make check-docs`、`make lint`、`make test`、`make security`、`git diff --check`。 -- [x] 对工作树、历史和生成 bundle 运行启用 redaction 的独立 secret scan;人工复核资产清单/许可证且不保存扫描输出。 -- [x] Trellis check 通过后更新规范、提交分支、推送并创建 PR #5;7 个执行检查成功后以 merge commit `d7aaa34` 合并。 - -## 5. v0.1.0 failure and v0.1.1 Pre-release - -- [x] 对合并提交 `d7aaa34` 创建/推送不可移动的 `v0.1.0`;部署/来源资产发布成功,三个镜像 job 在构建/推送前失败,Release 保持不完整 Pre-release。 -- [x] 从 Actions 日志确认 `curl --request HEAD` 导致 exit 18;不重跑、移动或补写 `v0.1.0`,添加 `--head` 回归合同。 -- [x] 修复 PR #6 的 6 个执行检查成功后,以 merge commit `81d3f3a` 合并并创建/推送 `v0.1.1`;containers run `32480362514` 全部成功。 -- [x] 验证 `v0.1.1` Pre-release 的八个部署/来源资产、checksum、三个 GHCR tag 和 digest manifest。 -- [x] 三个 package 已可匿名访问;在空 Docker 凭据环境 inspect 并核对单一 `linux/amd64`、OCI metadata 和三个 digest。 -- [x] 从临时目录匿名下载/校验/解包后运行 `docker compose config --quiet` 和 `docker compose pull`;未执行 up、宿主准备或 HIL。 - -## Rollback points - -- 发布 tag 前:PR 可安全修订;不产生 GHCR/Release 外部状态。 -- Release 资产发布后:保持 Pre-release,并修复 workflow;不要覆盖内容不同的同名资产。 -- tag 镜像发布后:tag/commit 不移动;瞬时失败只重跑,代码缺陷使用新 patch tag。 -- 不以旧 Compose 覆盖已经向前迁移的数据作为通用回滚方式。 - -## Verification notes - -- 使用官方 checksum 校验的 Gitleaks v8.28.0,并先以合成 canary 验证探测有效;工作树、 - 完整历史、部署包、二进制包和 Simplus 包装源码均 clean。对应源码中的命中只位于固定 - strongSwan/Mihomo 上游公开测试/示例夹具,已按路径和固定来源人工复核;未保存报告。 -- 本地忽略目录最初仍是 Go 1.26.5;`make doctor` 识别出与仓库锁定的 1.26.6 不一致, - 用校验和固定的 `make dev-toolchain` 刷新后,`make security` 报告可达漏洞 0、pnpm 漏洞 0。 -- `SIMPLUS_DEB_VERSION=0.1.0-1` 的 strongSwan 包、对应源码、manifest 和 checksum 已完成 - 本地隔离构建与校验;未执行宿主准备、Compose 启动或任何 HIL。 -- `v0.1.0` Actions run `32479227277` 证明来源资产阶段成功且镜像阶段失败关闭;修复后的 - `v0.1.1` bundle checksum/解包/Compose render、合同测试、lint、完整 `make test`、 - `make security` 和脱敏工作树/历史扫描均通过。 diff --git a/.trellis/tasks/archive/2026-08/08-21-ghcr-release-install/prd.md b/.trellis/tasks/archive/2026-08/08-21-ghcr-release-install/prd.md deleted file mode 100644 index 5e1812e..0000000 --- a/.trellis/tasks/archive/2026-08/08-21-ghcr-release-install/prd.md +++ /dev/null @@ -1,82 +0,0 @@ -# GHCR release installation - -## Goal - -让 Debian 13/amd64 管理员通过版本化 GitHub Release 部署包安装 Simplus,直接匿名拉取由 GitHub Actions 构建并发布到 GHCR 的三个生产镜像,不再在生产机克隆源码或本地构建镜像。 - -## Background - -- Docker Compose 已是唯一受支持的生产部署方式,现有 `control`、`agent`、`netd` 三镜像及 UID、capability、mount、socket 和网络隔离契约必须保持不变。 -- `compose.yaml` 已引用 `ghcr.io/leonfox28/simplus-{control,agent,netd}`,现有 tag workflow 具备构建/推送骨架,但远端尚无 Git tag、GitHub Release 或已验证的公开 GHCR 版本。 -- 当前安装文档仍要求在源码 checkout 中取得 Compose/宿主脚本,并保留首发前本地构建回退;这不是目标生产安装接口。 -- clean Debian VM 的完整安装、升级、停止和卸载生命周期仍未验收;首发不得提升为稳定 Runtime 结论。 - -## Release attempt outcome - -PR #5 在全部检查成功后以 `d7aaa34` 合并,并创建了不可移动的 `v0.1.0`。部署包和对应 -源码资产成功发布;随后三个镜像 job 因 registry HEAD 探测的 curl 语义错误,在任何 -镜像构建/推送前失败,digest manifest 未生成。按既定失败处理,不移动、重跑或补写 -`v0.1.0`;代码修复后以 `v0.1.1` 完成剩余验收。 - -## Requirements - -### R1 — Release bundle installation - -- 生产安装只依赖 Docker Engine、Compose、下载/校验/解包工具及版本化部署包,不依赖 Git、Go、Node、pnpm、Make 或本地 `docker build`。 -- 每个版本资产名固定为 `simplus-compose-<tag>-linux-amd64.tar.gz` 及同名 `.sha256`; - `v0.1.0` 已生成并保留,最终候选改用对应的 `v0.1.1` 资产。 -- 解包目录只包含:写死同版本镜像 tag 的 `compose.yaml`、`.env.example`、宿主准备和检查脚本、简明安装说明、`VERSION`、`LICENSE`、`THIRD_PARTY_NOTICES.md`。 -- `.env.example` 只暴露 `SIMPLUS_HTTP_PORT`、`SIMPLUS_CONTROLLER_PORT`、`SIMPLUS_DEVICE_GID`;生产用户不通过环境变量切换镜像版本。 - -### R2 — Versioned GHCR publication - -- 只允许严格 `vX.Y.Z` Git tag 发布 `linux/amd64` 镜像;PR 和 `workflow_dispatch` 只构建验证,不推送。 -- 不发布或引用 `latest`、`main` 或分支滚动标签。 -- 正式引用为 `ghcr.io/leonfox28/simplus-{control,agent,netd}:<tag>`;`v0.1.0` 是失败关闭的 - 首次尝试,首个完整候选改为 `v0.1.1`。 -- 继续保留 OCI source/version/revision/license 标签、SBOM、provenance 和按 target 分离的缓存。 - -### R3 — Release ordering and evidence - -- strongSwan 包/对应源码、Mihomo 对应源码和部署包必须先作为同 tag GitHub Pre-release 资产公开,随后才允许推送镜像。 -- 三镜像成功后发布 `simplus-images-<tag>.json`,记录版本、Git commit、`linux/amd64` - 平台、完整 tag reference 和实际 `sha256` digest;本任务的完整清单应为 - `simplus-images-v0.1.1.json`。 -- 任一来源、资产或镜像步骤失败时不得把 GitHub Release 标记为稳定可用。 -- 首次 package 必须逐一验证匿名访问;若保持 private,由仓库所有者显式改为 public, - 完成前不得宣告匿名安装可用。 - -### R4 — Documentation and lifecycle - -- README 和 `docs/installation.md` 以 Release 下载、SHA-256 校验、解包、宿主检查、`docker compose pull/up` 为唯一生产安装路径。 -- `make container-build` 和参数化源码 Compose 只保留在 `docs/development.md` 作为开发验证入口。 -- 升级通过用新版本部署包替换受管文件并 pull/up;不得承诺仅切回旧镜像即可安全降级数据库。 -- 更新 ADR 0021、相关 Trellis infra/docs 规范和必要的公开状态摘要,明确 `v0.1.0` 是 - 不完整 Pre-release、`v0.1.1` 仍只是部署候选。 - -### R5 — Safety and publication - -- 不改变业务 API、数据库 schema、Compose 服务拓扑、固定 UID、capability、mount、端口、socket 鉴权或硬件能力边界。 -- 本任务不运行宿主准备、`docker compose up`、真实设备部署、RF/SIM/短信/电话/VoWiFi 操作或 HIL。 -- 发布包采用明确文件白名单和确定性归档,不能包含源码、`.git`、`.dev`、`.tools`、`.env`、运行数据、日志、凭据或私人证据。 -- 发布前按项目公开规范完成文档检查、启用 redaction 的 secret scan,以及许可证和最终资产清单人工复核。 - -## Acceptance Criteria - -- [x] `make container-release-bundle RELEASE_TAG=v0.1.0` 生成规定名称、内容、权限、确定性 tar.gz 和有效 SHA-256 文件。 -- [x] 发布包中的 Compose 只含三张 `:v0.1.0` GHCR 镜像、没有 `build`/`latest`,并可在解包目录用 `.env` 成功渲染。 -- [x] `v0.1.1` 替代部署包也通过 checksum、解包和 Compose 渲染合同。 -- [x] 合同测试拒绝非法 tag、额外/私有文件、错误权限、非确定性输出、未固定镜像或缺失许可证。 -- [x] PR Actions 成功构建三个 target 但不推送;只有严格版本 tag 进入来源资产 → Pre-release → GHCR → image manifest 流程。 -- [x] `make check-container-files`、`go test ./internal/containercontract`、`make check-docs`、`make lint`、`make test`、`make security` 和 `git diff --check` 通过。 -- [x] 功能分支通过 PR #5 合并;`v0.1.0` workflow 成功生成 Pre-release/部署/源码资产,并在任何镜像推送前失败关闭,旧 tag 保持不可移动且不完整。 -- [x] 修复分支通过 PR #6/CI 合并后,新合并提交的 `v0.1.1` workflow 成功生成完整 Pre-release、部署资产、源码资产、三个 GHCR 镜像和 digest manifest。 -- [x] 三个 package 已可匿名访问;无 GHCR 登录也能 inspect/pull `v0.1.1`,且 digest/OCI 元数据与 `simplus-images-v0.1.1.json` 一致。 -- [x] 发布后仅执行解包、Compose config 和 pull 验证;未启动服务或接触真实硬件。 - -## Out of Scope - -- clean-VM 生命周期验收或把发布提升为稳定版; -- ARM64、其他发行版、Docker Desktop、Podman、rootless/userns 或 SELinux 支持; -- `latest`/main/nightly 发布通道、镜像签名平台或自动更新系统; -- 自动备份、数据库 downgrade、旧原生数据迁移或真实部署/HIL。 diff --git a/.trellis/tasks/archive/2026-08/08-21-ghcr-release-install/research/current-state.md b/.trellis/tasks/archive/2026-08/08-21-ghcr-release-install/research/current-state.md deleted file mode 100644 index a442a0b..0000000 --- a/.trellis/tasks/archive/2026-08/08-21-ghcr-release-install/research/current-state.md +++ /dev/null @@ -1,10 +0,0 @@ -# Current repository and GitHub state - -- `compose.yaml` already points five services at `ghcr.io/leonfox28/simplus-{control,agent,netd}` with a default `v0.1.0` tag. -- `.github/workflows/containers.yml` already builds the three targets and only pushes on `v*`, but its tag validation is broad and it publishes corresponding-source assets before the image matrix. -- `docs/installation.md` and README still assume a repository checkout for Compose/host scripts and document local image build as the pre-release fallback. -- `make container-build` is an intentional developer validation target and should remain outside the production installation path. -- The public GitHub repository had no tags or Releases when planning began; the containers workflow had only successful PR runs. Package listing was unavailable to the local token, and no tag-triggered publication had occurred. -- GitHub documents that a first Container registry package defaults to private; a public package permits anonymous pulls, and changing a personal package to public is an explicit owner action. -- Local `main` was fast-forwarded to `origin/main` before creating `feat/ghcr-release-install`; the only upstream container difference was the already-merged pinned Go base-image update. -- Existing specs require the three-image privilege split, source-before-image GPL publication, public/private evidence boundary, and no stable deployment claim before clean-VM lifecycle evidence. diff --git a/.trellis/tasks/archive/2026-08/08-21-ghcr-release-install/research/v0.1.0-release-failure.md b/.trellis/tasks/archive/2026-08/08-21-ghcr-release-install/research/v0.1.0-release-failure.md deleted file mode 100644 index ec02cdc..0000000 --- a/.trellis/tasks/archive/2026-08/08-21-ghcr-release-install/research/v0.1.0-release-failure.md +++ /dev/null @@ -1,48 +0,0 @@ -# Bug Analysis: GHCR HEAD probe exited before image publication - -## 1. Root Cause Category - -- **Category**: D/E — Test coverage gap plus implicit curl assumption. -- **Specific Cause**: the workflow used `curl --request HEAD` for two registry - manifest-existence probes. That changes the HTTP method but does not enable - curl's HEAD response handling, so curl still expected the `Content-Length` - body that GHCR correctly omitted and exited 18 before returning the HTTP - status to the shell. - -The GitHub Actions log is decisive evidence: all three image jobs stopped in -the first probe with `curl: (18) transfer closed with 70 bytes remaining to -read`; no build, digest staging, tag promotion, or manifest publication ran. - -## 2. Why Earlier Checks Missed It - -1. Static workflow and action lint validated YAML/shell syntax, not curl's - protocol-mode semantics. -2. PR jobs intentionally never authenticate to or probe GHCR, so the correct - no-push policy also meant this tag-only branch first ran in production. -3. Contract tests checked immutable-tag ordering and failure closure but did - not distinguish curl's `--head` behavior from a manually selected method. - -## 3. Prevention Mechanisms - -| Priority | Mechanism | Specific action | Status | -| --- | --- | --- | --- | -| P0 | Runtime correctness | Replace both probes with `curl --head` while retaining explicit 200/404 classification | Done | -| P0 | Test coverage | Require exactly two `--head` probes and reject `--request HEAD` in the container workflow contract | Done | -| P1 | Documentation | Record the curl HEAD/body-transfer rule in the versioned release spec and error matrix | Done | -| P1 | Rollout discipline | Keep incomplete `v0.1.0` immutable and publish the code fix only from a new `v0.1.1` tag | Done | - -## 4. Systematic Expansion - -- **Similar issues**: both pre-build reuse and pre-promotion race probes used - the same incorrect flag, so the fix and assertion cover both call sites. -- **Design improvement**: the registry response remains classified solely by - HTTP status; no response body is parsed or required. -- **Process improvement**: tag-only network operations need semantic contract - assertions even when PR policy correctly forbids external publication. - -## 5. Knowledge Capture - -- [x] Updated `.trellis/spec/core/infra/build-and-generated-files.md`. -- [x] Added a focused regression assertion to `internal/containercontract`. -- [x] Recorded the immutable failed-release evidence in the active task. -- [x] Completed the replacement `v0.1.1` Pre-release and anonymous pull checks. diff --git a/.trellis/tasks/archive/2026-08/08-21-ghcr-release-install/task.json b/.trellis/tasks/archive/2026-08/08-21-ghcr-release-install/task.json deleted file mode 100644 index 78808c0..0000000 --- a/.trellis/tasks/archive/2026-08/08-21-ghcr-release-install/task.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "id": "ghcr-release-install", - "name": "ghcr-release-install", - "title": "GHCR release installation", - "description": "", - "status": "completed", - "dev_type": null, - "scope": "infra,docs", - "package": "core", - "priority": "P2", - "creator": "Leon", - "assignee": "Leon", - "createdAt": "2026-08-21", - "completedAt": "2026-08-21", - "branch": "docs/v0-1-1-release-evidence", - "base_branch": "main", - "worktree_path": null, - "commit": "81d3f3ac3a7868ea39e90bb3533b599fd2939ccd", - "pr_url": "https://github.com/leonfox28/simplus/pull/6", - "subtasks": [], - "children": [], - "parent": null, - "relatedFiles": [], - "notes": "v0.1.0 remains an incomplete immutable Pre-release. v0.1.1 completed release run 32480362514 and anonymous bundle/image verification.", - "meta": { - "release_tag": "v0.1.1", - "incomplete_release_tag": "v0.1.0", - "initial_merge_commit": "d7aaa34aeb6936f3683f37bacbef71ca9a013f57", - "initial_pr_url": "https://github.com/leonfox28/simplus/pull/5", - "evidence_pr_url": "https://github.com/leonfox28/simplus/pull/7", - "evidence_merge_commit": "bd09718f07e1026843da22bc1f69bbe6b0f52c74", - "release_run": 32480362514, - "release_url": "https://github.com/leonfox28/simplus/releases/tag/v0.1.1" - } -} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-08/08-21-improve-user-readme/check.jsonl b/.trellis/tasks/archive/2026-08/08-21-improve-user-readme/check.jsonl deleted file mode 100644 index 8aee2b9..0000000 --- a/.trellis/tasks/archive/2026-08/08-21-improve-user-readme/check.jsonl +++ /dev/null @@ -1,3 +0,0 @@ -{"file":".trellis/spec/core/docs/documentation-ownership.md","reason":"Review that the root README remains an entry point and canonical operational/evidence details stay in their owning documents."} -{"file":".trellis/spec/core/docs/privacy-and-publication.md","reason":"Review the final public text for private deployment details, credentials, identities, topology, logs, or overclaimed evidence."} -{"file":".trellis/spec/core/infra/containers-and-privileges.md","reason":"Review install commands, data paths, image/version behavior, permissions, and lifecycle claims against the production container contract."} diff --git a/.trellis/tasks/archive/2026-08/08-21-improve-user-readme/implement.jsonl b/.trellis/tasks/archive/2026-08/08-21-improve-user-readme/implement.jsonl deleted file mode 100644 index a67a724..0000000 --- a/.trellis/tasks/archive/2026-08/08-21-improve-user-readme/implement.jsonl +++ /dev/null @@ -1,4 +0,0 @@ -{"file":".trellis/spec/core/docs/documentation-ownership.md","reason":"Defines progressive disclosure and the canonical owners for root README, installation, compatibility, handoff, and release-facing documentation."} -{"file":".trellis/spec/core/docs/privacy-and-publication.md","reason":"Constrains the new local-deployment conclusion to a sanitized evidence statement and prevents private host/runtime material from entering public docs."} -{"file":".trellis/spec/core/infra/containers-and-privileges.md","reason":"Provides the supported release-bundle, persistent-data, host-preparation, lifecycle, and evidence boundaries that onboarding commands must preserve."} -{"file":".trellis/tasks/archive/2026-08/08-21-migrate-local-production/research/current-state.md","reason":"Provides the already-sanitized observed deployment baseline without requiring rereading private runtime evidence."} diff --git a/.trellis/tasks/archive/2026-08/08-21-improve-user-readme/prd.md b/.trellis/tasks/archive/2026-08/08-21-improve-user-readme/prd.md deleted file mode 100644 index bb18677..0000000 --- a/.trellis/tasks/archive/2026-08/08-21-improve-user-readme/prd.md +++ /dev/null @@ -1,72 +0,0 @@ -# 优化用户 README 与部署说明 - -## Goal - -让首次接触 Simplus 的用户能够快速判断项目是否适合自己,并在受支持宿主上从版本化 -Release 安全安装到持久部署目录、找到首次登录入口、理解数据/升级边界;同时以正确证据 -等级记录已完成的本机生产形态切换,不夸大为 clean-VM Runtime。 - -## Background - -- 根 `README.md` 的技术事实、内部链接和 `v0.1.1` Release 命令当前有效, - `make check-docs` 已通过。 -- 当前入口把快速安装、`v0.1.0` 失败史、GHCR visibility 和发布工程细节混在一起; - 用户按现有命令直接在解压目录启动时,持久 `./data` 可能留在下载/临时目录。 -- 顶部“请勿用于生产”与后文“唯一受支持的生产部署方式”容易被理解为冲突;准确含义是 - Docker Compose 是唯一正式部署形态,但 `v0.1.1` 仍是只适合受控评估的 Pre-release - 部署候选。 -- 开头列出电话、数字音频和 eUICC 管理目标,但真实硬件能力尚未全部开放;README 必须 - 区分 Simulator/Fixture 与已经进入 production Agent 的能力。 -- 本机已使用校验过的 `v0.1.1` Release/GHCR 生产形态完成现有数据的停机快照、恢复、 - 回滚演练、最终切换以及 health/digest/mount 验收。该结论可公开脱敏记录,但宿主不是 - clean VM,不能据此提升 clean-VM 生命周期或稳定版本声明。 - -## Requirements - -- 根 README 使用渐进披露:简短说明项目、成熟度、支持边界和当前能力,然后提供一个 - 可复制的快速开始;发布失败史、package visibility 和完整生命周期留给规范文档。 -- 快速开始必须先让用户校验 Release 归档,再安装到明确的持久目录 - `/opt/simplus`,避免从下载目录长期运行;数据路径在 `compose up` 之前说明。 -- 安装命令只使用公开 `v0.1.1` 部署包和写死版本的 GHCR 镜像,不使用 Git checkout、 - 本地 build、`latest` 或生产镜像 tag 环境变量。 -- 安装入口明确普通用户只下载一个版本化部署归档和一个 SHA-256 文件;说明归档内 - Compose/环境模板是运行核心,其余脚本、版本和许可材料是同版本安全与合规附件,避免 - 把 Release 的源码、包和 digest 资产误解为多套必装归档。 -- 用户能直接检查 Debian 13/amd64、Docker/Compose 版本和 ttyUSB 数字 GID;仍明确 - rootful/no-userns、可信 LAN 和不能暴露公网的边界。 -- 首次登录说明包含 `bootstrap` 的一次性随机密码语义、默认 Web 地址,以及已有实例不会 - 重新输出/覆盖密码的含义;不得把真实凭据或日志写入文档。 -- 根 README 只保留最小数据安全提醒,并链接 canonical `docs/installation.md` 获取升级、 - 备份、回滚、权限和故障处理;文档链接名改为“生产部署与生命周期”。 -- `docs/installation.md` 成为持久部署目录与升级命令的唯一完整来源; - `packaging/container/README.md` 与其保持一致,但源修改只影响未来 Release,不能改写已 - 发布的不可移动 `v0.1.1` 资产。 -- `docs/compatibility.md` 与 `docs/handoff.zh-CN.md` 仅增加脱敏结论:开发宿主已完成 - `v0.1.1` Release 生产形态的有数据切换和回滚/验收;clean-VM Runtime 仍待完成。 -- 不新增产品能力、部署平台、硬件授权、稳定性承诺、架构决定或截图。 - -## Acceptance Criteria - -- [x] 新用户从根 README 前两屏能回答:项目用途、当前成熟度、支持宿主、当前可安装版本、 - 真实已开放能力和关键未验证边界。 -- [x] 快速开始从下载校验到 `/opt/simplus` 启动形成连续命令,启动前明确三项 `.env` - 参数、数据目录和备份责任,不从临时解压目录长期运行。 -- [x] `README.md` 不再展开 `v0.1.0` 失败时间线或 package visibility 操作细节,而是链接 - canonical 安装/兼容性文档。 -- [x] 根 README、安装文档和部署包 README 的命令、版本、路径、镜像/数据边界一致,且 - 不暗示修改已发布的 `v0.1.1` Release 资产。 -- [x] 三处安装入口都明确只需一个部署归档和一个校验文件,GHCR 镜像由 Compose 拉取, - 归档内辅助文件不构成额外下载项。 -- [x] 本机验证结论明确限定为开发宿主上的生产形态切换,不声称 clean-VM Runtime、稳定版 - 或其他硬件/运营商兼容性。 -- [x] 所有本地 Markdown 链接有效;`make check-docs`、`make check-container-files`、 - `go test ./internal/containercontract` 和 `git diff --check` 通过。 -- [x] 公开文本不包含真实主机路径、地址、用户名、数据、凭据、设备/SIM 身份、日志或 - 排障时间线。 - -## Out of Scope - -- 重新发布、替换或补写 `v0.1.1` Release/GHCR 资产。 -- 把当前宿主声明为 clean VM,或把部署候选提升为稳定生产版本。 -- 修改安装脚本、Compose、镜像、运行时数据、权限、端口、数据库或容器状态。 -- 添加截图、真实日志、现场拓扑或逐次部署记录。 diff --git a/.trellis/tasks/archive/2026-08/08-21-improve-user-readme/result.md b/.trellis/tasks/archive/2026-08/08-21-improve-user-readme/result.md deleted file mode 100644 index 36be263..0000000 --- a/.trellis/tasks/archive/2026-08/08-21-improve-user-readme/result.md +++ /dev/null @@ -1,28 +0,0 @@ -# 实施结果 - -## 文档变更 - -- 根 README 采用渐进披露,并提供从 `v0.1.1` 校验到 `/opt/simplus` 启动的连续入口。 -- 全新安装命令在下载、校验、目标目录创建或宿主/Compose 检查失败时停止;已有 - `/opt/simplus` 不会被全新安装覆盖,也不再递归改写目录属主。部署包 README 直接把 - 已校验归档解压到新建目标,不会从可能复用的解包目录复制额外文件。 -- 安装文档成为持久目录、数据、备份、升级、回滚和遗留状态边界的完整来源。 -- 未来部署包的 README 与上述路径、三项环境变量、首次密码和数据安全语义保持一致; - 已发布的 `v0.1.1` 资产未被修改。 -- 三处安装入口明确普通用户只下载一个版本化 `.tar.gz` 和一个 SHA-256 文件;三个镜像 - 由 Compose 拉取,归档内其余脚本、版本和许可文件用于同版本安全检查与合规,不是额外 - 安装包。启动命令等待 `bootstrap` 成功退出后才显示状态和首次凭据日志。 -- 兼容性与交接只记录开发宿主上的脱敏生产形态切换结论,未提升 clean-VM Runtime。 -- 现有容器发布规范同步记录全新安装拒绝已有部署根、非递归属主修改和失败即停合同。 - -## 验证 - -- `make check-docs` -- `make check-container-files` -- `go test -count=1 ./internal/containercontract` -- `make lint` -- `umask 0022 && CI=true make test` -- `git diff --check` - -以上检查均通过。完整测试包含 Go、19 个 Vitest 文件/70 项测试、Web typecheck、worktree -manifest 与 Simulator supervisor。未修改脚本、Compose、Release 资产、容器或运行时数据。 diff --git a/.trellis/tasks/archive/2026-08/08-21-improve-user-readme/task.json b/.trellis/tasks/archive/2026-08/08-21-improve-user-readme/task.json deleted file mode 100644 index 6390915..0000000 --- a/.trellis/tasks/archive/2026-08/08-21-improve-user-readme/task.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "id": "improve-user-readme", - "name": "improve-user-readme", - "title": "优化用户 README 与部署说明", - "description": "", - "status": "completed", - "dev_type": null, - "scope": null, - "package": "core", - "priority": "P2", - "creator": "Leon", - "assignee": "Leon", - "createdAt": "2026-08-21", - "completedAt": "2026-08-22", - "branch": null, - "base_branch": "main", - "worktree_path": null, - "commit": null, - "pr_url": null, - "subtasks": [], - "children": [], - "parent": null, - "relatedFiles": [], - "notes": "", - "meta": {} -} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-08/08-21-migrate-local-production/check.jsonl b/.trellis/tasks/archive/2026-08/08-21-migrate-local-production/check.jsonl deleted file mode 100644 index 4a90a11..0000000 --- a/.trellis/tasks/archive/2026-08/08-21-migrate-local-production/check.jsonl +++ /dev/null @@ -1,4 +0,0 @@ -{"file":".trellis/spec/core/infra/containers-and-privileges.md","reason":"Review the final deployment against the fixed image, UID, mount, host-check, lifecycle, and rollback contracts."} -{"file":".trellis/spec/core/infra/hardware-and-hil-safety.md","reason":"Review that execution stayed inside authorized production startup and did not expose or trigger prohibited hardware/private evidence paths."} -{"file":".trellis/spec/core/docs/privacy-and-publication.md","reason":"Review all committed task and journal artifacts for credentials, identities, topology, raw logs, databases, or other private deployment evidence."} -{"file":".trellis/tasks/08-21-migrate-local-production/research/current-state.md","reason":"Compare post-cutover metadata with the sanitized baseline without rereading private runtime state."} diff --git a/.trellis/tasks/archive/2026-08/08-21-migrate-local-production/design.md b/.trellis/tasks/archive/2026-08/08-21-migrate-local-production/design.md deleted file mode 100644 index 31a4e9b..0000000 --- a/.trellis/tasks/archive/2026-08/08-21-migrate-local-production/design.md +++ /dev/null @@ -1,88 +0,0 @@ -# Design: local production cutover with preserved state - -## Boundaries - -This is an operational cutover, not a product-code or schema change. The verified -`v0.1.1` Release bundle remains immutable. Its files are installed under `/opt/simplus`, -and its relative `./data` mounts resolve to `/opt/simplus/data`. The repository Compose and -`.env` are never rewritten; migration commands never overwrite/delete `<repo>/data`, while a -rollback may resume its ordinary service writes before a fresh retry snapshot. - -The target retains the fixed `simplus` Compose project name and the existing three-image -privilege model. Old and new Compose definitions must never be running concurrently. - -## Storage layout - -```text -<repo>/data/ preserved source state / immediate rollback source -/var/backups/simplus/<id>/ root-only archive, checksum, and sanitized cutover metadata -/opt/simplus/ verified v0.1.1 deployment bundle -/opt/simplus/data/ restore of the stopped pre-cutover archive -``` - -The backup directory is mode 0700 and the archive/checksum are mode 0600. The deployment -root and public bundle files are root-owned and readable; `.env` contains only the three -non-secret production parameters. `data-init` preserves/fixes the defined UID/mode contract -for the restored `core` and `agent` directories. - -## Cutover data flow - -1. While the current deployment remains healthy, download the `v0.1.1` deployment archive, - checksum, and image digest manifest into a temporary directory. Verify checksum, strict - asset allowlist, VERSION, literal image tags, absence of `build`/`latest`, Compose render, - anonymous pulls, and pulled digests. -2. Install the verified managed files under `/opt/simplus`, create the exact three-key - `.env`, run the bounded host preparation, and run the host check. No current container is - interrupted unless all pre-cutover gates pass. -3. Enter the no-write acceptance window and stop the source Compose with `down` and no - volume flag. Confirm no old business container remains running. -4. As root, archive the whole `<repo>/data` with numeric ownership, ACL/xattr, sparse-file, - and single-filesystem semantics. Store and verify SHA-256, test the archive, restore that - same archive to `/opt/simplus/data`, then compare both source and restored trees against - the archive without emitting filenames or contents. -5. Re-run host/config checks and start only `/opt/simplus/compose.yaml`. Let the standard - typed startup register reviewed USB IDs, create bounded runtime network objects, and - restore existing service intent. Do not trigger any manual business or hardware action. -6. Verify service health, one-shot exit codes, image tags/digests, Compose labels, and exact - bind-mount sources using metadata-only Docker inspection. Never print bootstrap output or - raw service logs. - -## Compatibility and side effects - -`v0.1.1` is still a Pre-release deployment candidate. Starting it may perform forward-only -database migration on `/opt/simplus/data`; it cannot write the preserved source copy. -Normal Compose startup may briefly interrupt the existing modem/network service and restore -previously saved activation intent. Host preparation idempotently writes only the fixed -module-load configuration and loads `option`; it does not write an arbitrary USB ID, RF -setting, SIM state, message, or call. - -The production HTTP/controller ports continue listening on the same addresses and ports as -before. Firewall or exposure changes are outside this task. - -## Failure and rollback - -Every staging/check failure before old Compose shutdown is fail-closed and leaves the old -deployment running. During cutover, any archive/restore comparison failure stops before new -Compose startup. - -If target startup or metadata-only acceptance fails before user traffic resumes: - -1. stop `/opt/simplus` Compose without `-v`; -2. keep the failed production copy and private archive for diagnosis; -3. start the repository Compose with its unchanged `.env` and preserved `<repo>/data`; -4. verify the three old services return healthy using metadata-only checks. - -Once the target has accepted new business writes, rollback to the pre-cutover source would -discard those writes. At that point the workflow stops and requires a separate data decision; -it never silently changes image tags or restores an older database. - -The original data and same-disk archive are intentionally not deleted after success. Their -later retention/cleanup is a separate destructive action requiring explicit authorization. - -## Privacy - -Commands may record public image digests, versions, exit states, owners/modes, byte counts, -and mount destinations. They must not emit archive listings, filenames within private state, -database values, credentials, device/SIM identity, endpoints/topology, raw logs, or HIL -transcripts. Failure inspection is limited to stable container state and already-redacted -health information; otherwise roll back and report the stage. diff --git a/.trellis/tasks/archive/2026-08/08-21-migrate-local-production/implement.jsonl b/.trellis/tasks/archive/2026-08/08-21-migrate-local-production/implement.jsonl deleted file mode 100644 index 4dca38a..0000000 --- a/.trellis/tasks/archive/2026-08/08-21-migrate-local-production/implement.jsonl +++ /dev/null @@ -1,3 +0,0 @@ -{"file":".trellis/spec/core/infra/containers-and-privileges.md","reason":"Defines the production Compose shape, fixed data mounts, host preparation, release-bundle interface, and no-privilege-expansion boundary."} -{"file":".trellis/spec/core/infra/hardware-and-hil-safety.md","reason":"Defines the exact authorization and privacy boundary for Compose hardware startup and forbids unintended RF, messaging, SIM, or HIL actions."} -{"file":".trellis/tasks/08-21-migrate-local-production/research/current-state.md","reason":"Provides the sanitized observed host, running-container, data-layout, and target-path facts needed for the cutover."} diff --git a/.trellis/tasks/archive/2026-08/08-21-migrate-local-production/implement.md b/.trellis/tasks/archive/2026-08/08-21-migrate-local-production/implement.md deleted file mode 100644 index 67dab5a..0000000 --- a/.trellis/tasks/archive/2026-08/08-21-migrate-local-production/implement.md +++ /dev/null @@ -1,131 +0,0 @@ -# Implementation plan - -## 1. Pre-cutover inventory and gates (current service stays running) - -- [x] Resolve explicit source, target (`/opt/simplus`), backup, and temporary paths; reject - pre-existing unexpected target content and confirm sufficient space. -- [x] Record only current container names, service states, safe OCI revision labels, exact - bind-mount metadata, and data-root owner/mode/size. Do not inspect environment, data, - device identity, or logs. -- [x] Download the `v0.1.1` Compose archive, SHA-256 file, and image digest manifest from the - matching GitHub Release into `mktemp -d`; verify checksum before extraction. -- [x] Verify the extracted top-level directory and exact eight-file allowlist, expected modes, - `VERSION`, literal three-image `v0.1.1` references, and absence of `build`, `latest`, or - image-tag substitution. -- [x] Install bundle files under `/opt/simplus` with explicit root ownership/modes; create a - three-key `.env` with ports 8080/19090 and device GID 20. Do not copy source Compose. -- [x] Run `sudo bash /opt/simplus/prepare-container-host.sh`, then the read-only host check and - `docker compose config --quiet` from `/opt/simplus`. -- [x] Pull all images anonymously, compare the resolved platform/digests and OCI - source/version/revision/license labels with the Release manifest, and stop before - downtime on any mismatch. - -### Pre-cutover result (2026-08-21) - -- All seven gates passed while the existing `:dev` deployment remained running and healthy; - no source container or persistent-state directory was stopped, restarted, or modified. -- The verified Pre-release is `v0.1.1`, commit - `81d3f3ac3a7868ea39e90bb3533b599fd2939ccd`, platform `linux/amd64`. -- `/opt/simplus` contains the eight root-owned Release bundle files plus the exact three-key - root-owned `.env`; `/opt/simplus/data` remains absent until the quiesced restore stage. -- Host preparation installed `/etc/modules-load.d/simplus.conf` as root-owned mode `0644`, - loaded `option`, and did not write a USB ID. The host check and Compose render passed. -- Anonymous pulls used an empty Docker credential directory. Resolved public image digests: - - control: `sha256:f93f88dd228f610d8e4c9e4e9470b8a42248a1ec6b1d887365092169a7a5d57d` - - agent: `sha256:9640e6c5c59590dde07cdf44242f43d8e3bd6f61641abe9716d5767445d28716` - - netd: `sha256:c6423e1cf21b84ad1864fead9e4bbdac556689286575460d3614e962508f0f39` - -## 2. Quiesced snapshot and restore (downtime begins) - -- [x] Announce the cutover stage; do not allow user/business writes until acceptance ends. -- [x] From the repository project, run ordinary `docker compose down` without `-v`; verify old - business containers are no longer running and original bind data still exists. -- [x] Create a unique root-only `/var/backups/simplus/<cutover-id>` and archive the complete - source `data/` with numeric owner, ACL/xattr, sparse and one-filesystem preservation. -- [x] Write and verify SHA-256, test archive readability without listing private paths, and - compare the stopped source tree to the archive. -- [x] Restore the same archive beneath `/opt/simplus`, verify owner/mode and absence of - symlinks, then compare the restored tree to the archive without printing private paths. -- [x] Re-run `/opt/simplus/check-container-host.sh /opt/simplus` and - `docker compose config --quiet`; do not start on any mismatch. - -### Quiesced snapshot result (2026-08-21) - -- The source Compose was stopped with ordinary `down`; named volumes and the original bind - data were retained. No production container was started in this stage. -- A root-only cutover directory contains the complete compressed archive and its verified - SHA-256 file, both root-owned mode `0600`. Archive readability and the stopped - source/archive comparison passed without emitting private paths. -- `/opt/simplus/data` was restored from that same archive. The restored data root is - root-owned mode `0755`; `core` is `10001:10001` mode `0700` and `agent` is - `10002:10002` mode `0700`. No symlink was found and the restored/archive comparison passed. -- The post-restore host check and Compose render passed. The old business services remain - stopped for the production-start acceptance stage. -- An initial checksum-verification command could not enter the intentionally root-only backup - directory as the unprivileged caller. The rollback gate restored all three old services to - healthy before a new unique stopped snapshot was taken; the unused attempt remains retained. -- Two later target attempts passed every production health/image/mount gate but were rolled - back by extra, unreviewed diagnostics. Each rollback restored the source services healthy; - each failed target data tree was preserved in a unique root-only quarantine. The final - cutover therefore took a fresh stopped snapshot after proving zero source-config container - mounts and no open writer against the stopped source data. - -## 3. Production start and metadata-only acceptance - -- [x] Run `/opt/simplus` `docker compose up -d`; poll in intervals shorter than 60 seconds and - require `agent`, `netd`, and `app` healthy plus `data-init`/`bootstrap` exit 0. -- [x] Inspect only safe Docker metadata to assert all running image tags/digests match the - Release manifest and exact core/agent mount sources resolve under `/opt/simplus/data`. -- [x] Confirm the source Compose has no running containers, the production Compose renders - again, the original source data still has its pre-cutover metadata, and no unexpected - new admin/bootstrap output was read. -- [x] Leave `/opt/simplus`, the preserved source data, backup archive/checksum, old images, and - runtime volumes in place. Remove only the temporary public Release download directory. - -### Production acceptance result (2026-08-21) - -- The authoritative final run accepted `app`, `agent`, and `netd` as running/healthy and - `data-init`/`bootstrap` as exited with status 0; no bootstrap or service log was read. -- All five containers identify `/opt/simplus/compose.yaml` and `/opt/simplus` through their - Compose labels. No source-config container remains. -- The literal `v0.1.1` tags, three public Release digests, and required OCI - source/version/revision/license labels match. The five persistent bind checks resolve only - to `/opt/simplus/data`, `/opt/simplus/data/core`, or `/opt/simplus/data/agent` as designed. -- Source `core`/`agent` roots retain their fixed owner/mode and are not mounted into the - production containers. The final archive/checksum, original source state, every earlier - migration attempt, failed target quarantine, old images, and runtime volumes remain. -- The temporary public Release download and the synthetic tar-semantics probe were removed; - no private data, archive, log, credential, identity, or topology was written to Git. - -## 4. Failure rollback gate - -- [x] Before production startup: keep old Compose running, or if already quiesced restart it - from the repository using preserved data; do not use the partially restored target. -- [x] After failed target startup but before user writes: run target `docker compose down` - without `-v`, restart source Compose, and verify its three business services healthy. -- [x] If any new business write may have occurred, stop and request a separate data recovery - decision instead of silently returning to the pre-cutover database. -- [x] Never broaden hardware inspection, print raw logs, modify RF/SIM state, or delete data to - repair a failed gate. - -### Rollback result (2026-08-21) - -- The source-restart branch was exercised after one checksum invocation error and two - validation-harness errors. Each time it stopped the target without `-v` and restored all - three source business services healthy before a fresh snapshot was attempted. -- No known user write occurred in an acceptance window. Every retry nonetheless used a new - unique stopped snapshot; no earlier archive was reused as current state. -- The two healthy-target rollbacks were caused by checks absent from the approved gate list, - not by an image, health, digest, OCI-label, Compose-label, mount, or data-restore mismatch. - The reusable prevention contract is recorded in the infra spec and validation-loop research. - -## 5. Completion and repository hygiene - -- [x] Record only sanitized versions, public digests, states, owner/mode summaries, and whether - each acceptance gate passed in the task notes/journal. -- [x] Run `git diff --check` and a scoped secret/private-data review of task artifacts. -- [x] Use `trellis-check` for final spec/plan/result consistency; update infra spec only if an - actual reusable contract changed. The live-cutover acceptance contract did change after - the repeated rollback-loop analysis. -- [x] Commit only Trellis task/journal records, then archive the task. Production data, - archives, Release downloads, logs, and host evidence remain outside Git. diff --git a/.trellis/tasks/archive/2026-08/08-21-migrate-local-production/prd.md b/.trellis/tasks/archive/2026-08/08-21-migrate-local-production/prd.md deleted file mode 100644 index 1f4a108..0000000 --- a/.trellis/tasks/archive/2026-08/08-21-migrate-local-production/prd.md +++ /dev/null @@ -1,70 +0,0 @@ -# 切换本地生产容器并保留持久数据 - -## Goal - -将当前本机运行的 Simplus 源码 Compose `:dev` 容器切换为 GitHub Release `v0.1.1` -部署包与 GHCR 版本化镜像;新容器使用停机时取得的完整持久数据副本,原数据与独立备份 -不被迁移命令覆盖或删除,以便在验收窗口内可靠回滚。 - -## Background - -- `v0.1.1` 是首个资产、三张 `linux/amd64` 镜像和 digest manifest 完整且可匿名拉取的 - Pre-release;不完整的 `v0.1.0` 不得使用。 -- 只读盘点确认宿主为 Debian 13/amd64、rootful Docker,Docker Compose 版本满足要求; - 固定 `option1/new_id` 可用,ModemManager 与遗留 production units 均未运行,开发 - Agent 已停用。 -- 当前 `simplus` 项目由源码 `compose.yaml` 启动,`app`、`agent`、`netd` 健康, - `data-init` 与 `bootstrap` 已正常退出,但三张镜像仍为旧 revision 的 `:dev` tag。 -- 实际持久 bind mount 是 `<repo>/data/core` 与 `<repo>/data/agent`;目录分别为 - UID 10001/10002、mode 0700,未发现 symlink,总状态约 51 MiB。当前根文件系统的空间 - 足以同时保留原目录、生产副本和迁移备份。 -- 数据库升级只保证向前迁移;因此不能让 `v0.1.1` 直接修改唯一一份旧数据后再假设旧 - `:dev` 镜像可以安全接管。 - -## Requirements - -- 生产部署根固定为 `/opt/simplus`,包含校验过的 Release 受管文件、只含三项允许参数的 - `.env`,以及从停机快照恢复出的 `data/core` 和 `data/agent`。 -- 迁移备份固定放在 root-only 的 `/var/backups/simplus/<cutover-id>`;原 - `<repo>/data` 不得被迁移命令覆盖或删除;安全回滚重新启动旧服务时允许它产生正常运行 - 写入,此后的重试必须重新停机取快照。两者均不得进入 Git。 -- 生产镜像只使用 `ghcr.io/leonfox28/simplus-{control,agent,netd}:v0.1.1`,不得使用 - 本地 `build`、`:dev`、`latest` 或环境变量镜像 tag。 -- 保留当前安装参数:HTTP 端口 8080、controller 端口 19090、ttyUSB 数字 GID 20。 -- 下载、checksum/资产白名单、Compose 渲染、镜像 digest 和宿主检查必须在停机前完成; - 任一门禁失败时不得中断当前服务。 -- 停机后使用保留 owner、mode、ACL/xattr 和稀疏文件语义的完整归档取得一致快照;验证 - archive、checksum 及恢复树后,才允许启动生产容器。不得只复制数据库单文件。 -- 切换只使用普通 `docker compose down`,不得使用 `down -v`、删除 runtime volume、 - 删除原数据或清理备份。 -- 启动仅授权生产 Compose 的标准副作用:写入固定 module-load 配置、注册已审查的 USB - serial ID、创建临时 Docker/netd 网络对象,并按已有持久状态恢复服务意图。不得执行 - 人工 RF 更改、短信、电话、SIM/eUICC mutation、模组持久写入或新的 HIL 动作。 -- 验收期间不进行用户写入;若生产服务未健康,先停止生产 Compose,再从未被迁移命令 - 覆盖的源码数据启动旧 `:dev` Compose。生产副本已经接收新写入后不得静默回退。 -- 全过程不得读取、打印、提交或发布数据库内容、管理员凭据、设备/SIM 身份、私有端点、 - 网络拓扑或原始容器/HIL 日志。 - -## Acceptance Criteria - -- [x] 停机前门禁确认 `v0.1.1` 归档 checksum、严格文件清单、VERSION、三张固定镜像、 - digest manifest、Compose config/pull 和宿主检查全部通过。 -- [x] 已以私有权限生成完整迁移归档及 SHA-256,并验证归档可读;原 `<repo>/data` 未被 - 覆盖或删除,最终生产副本来自最后一次停机后的最新状态。 -- [x] `/opt/simplus/data` 从同一停机归档恢复,文件内容和元数据比较通过,且不存在 symlink。 -- [x] 新 Compose 的三个业务容器均健康,两个 one-shot 容器成功退出;未输出 bootstrap - 或业务原始日志。 -- [x] 运行容器实际使用 `v0.1.1` 镜像/digest,`core` 与 `agent` mount source 分别是 - `/opt/simplus/data/core` 与 `/opt/simplus/data/agent`。 -- [x] 生产 Compose 二次渲染仍通过,现有管理员未被覆盖,旧源码 Compose 未运行且源码 - `compose.yaml`/`.env` 未被改写。 -- [x] 原数据、私有备份、旧镜像标识和明确的回滚命令均保留;任务不执行清理。 -- [x] Git 记录只包含脱敏任务/会话信息,不包含 Release 下载物、数据、备份或私有运行证据。 - -## Out of Scope - -- 迁移遗留原生 `/var/lib/simplus`、systemd production 状态或其他主机的数据。 -- 修改 Web/API、数据库 schema、容器权限、UID、挂载合同、宿主 Docker daemon 或防火墙。 -- 真实短信、电话、RF/SIM/eUICC 操作、业务功能验收或新的 HIL/clean-VM 取证。 -- 删除旧源码数据、迁移备份、旧镜像或 runtime volumes;这些只能在稳定运行后另行授权。 -- 同盘迁移备份不作为磁盘故障或主机灾难恢复方案。 diff --git a/.trellis/tasks/archive/2026-08/08-21-migrate-local-production/research/current-state.md b/.trellis/tasks/archive/2026-08/08-21-migrate-local-production/research/current-state.md deleted file mode 100644 index d8da450..0000000 --- a/.trellis/tasks/archive/2026-08/08-21-migrate-local-production/research/current-state.md +++ /dev/null @@ -1,40 +0,0 @@ -# Sanitized current-state inventory - -## Host - -- Debian 13, Linux amd64. -- Rootful Docker Engine is reachable and Docker Compose exceeds the supported minimum. -- Non-interactive sudo is available for the bounded `/opt`, `/var/backups`, archive, and - module-load operations in the approved plan. -- The fixed option-driver sysfs point satisfies the release preflight. -- ModemManager is inactive. All allowlisted legacy production services are absent/inactive; - the development Agent is inactive and disabled. - -## Running deployment - -- Compose project: `simplus`, sourced from the repository development Compose. -- `app`, `agent`, and `netd` are healthy; `data-init` and `bootstrap` exited successfully. -- All three images use the explicit `:dev` tag and share an older source revision than - the `v0.1.1` release. -- Only the safe OCI labels, container states, Compose labels, and mount metadata were read. - No container environment, database, bootstrap output, device identity, or raw log was read. - -## Persistent state - -- Core: `<repo>/data/core` -> `/var/lib/simplus`, UID/GID 10001, mode 0700. -- Agent: `<repo>/data/agent` -> `/var/lib/simplus-agent`, UID/GID 10002, mode 0700. -- The combined state is approximately 51 MiB and no symlink was observed from within the - respective containers. -- Named runtime volumes contain sockets/temporary state and are not part of the migration - backup contract. -- Available space on the current filesystem is sufficient for the source data, restored - production copy, and one compressed migration archive. The archive remains same-disk - rollback protection, not disaster recovery. - -## Selected target - -- Deployment root: `/opt/simplus` (currently absent). -- Private migration backup root: `/var/backups/simplus` (currently absent). -- Migration commands do not overwrite/delete the source data. Production starts from a - verified restore of the stopped archive; a rollback may resume normal source-service writes, - so every retry requires a fresh stopped archive. diff --git a/.trellis/tasks/archive/2026-08/08-21-migrate-local-production/research/validation-loop.md b/.trellis/tasks/archive/2026-08/08-21-migrate-local-production/research/validation-loop.md deleted file mode 100644 index f858f5d..0000000 --- a/.trellis/tasks/archive/2026-08/08-21-migrate-local-production/research/validation-loop.md +++ /dev/null @@ -1,48 +0,0 @@ -# Bug Analysis: unreviewed acceptance checks caused healthy-target rollbacks - -### 1. Root Cause Category - -- **Category**: E - Implicit Assumption. -- **Specific Cause**: The execution harness assumed host `pgrep` distinguishes - native processes from container processes, even though rootful Docker exposes - container PIDs in the host PID namespace. It also let checks added during - execution share the authoritative `set -e` rollback trap. - -### 2. Why Fixes Failed - -1. The first target passed health, image, OCI-label, Compose-label, and mount - gates, but an extra post-start source/archive comparison was added. Its - failure triggered rollback and erased the quiesced state needed to classify - the historical member difference without exposing private filenames. -2. The retry added a pre-start no-writer proof, then appended a post-start - `pgrep -x` assertion not present in the approved plan. It saw the healthy - container processes as host-native and rolled the target back again. - -Both were validation-scope/mental-model failures, not production image, mount, -health, or persistent-data failures. - -### 3. Prevention Mechanisms - -| Priority | Mechanism | Specific Action | Status | -| --- | --- | --- | --- | -| P0 | Runtime | Limit automatic rollback to the frozen reviewed gate list | DONE | -| P0 | Architecture | Identify post-start ownership through Docker Compose labels/mounts, never host PID names | DONE | -| P1 | Rollback | After source restart, quarantine target and create a fresh stopped snapshot before retry | DONE | -| P1 | Documentation | Add the live-cutover acceptance scenario to the infra spec | DONE | - -### 4. Systematic Expansion - -- **Similar Issues**: `ps`, `pgrep`, and `/proc` checks on a Docker host all see - container processes unless they also classify cgroups/Compose metadata. -- **Design Improvement**: Separate authoritative acceptance functions from - diagnostics; only the former may invoke rollback. -- **Process Improvement**: Never add a new rollback gate after planning without - reviewing its semantics and side effects. - -### 5. Knowledge Capture - -- [x] Updated `.trellis/spec/core/infra/containers-and-privileges.md` with the - executable cutover/retry contract and wrong/correct command shape. -- [x] No matching project-specific spec template exists under - `src/templates/markdown/spec/`; there is no generated mirror to sync. -- [x] Commit the spec and task record with the completed deployment result. diff --git a/.trellis/tasks/archive/2026-08/08-21-migrate-local-production/task.json b/.trellis/tasks/archive/2026-08/08-21-migrate-local-production/task.json deleted file mode 100644 index e4e7c03..0000000 --- a/.trellis/tasks/archive/2026-08/08-21-migrate-local-production/task.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "id": "migrate-local-production", - "name": "migrate-local-production", - "title": "切换本地生产容器并保留持久数据", - "description": "", - "status": "completed", - "dev_type": null, - "scope": null, - "package": "core", - "priority": "P2", - "creator": "Leon", - "assignee": "Leon", - "createdAt": "2026-08-21", - "completedAt": "2026-08-21", - "branch": null, - "base_branch": "main", - "worktree_path": null, - "commit": null, - "pr_url": null, - "subtasks": [], - "children": [], - "parent": null, - "relatedFiles": [], - "notes": "", - "meta": {} -} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-09/09-14-fix-mihomo-subscription-update/check.jsonl b/.trellis/tasks/archive/2026-09/09-14-fix-mihomo-subscription-update/check.jsonl deleted file mode 100644 index bfca2c5..0000000 --- a/.trellis/tasks/archive/2026-09/09-14-fix-mihomo-subscription-update/check.jsonl +++ /dev/null @@ -1,6 +0,0 @@ -{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Review the end-to-end provider-to-browser error and success flow."} -{"file":".trellis/spec/core/backend/index.md","reason":"Check backend dependency direction, privacy, and Mihomo safety invariants."} -{"file":".trellis/spec/core/backend/api-contracts.md","reason":"Verify stable HTTP status/error mapping and absence of raw internal errors."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Apply focused-to-broad tests, generated drift, and provider-secret regression rules."} -{"file":".trellis/spec/web/frontend/index.md","reason":"Check generated client usage and authoritative HTTP state behavior."} -{"file":".trellis/spec/web/frontend/quality-guidelines.md","reason":"Check visible Web behavior, type safety, builds, and synthetic-only fixtures."} diff --git a/.trellis/tasks/archive/2026-09/09-14-fix-mihomo-subscription-update/design.md b/.trellis/tasks/archive/2026-09/09-14-fix-mihomo-subscription-update/design.md deleted file mode 100644 index 7abc877..0000000 --- a/.trellis/tasks/archive/2026-09/09-14-fix-mihomo-subscription-update/design.md +++ /dev/null @@ -1,48 +0,0 @@ -# Design: Mihomo subscription refresh compatibility - -## 1. Current failure path - -```text -Mihomo page “更新” - -> POST /api/v1/mihomo/subscriptions/{id}/refresh - -> SubscriptionService.Refresh - -> HTTPS GET with User-Agent: Simplus - -> provider HTTP 403 - -> raw Go HTTP error / string-based HTTP classification - -> 502 MIHOMO_SUBSCRIPTION_REFRESH_FAILED - -> generic Web fallback text -``` - -The running services and existing selected artifact remain healthy. A privacy-safe live comparison proved that only the client identifier changes the response class: `Simplus` receives 403, while the minimal `mihomo` identifier receives 200. - -## 2. Request compatibility contract - -Define one package constant for the subscription download user agent and set it to `mihomo`. Keep the existing `Accept` header, custom transport, public-address resolution check, HTTPS-only redirect validation, three-redirect limit, 30-second client timeout, 5 MiB body limit and node/config validation unchanged. - -The change is provider-neutral: there is no hostname branch, credential transformation, retry loop or fallback request. One administrator action still results in one application refresh attempt. - -## 3. Typed and privacy-safe refresh failure - -Add an application-owned `SubscriptionRefreshError` carrying only the stable refresh status code already persisted on the subscription, such as `SUBSCRIPTION_FETCH_FAILED`, `SUBSCRIPTION_PARSE_FAILED` or `SUBSCRIPTION_CONFIG_VALIDATION_FAILED`. - -Its `Error()` value is bounded and derived only from that stable code. It must not wrap the original `http.Client` error, URL, provider body, node content, command output or filesystem path. `refreshFailed` first records the existing failed status; if that persistence operation itself fails, the storage error is returned instead of falsely reporting a successfully recorded refresh failure. - -The HTTP mapper uses `errors.As` for `SubscriptionRefreshError` and returns the existing HTTP 502 / `MIHOMO_SUBSCRIPTION_REFRESH_FAILED` response. It logs only the stable refresh code. Invalid/not-found and unknown persistence mappings remain 400/404 and 500 respectively. No OpenAPI or generated source changes are required. - -## 4. Browser behavior - -Add a dedicated message for `MIHOMO_SUBSCRIPTION_REFRESH_FAILED` in the existing bounded `codeMessages` map. The message tells the administrator that the subscription source rejected the request or returned unusable content and recommends checking validity before retrying. It does not display the upstream status, URL, token, host, response body or raw backend detail. - -TanStack Query mutation behavior stays unchanged: no automatic retry, row-scoped busy state is cleared in `onSettled`, existing subscription snapshots remain authoritative, and a successful mutation invalidates the generated Mihomo query keys. - -## 5. Test boundaries - -- Application tests use a synthetic public HTTPS URL plus an injected `RoundTripper`; no network is opened. They assert the exact User-Agent, success persistence/artifact calls, 403 and transport failure state, stage-specific failure codes, and absence of synthetic secret markers from returned errors. -- HTTP tests invoke the error mapper with a typed refresh error and an ordinary storage error, asserting the existing 502/500 response contract and privacy-safe logging. -- Web tests construct an `ApiClientError` with `MIHOMO_SUBSCRIPTION_REFRESH_FAILED` and assert the dedicated Chinese message. The existing Mihomo page test continues to protect URL-hint-only rendering. - -## 6. Compatibility, rollout and rollback - -There is no schema migration, data rewrite, generated API change, core update, process restart protocol change or hardware action. Existing last-known-good subscription artifacts and running Mihomo state are untouched by the source change. - -Rollout is the normal application/Web release. Rollback is a source-only revert. A provider may still return 403 for an expired token, account restriction or allowlist; in that case the corrected UI reports a subscription-source failure without attempting to bypass provider policy. diff --git a/.trellis/tasks/archive/2026-09/09-14-fix-mihomo-subscription-update/implement.jsonl b/.trellis/tasks/archive/2026-09/09-14-fix-mihomo-subscription-update/implement.jsonl deleted file mode 100644 index 40cdbef..0000000 --- a/.trellis/tasks/archive/2026-09/09-14-fix-mihomo-subscription-update/implement.jsonl +++ /dev/null @@ -1,6 +0,0 @@ -{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Trace the subscription failure contract from provider request through application, HTTP, and Web presentation."} -{"file":".trellis/spec/core/backend/index.md","reason":"Preserve backend ownership, privacy boundaries, and targeted validation order."} -{"file":".trellis/spec/core/backend/api-contracts.md","reason":"Keep expected failures typed, stable, bounded, and OpenAPI-compatible."} -{"file":".trellis/spec/core/backend/quality-and-testing.md","reason":"Use synthetic provider fixtures and assert secret-free error strings and logs."} -{"file":".trellis/spec/web/frontend/index.md","reason":"Keep the generated API boundary and TanStack Query ownership unchanged."} -{"file":".trellis/spec/web/frontend/quality-guidelines.md","reason":"Verify the dedicated visible error message with focused Vitest and Web checks."} diff --git a/.trellis/tasks/archive/2026-09/09-14-fix-mihomo-subscription-update/implement.md b/.trellis/tasks/archive/2026-09/09-14-fix-mihomo-subscription-update/implement.md deleted file mode 100644 index f547e64..0000000 --- a/.trellis/tasks/archive/2026-09/09-14-fix-mihomo-subscription-update/implement.md +++ /dev/null @@ -1,67 +0,0 @@ -# Implementation plan - -## 1. Application refresh contract - -- [x] Add the `mihomo` subscription User-Agent constant and use it for every subscription fetch. -- [x] Add the bounded `SubscriptionRefreshError` type and stable code access. -- [x] Route fetch, response-size, parse and artifact build failures through the typed error without wrapping sensitive causes. -- [x] Return a storage error if persisting the failed refresh state fails. -- [x] Add synthetic success, 403/transport privacy, parse and artifact failure tests in `internal/application/mihomo`. - -Validation: - -```bash -go test -count=1 ./internal/application/mihomo -go test -race -count=1 ./internal/application/mihomo -``` - -## 2. HTTP boundary - -- [x] Replace Mihomo refresh error-string inspection with `errors.As` against the application type. -- [x] Log only the bounded refresh code and retain the existing 502 / `MIHOMO_SUBSCRIPTION_REFRESH_FAILED` response. -- [x] Add focused handler/error-mapper tests for typed refresh and ordinary persistence errors, including log privacy. - -Validation: - -```bash -go test -count=1 ./internal/api/httpapi -go test -race -count=1 ./internal/api/httpapi -``` - -## 3. Web error guidance - -- [x] Add the dedicated `MIHOMO_SUBSCRIPTION_REFRESH_FAILED` message to `web/src/api/errors.ts`. -- [x] Add a focused visible-message test using only a synthetic `ApiClientError`. - -Validation: - -```bash -corepack pnpm --dir web test -corepack pnpm --dir web typecheck -corepack pnpm --dir web build -``` - -## 4. Integrated quality gate - -- [x] Run format and generated-drift checks. -- [x] Run supported lint and broad test targets in proportion to the cross-layer change. -- [x] Confirm no private URL, token, provider body, raw log or temporary database entered the worktree. -- [x] Update the backend/API specification with the newly established User-Agent and typed-error privacy contract. - -Validation: - -```bash -make check-format -make verify-generated -make lint -make test -git diff --check -git status --short -``` - -## Risk and rollback points - -- Keep the request change to one constant; do not add provider-specific branches or retries. -- Do not change the OpenAPI contract unless implementation proves the existing 502 response cannot express the requirement. -- Do not run a production refresh, restart Mihomo, change Line egress, access hardware or perform HIL validation. -- Roll back all source changes together; there is no data migration or runtime rollback step. diff --git a/.trellis/tasks/archive/2026-09/09-14-fix-mihomo-subscription-update/prd.md b/.trellis/tasks/archive/2026-09/09-14-fix-mihomo-subscription-update/prd.md deleted file mode 100644 index 7be9e51..0000000 --- a/.trellis/tasks/archive/2026-09/09-14-fix-mihomo-subscription-update/prd.md +++ /dev/null @@ -1,48 +0,0 @@ -# 修复 Mihomo 订阅更新管理服务错误 - -## Goal - -让管理员点击 Mihomo 订阅“更新”后得到正确、可操作且不泄露敏感信息的结果;若当前订阅服务可通过受支持的客户端请求方式读取,则完成下载、转换、校验和原子发布,若上游拒绝请求,则明确显示订阅源拒绝而不是笼统的“管理服务”故障。 - -## Background - -- 用户提供的页面截图显示,订阅更新 mutation 失败,页面只显示“管理服务暂时无法完成该操作”,订阅行标记为“更新失败”。 -- 生产容器均健康,Mihomo 当前仍在运行,已安装 core 为 `v1.19.29`;故障不等同于进程或 netd 整体不可用。 -- 经脱敏分类的应用日志显示,最近两次失败均为订阅上游返回 HTTP 403。未读取或记录订阅正文、节点、完整 URL 或访问令牌。 -- 当前下载请求在 `internal/application/mihomo/subscriptions.go:299` 固定发送 `User-Agent: Simplus`。经用户明确授权后,对同一个已配置公网 HTTPS 目标进行脱敏只读探测:`Simplus` 返回 403,而 `mihomo`、`clash.meta` 和 `clash` 均返回 200。探测只输出匿名请求标签和状态码,未保存响应正文。 -- 当前后端把上游刷新失败映射为稳定代码 `MIHOMO_SUBSCRIPTION_REFRESH_FAILED`,但 Web 未为该代码提供专用文案,因此回落到通用 HTTP 错误提示。 -- `internal/api/httpapi/server.go:1488` 通过检查错误字符串来识别刷新失败;网络错误还可能把含访问令牌的完整请求 URL 包进 `Error()` 后写入普通日志。 -- 应用服务的完整刷新成功/失败路径以及 HTTP 错误映射目前缺少针对性回归测试。 - -## Requirements - -- R1:保留订阅下载的公网 HTTPS、DNS 重绑定、重定向次数、响应大小和超时防护,不因兼容性修复放宽 SSRF 或资源边界。 -- R2:下载、解析和工件构建阶段的预期失败必须返回类型化、可由 HTTP 层可靠识别且字符串表示不含底层敏感原因的刷新错误;数据库失败继续作为独立持久化错误处理。 -- R3:订阅下载请求使用现场证据已证明兼容的最小稳定标识 `User-Agent: mihomo`,并由合成 HTTP fixture 覆盖;不得针对单个私有域名写例外。 -- R4:刷新失败不得破坏最后一次通过校验的不可变工件或当前运行配置;失败状态和有界错误码仍需持久化并可在列表中观察。 -- R5:现有公开 HTTP 502 与 `MIHOMO_SUBSCRIPTION_REFRESH_FAILED` 契约保持不变;Web 应把该错误显示为可操作、非误导的中文提示,而不是宣称管理服务不可用。 -- R6:实现、检查和收尾均由当前主会话完成,不使用子代理。 - -## Acceptance Criteria - -- [x] AC1:合成测试证明刷新请求发送精确的 `User-Agent: mihomo`;上游返回有效订阅时节点被替换、刷新状态变为 `success`,并调用工件构建边界。 -- [x] AC2:合成测试证明上游 403/非 200、网络失败或超时返回类型化刷新错误,`Error()` 不含 URL、令牌、响应正文或底层私密标记,并持久化 `SUBSCRIPTION_FETCH_FAILED`。 -- [x] AC3:解析及配置生成/校验失败同样返回类型化刷新错误并保留对应的持久状态码;HTTP 层通过类型而非字符串匹配映射到既有 502 契约,数据库失败仍映射为 500 持久化错误。 -- [x] AC4:Web 对 `MIHOMO_SUBSCRIPTION_REFRESH_FAILED` 显示订阅源访问/有效性检查提示,mutation 失败后清理行级 busy 状态并保留现有数据。 -- [x] AC5:现有 SSRF、原子工件、运行态和隐私边界测试继续通过;不执行真实 SMS、通话、RF、modem 写入或 HIL 操作。 - -## Key Decisions - -- 使用 `mihomo` 而不是伪造某个带版本的第三方客户端标识;它是满足现场 200 响应的最小值,也不会把私有部署信息放进请求。 -- 保留现有 OpenAPI 状态和错误码,不引入生成文件变更;精细阶段原因继续通过订阅记录的有界 `lastErrorCode` 表达。 -- 新的刷新错误只携带稳定阶段代码,不包装或暴露原始 HTTP/URL 错误;HTTP 日志记录该稳定代码。 -- 所有自动化验证使用合成 URL、HTTP response 和节点;不把真实订阅、日志或数据库复制进任务或测试。 -- 本任务按用户要求采用当前会话内联实现和检查,不生成或派发子代理上下文。 - -## Out of Scope - -- 修改或续费第三方订阅账户、令牌、套餐、IP 白名单或 WAF 规则。 -- 针对某个私有订阅域名加入硬编码绕过。 -- 改变 Mihomo core 版本、真实运行配置、线路出口或 Host VoWiFi 生命周期。 -- 在任务材料、测试、提交或最终说明中保存私有 URL、节点、原始日志或响应正文。 -- 将本次只读探测扩展为真实生产刷新或修改第三方订阅账户、令牌、套餐、IP 白名单和 WAF 规则。 diff --git a/.trellis/tasks/archive/2026-09/09-14-fix-mihomo-subscription-update/research/break-loop.md b/.trellis/tasks/archive/2026-09/09-14-fix-mihomo-subscription-update/research/break-loop.md deleted file mode 100644 index e537f39..0000000 --- a/.trellis/tasks/archive/2026-09/09-14-fix-mihomo-subscription-update/research/break-loop.md +++ /dev/null @@ -1,72 +0,0 @@ -## Bug Analysis: Mihomo subscription refresh rejected and misreported - -### 1. Root Cause Category - -- **Category**: E - Implicit Assumption, compounded by B - Cross-Layer - Contract and D - Test Coverage Gap. -- **Specific Cause**: The subscription downloader assumed the product-specific - `Simplus` User-Agent was accepted by subscription providers. The affected - provider rejected that identifier but accepted the minimal Mihomo client - identifier. The application then returned raw, prose-based errors, HTTP - classified them with string matching, and Web had no message for the stable - refresh code, so one provider compatibility failure appeared as a generic - management-service failure. -- **Evidence and confidence**: Initial hypotheses were service availability - (45%), subscription/account rejection (35%), and request compatibility (20%). - Healthy service state weakened the first; repeated bounded 403 evidence kept - the latter two plausible; a user-authorized, status-only comparison in which - only User-Agent changed made request compatibility the discriminating cause. - Confidence is above 95% because the old identifier consistently failed while - the new exact identifier consistently succeeded, without reading or retaining - response content. - -### 2. Why Fixes Failed (if applicable) - -1. **Generic browser fallback**: It described the HTTP symptom as a management - service problem and provided no clue that the subscription source rejected - or could not supply usable content. -2. **String-based HTTP classification**: It recognized only selected current - error prose, coupled layers to formatting, and encouraged wrapping raw HTTP - errors that can contain a credential-bearing URL. -3. **Missing refresh fixtures**: Parser tests existed, but no synthetic service - test asserted the outbound User-Agent, stage-specific failure state, - artifact call, or complete error-string privacy. - -No earlier source fix was deployed during this task; the investigation moved -from health evidence to a discriminating request comparison before editing. - -### 3. Prevention Mechanisms - -| Priority | Mechanism | Specific Action | Status | -| --- | --- | --- | --- | -| P0 | Architecture | Return `SubscriptionRefreshError` with only a stable stage code; map it with `errors.As` and log only that code. | DONE | -| P0 | Test coverage | Assert exact `User-Agent: mihomo`, success publication boundaries, each failure stage, persistence failure, and secret-free error/log strings with synthetic fixtures. | DONE | -| P0 | Browser contract | Map the existing public refresh code to source-validity guidance while retaining mutation settlement behavior. | DONE | -| P1 | Documentation | Record the exact fetch, SSRF/resource, persistence and error matrix in the backend application-boundary spec. | DONE | -| P1 | Review checklist | Add a credential-bearing external-fetch checklist to the cross-layer guide. | DONE | - -### 4. Systematic Expansion - -- **Similar Issues**: A focused scan found other external HTTP clients. The - credential-bearing notification paths already reduce transport/provider - failures to bounded sentinels; official Mihomo release downloads are a - separate public-source contract. No unrelated source change is justified by - this task. -- **Design Improvement**: Keep provider compatibility at one application-owned - request constant and keep raw external errors inside the fetch boundary. - Stage details cross layers only as stable codes. -- **Process Improvement**: For credential-bearing fetch failures, compare - service health, bounded upstream status and one discriminating request - attribute before changing behavior. Add an end-to-end error-flow trace and - privacy assertions to the task plan. - -### 5. Knowledge Capture - -- [x] Added the seven-section executable contract to - `.trellis/spec/core/backend/application-boundaries.md`. -- [x] Added the reusable external-fetch checklist to - `.trellis/spec/guides/cross-layer-thinking-guide.md`. -- [x] Confirmed this repository has no `src/templates/markdown/spec/` mirror, - so there is no generated template copy to synchronize. -- [x] Kept the analysis in this task and covered the root fix here; no separate - issue or feature ticket is needed. diff --git a/.trellis/tasks/archive/2026-09/09-14-fix-mihomo-subscription-update/task.json b/.trellis/tasks/archive/2026-09/09-14-fix-mihomo-subscription-update/task.json deleted file mode 100644 index 743f881..0000000 --- a/.trellis/tasks/archive/2026-09/09-14-fix-mihomo-subscription-update/task.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "id": "fix-mihomo-subscription-update", - "name": "fix-mihomo-subscription-update", - "title": "修复 Mihomo 订阅更新管理服务错误", - "description": "", - "status": "completed", - "dev_type": null, - "scope": null, - "package": "core", - "priority": "P2", - "creator": "Leon", - "assignee": "Leon", - "createdAt": "2026-09-14", - "completedAt": "2026-09-14", - "branch": null, - "base_branch": "main", - "worktree_path": null, - "commit": null, - "pr_url": null, - "subtasks": [], - "children": [], - "parent": null, - "relatedFiles": [], - "notes": "", - "meta": {} -} \ No newline at end of file diff --git a/.trellis/workflow.md b/.trellis/workflow.md deleted file mode 100644 index 158142e..0000000 --- a/.trellis/workflow.md +++ /dev/null @@ -1,709 +0,0 @@ -# Development Workflow - ---- - -## Core Principles - -1. **Plan before code** — figure out what to do before you start -2. **Specs injected, not remembered** — guidelines are injected via hook/skill, not recalled from memory -3. **Persist everything** — research, decisions, and lessons all go to files; conversations get compacted, files don't -4. **Incremental development** — one task at a time -5. **Capture learnings** — after each task, review and write new knowledge back to spec - ---- - -## Trellis System - -### Developer Identity - -On first use, initialize your identity: - -```bash -python3 ./.trellis/scripts/init_developer.py <your-name> -``` - -Creates `.trellis/.developer` (gitignored) + `.trellis/workspace/<your-name>/`. - -### Spec System - -`.trellis/spec/` holds coding guidelines organized by package and layer. - -- `.trellis/spec/<package>/<layer>/index.md` — entry point with **Pre-Development Checklist** + **Quality Check**. Actual guidelines live in the `.md` files it points to. -- `.trellis/spec/guides/index.md` — cross-package thinking guides. - -```bash -python3 ./.trellis/scripts/get_context.py --mode packages # list packages / layers -``` - -**When to update spec**: new pattern/convention found · bug-fix prevention to codify · new technical decision. - -### Task System - -Every task has its own directory under `.trellis/tasks/{MM-DD-name}/` holding `task.json`, `prd.md`, optional `design.md`, optional `implement.md`, optional `research/`, and context manifests (`implement.jsonl`, `check.jsonl`) for sub-agent-capable platforms. - -```bash -# Task lifecycle -python3 ./.trellis/scripts/task.py create "<title>" [--slug <name>] [--parent <dir>] -python3 ./.trellis/scripts/task.py start <name> # set active task (session-scoped when available) -python3 ./.trellis/scripts/task.py current --source # show active task and source -python3 ./.trellis/scripts/task.py finish # clear active task (triggers after_finish hooks) -python3 ./.trellis/scripts/task.py archive <name> # move to archive/{year-month}/ -python3 ./.trellis/scripts/task.py list [--mine] [--status <s>] -python3 ./.trellis/scripts/task.py list-archive - -# Code-spec context (injected into implement/check agents via JSONL). -# `implement.jsonl` / `check.jsonl` are seeded on `task create` for sub-agent-capable -# platforms; the AI curates real spec + research entries during planning when needed. -python3 ./.trellis/scripts/task.py add-context <name> <action> <file> <reason> -python3 ./.trellis/scripts/task.py list-context <name> [action] -python3 ./.trellis/scripts/task.py validate <name> - -# Task metadata -python3 ./.trellis/scripts/task.py set-branch <name> <branch> -python3 ./.trellis/scripts/task.py set-base-branch <name> <branch> # PR target -python3 ./.trellis/scripts/task.py set-scope <name> <scope> - -# Hierarchy (parent/child) -python3 ./.trellis/scripts/task.py add-subtask <parent> <child> -python3 ./.trellis/scripts/task.py remove-subtask <parent> <child> - -# PR creation -python3 ./.trellis/scripts/task.py create-pr [name] [--dry-run] -``` - -> Run `python3 ./.trellis/scripts/task.py --help` to see the authoritative, up-to-date list. - -**Current-task mechanism**: `task.py create` creates the task directory and (when session identity is available) auto-sets the per-session active-task pointer so the planning breadcrumb fires immediately. `task.py start` writes the same pointer (idempotent if already set) and flips `task.json.status` from `planning` to `in_progress`. State is stored under `.trellis/.runtime/sessions/`. If no context key is available from hook input, `TRELLIS_CONTEXT_ID`, or a platform-native session environment variable, there is no active task and `task.py start` fails with a session identity hint. `task.py finish` deletes the current session file (status unchanged). `task.py archive <task>` writes `status=completed`, moves the directory to `archive/`, and deletes any runtime session files that still point at the archived task. - -### Workspace System - -Records every AI session for cross-session tracking under `.trellis/workspace/<developer>/`. - -- `journal-N.md` — session log. **Max 2000 lines per file**; a new `journal-(N+1).md` is auto-created when exceeded. -- `index.md` — personal index (total sessions, last active). - -```bash -python3 ./.trellis/scripts/add_session.py --title "Title" --commit "hash" --summary "Summary" -``` - -### Context Script - -```bash -python3 ./.trellis/scripts/get_context.py # full session runtime -python3 ./.trellis/scripts/get_context.py --mode packages # available packages + spec layers -python3 ./.trellis/scripts/get_context.py --mode phase --step <X.Y> # detailed guide for a workflow step -``` - ---- - -<!-- - WORKFLOW-STATE BREADCRUMB CONTRACT (read this before editing the tag blocks below) - - The [workflow-state:STATUS] blocks embedded in the ## Phase Index section - below are the SINGLE source of truth for the per-turn `<workflow-state>` - breadcrumb that every supported AI platform's UserPromptSubmit hook - reads. inject-workflow-state.py (Python platforms) and - inject-workflow-state.js (OpenCode plugin) only parse them — there is no - fallback dict baked into the scripts after v0.5.0-rc.0. - - STATUS charset: [A-Za-z0-9_-]+. When the hook can't find a tag, it - degrades to a generic "Refer to workflow.md for current step." line — - intentionally visible so users notice and fix a broken workflow.md. - - INVARIANT (test/regression.test.ts): - Every workflow-walkthrough step marked `[required · once]` must have a - matching enforcement line in its phase's [workflow-state:*] block. The - breadcrumb is the only per-turn channel; if a mandatory step isn't - mentioned there, the AI silently skips it (Phase 1 planning gate - skip and Phase 3.4 commit skip both manifested via this gap). - - TAG ↔ PHASE scoping: - [workflow-state:no_task] → no active task; before Phase 1 - [workflow-state:planning] → all of Phase 1 (status='planning') - [workflow-state:planning-inline] → Codex inline variant of Phase 1 - [workflow-state:in_progress] → Phase 2 + Phase 3.2-3.4 - (status stays 'in_progress' from - task.py start until task.py archive) - [workflow-state:in_progress-inline] → Codex inline variant of Phase 2/3 - [workflow-state:completed] → currently DEAD: cmd_archive flips - status and moves the dir in the same - call, so the resolver loses the - pointer (block kept for a future - explicit in_progress→completed - transition) - - Editing checklist: - - When you change a [workflow-state:STATUS] block, also check the - matching phase's `[required · once]` walkthrough steps for sync - - Run `trellis update` after editing to push the new bodies to - downstream user projects (block-level managed replacement) - - Full runtime contract: - .trellis/spec/cli/backend/workflow-state-contract.md ---> - -## Phase Index - -``` -Phase 1: Plan → classify, get task-creation consent, then write planning artifacts -Phase 2: Execute → implement only after task status is in_progress -Phase 3: Finish → verify, update spec, commit, and wrap up -``` - -### Request Triage - -- Simple conversation or small task: ask only whether this turn should create a Trellis task. If the user says no, skip Trellis for this session. -- Complex task: ask whether you may create a Trellis task and enter planning. If the user says no, do not do broad inline implementation; explain, clarify scope, or suggest a smaller split. -- User approval to create a task is not approval to start implementation. Planning still happens first. - -### Planning Artifacts - -- `prd.md` — requirements, constraints, and acceptance criteria. Do not put technical design or execution checklists here. -- `design.md` — technical design for complex tasks: boundaries, contracts, data flow, tradeoffs, compatibility, rollout / rollback shape. -- `implement.md` — execution plan for complex tasks: ordered checklist, validation commands, review gates, and rollback points. -- `implement.jsonl` / `check.jsonl` — spec and research manifests for sub-agent context. They do not replace `implement.md`. -- Lightweight tasks may be PRD-only. Complex tasks must have `prd.md`, `design.md`, and `implement.md` before `task.py start`. - -### Parent / Child Task Trees - -Use a parent task when one user request contains several independently verifiable deliverables. The parent task owns the source requirement set, the task map, cross-child acceptance criteria, and final integration review; it normally should not be the implementation target unless it also has direct work. - -Use child tasks for deliverables that can be planned, implemented, checked, and archived independently. Parent/child structure is not a dependency system: if one child must wait for another, write that ordering in the child `prd.md` / `implement.md` and keep each child's acceptance criteria testable. - -Create new children with `task.py create "<title>" --slug <name> --parent <parent-dir>`. Link existing tasks with `task.py add-subtask <parent> <child>`, and unlink mistakes with `task.py remove-subtask <parent> <child>`. - -<!-- Per-turn breadcrumb: shown when there is no active task (before Phase 1) --> - -[workflow-state:no_task] -No active task. First classify the current turn and ask for task-creation consent before creating any Trellis task. -Simple conversation / small task: ask only whether this turn should create a Trellis task. If the user says no, skip Trellis for this session. -Complex task: ask the user if you can create a Trellis task and enter the planning phase. If the user says no, explain, clarify scope, or suggest a smaller split. -[/workflow-state:no_task] - -### Phase 1: Plan -- 1.0 Create task `[required · once]` (only after task-creation consent) -- 1.1 Requirement exploration `[required · repeatable]` (`prd.md`; complex tasks also need `design.md` + `implement.md`) -- 1.2 Research `[optional · repeatable]` -- 1.3 Configure context `[required · once]` — Claude Code, Cursor, OpenCode, Codex, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Grok, Kimi Code (sub-agent-dispatch platforms only; inline platforms skip) -- 1.4 Activate task `[required · once]` (review gate, then `task.py start`; status → in_progress) -- 1.5 Completion criteria - -<!-- Per-turn breadcrumb: shown throughout Phase 1 (status='planning') --> - -[workflow-state:planning] -Load `trellis-brainstorm`; stay in planning. -Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`. -Multi-deliverable scope: consider a parent task plus independently verifiable child tasks; dependencies must be written in child artifacts, not implied by tree position. -Sub-agent mode: curate `implement.jsonl` and `check.jsonl` as spec/research manifests before start. -[/workflow-state:planning] - -<!-- Per-turn breadcrumb: shown throughout Phase 1 when codex.dispatch_mode=inline. - Codex-only opt-in alternate to [workflow-state:planning]. The main agent - edits code directly in Phase 2, so jsonl curation is skipped — - the inline workflow loads `trellis-before-dev` instead of injecting JSONL - into a sub-agent. --> - -[workflow-state:planning-inline] -Load `trellis-brainstorm`; stay in planning. -Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`. -Multi-deliverable scope: consider a parent task plus independently verifiable child tasks; dependencies must be written in child artifacts, not implied by tree position. -Inline mode: skip jsonl curation; Phase 2 reads artifacts/specs via `trellis-before-dev`. -[/workflow-state:planning-inline] - -### Phase 2: Execute -- 2.1 Implement `[required · repeatable]` -- 2.2 Quality check `[required · repeatable]` -- 2.3 Rollback `[on demand]` - -<!-- Per-turn breadcrumb: shown while status='in_progress'. - Scope: all of Phase 2 + Phase 3.2-3.4 (status stays 'in_progress' from - task.py start until task.py archive; only archive flips it). The body - therefore must cover every required step from implementation through - commit, including Phase 3.3 spec update and Phase 3.4 commit. --> - -Sub-agent dispatch protocol applies to all platforms and all sub-agents, including native Codex `SubagentStart` context injection with child-side pull fallback, class-2 Gemini/Qoder/Copilot/Reasonix/Trae/Grok/Kimi Code, hook-backed ZCode/Snow, and `trellis-research`: every dispatch prompt starts with `Active task: <task path from task.py current>` before role-specific instructions. On Grok Build, use `spawn_subagent` with `subagent_type` set to the Trellis agent name (e.g. `trellis-implement`). On Kimi Code, dispatch the built-in `coder` / `explore` sub-agent with the matching `.kimi-code/skills/trellis-<role>/SKILL.md` instructions. - -[workflow-state:in_progress] -Tools: `trellis-implement` / `trellis-research` are sub-agent types only (Task/Agent tool, NOT Skill; there is no skill by these names). `trellis-update-spec` is a skill. `trellis-check` exists as both; prefer the Agent form when verifying after code changes. -Flow: `trellis-implement` -> `trellis-check` -> `trellis-update-spec` -> commit (Phase 3.4) -> `/trellis:finish-work`. -Main-session default: dispatch implement/check sub-agents. Sub-agent self-exemption: if already running as `trellis-implement`, do NOT spawn another `trellis-implement` or `trellis-check`; if already running as `trellis-check`, do NOT spawn another `trellis-check` or `trellis-implement`. Dispatch is main session only. -Dispatch prompt starts with `Active task: <task path from task.py current>`. Read context: jsonl entries -> `prd.md` -> `design.md if present` -> `implement.md if present`. -[/workflow-state:in_progress] - -<!-- Per-turn breadcrumb: shown while status='in_progress' when - codex.dispatch_mode=inline. Codex-only opt-in alternate to - [workflow-state:in_progress]. The main session edits code directly - instead of dispatching sub-agents. --> - -[workflow-state:in_progress-inline] -Flow: `trellis-before-dev` -> edit -> `trellis-check` -> validation -> `trellis-update-spec` -> commit (Phase 3.4) -> `/trellis:finish-work`. -Do not dispatch implement/check sub-agents in inline mode. -Read context: `prd.md` -> `design.md if present` -> `implement.md if present`, plus relevant spec/research loaded by skills. -[/workflow-state:in_progress-inline] - -### Phase 3: Finish -- 3.2 Debug retrospective `[on demand]` -- 3.3 Spec update `[required · once]` -- 3.4 Commit changes `[required · once]` -- 3.5 Wrap-up reminder - -> Note: step 3.1 was folded into 2.2 (last-iteration full-scope check) and 3.4 (commit preamble). Numbering kept stable to avoid breaking external references. - -<!-- Per-turn breadcrumb: shown while status='completed'. - Currently DEAD in normal flow: cmd_archive writes status='completed' in - the same call that moves the task dir to archive/, so the active-task - resolver loses the pointer and the hook never fires on archived tasks. - Block preserved for a future status-transition redesign (e.g. an - explicit in_progress→completed command). Edit through the same spec - channel as the live blocks. --> - -[workflow-state:completed] -Code committed. Run `/trellis:finish-work`; if dirty, return to Phase 3.4 first. -[/workflow-state:completed] - -### Rules - -1. Identify which Phase you're in, then continue from the next step there -2. Run steps in order inside each Phase; `[required]` steps can't be skipped -3. Phases can roll back (e.g., Execute reveals a prd defect → return to Plan to fix, then re-enter Execute) -4. Steps tagged `[once]` are skipped if the output already exists; don't re-run -5. Artifact presence informs the next step; missing `design.md` / `implement.md` is valid for lightweight tasks and incomplete planning for complex tasks. - -### Active Task Routing - -When a user request matches one of these intents inside an active task, route first, then load the detailed phase step if needed. - -[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code] - -- Planning or unclear requirements -> `trellis-brainstorm`. -- `in_progress` implementation/check -> dispatch `trellis-implement` / `trellis-check`. -- Repeated debugging -> `trellis-break-loop`; spec updates -> `trellis-update-spec`. - -[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code] - -[codex-inline, Kilo, Antigravity, Devin, 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, DeepSeek Harness] - -### Guardrails - -- Task creation approval is not implementation approval; implementation waits for `task.py start` after artifact review. -- PRD-only is valid for lightweight tasks; complex tasks need `design.md` + `implement.md`. -- Planning must be persisted to task artifacts; checks must run before reporting completion. - -### Loading Step Detail - -At each step, run this to fetch detailed guidance: - -```bash -python3 ./.trellis/scripts/get_context.py --mode phase --step <step> -# e.g. python3 ./.trellis/scripts/get_context.py --mode phase --step 1.1 -``` - ---- - -## Phase 1: Plan - -Goal: classify the request, get task-creation consent when a task is needed, and produce the planning artifacts required before implementation. - -#### 1.0 Create task `[required · once]` - -Create the task directory only after task-creation consent. The command sets status to `planning`, writes `task.json`, creates a default `prd.md`, and auto-targets the new task when session identity is available: - -```bash -python3 ./.trellis/scripts/task.py create "<task title>" --slug <name> -``` - -`--slug` is the human-readable name only. Do **not** include the `MM-DD-` date prefix; `task.py create` adds that prefix automatically. - -For task trees, create the parent task first and then create each child with `--parent <parent-dir>`. Do not start the parent just because children exist; start the child that owns the next independently verifiable deliverable. - -After this command succeeds, the per-turn breadcrumb auto-switches to `[workflow-state:planning]`, telling the AI to stay in planning. - -Run only `create` here — do not also run `start`. `start` flips status to `in_progress`, which switches the breadcrumb to the implementation phase before planning artifacts are reviewed. Save `start` for step 1.4. - -Skip when `python3 ./.trellis/scripts/task.py current --source` already points to a task. - -#### 1.1 Requirement exploration `[required · repeatable]` - -Load the `trellis-brainstorm` skill and explore requirements interactively with the user per the skill's guidance. - -The brainstorm skill will guide you to: -- Ask one question at a time -- Prefer researching over asking the user -- Prefer offering options over open-ended questions -- Update `prd.md` immediately after each user answer -- Split large scopes into a parent task plus child tasks when the deliverables can be verified independently -- Keep `prd.md` focused on requirements and acceptance criteria -- For complex tasks, produce `design.md` and `implement.md` before implementation starts - -When considering a parent/child split: -- Use a parent task when one request contains several independently verifiable deliverables. -- Parent tasks own source requirements, child-task mapping, cross-child acceptance criteria, and final integration review. -- Child tasks own actual deliverables that can be planned, implemented, checked, and archived independently. -- Parent/child structure is not a dependency system. If child B depends on child A, write that ordering in child B's `prd.md` / `implement.md`. -- Start the child task that owns the next deliverable. Do not start the parent unless the parent itself has direct implementation work. - -Return to this step whenever requirements change and revise the relevant artifact. - -#### 1.2 Research `[optional · repeatable]` - -Research can happen at any time during requirement exploration. It isn't limited to local code — you can use any available tool (MCP servers, skills, web search, etc.) to look up external information, including third-party library docs, industry practices, API references, etc. - -[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code] - -Spawn the research sub-agent: - -- **Agent type**: `trellis-research` -- **Task description**: Research <specific question> -- **Key requirement**: Research output MUST be persisted to `{TASK_DIR}/research/` - -[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code] - -[codex-inline, Kilo, Antigravity, Devin, 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, DeepSeek Harness] - -**Research artifact conventions**: -- One file per research topic (e.g. `research/auth-library-comparison.md`) -- Record third-party library usage examples, API references, version constraints in files -- Note relevant spec file paths you discovered for later reference - -Brainstorm and research can interleave freely — pause to research a technical question, then return to talk with the user. - -**Key principle**: Research output must be written to files, not left only in the chat. Conversations get compacted; files don't. - -#### 1.3 Configure context `[required · once]` - -[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code] - -Curate `implement.jsonl` and `check.jsonl` so the Phase 2 sub-agents get the right spec/research context. These files were seeded on `task create` with a single self-describing `_example` line; your job here is to fill in real entries. - -**Location**: `{TASK_DIR}/implement.jsonl` and `{TASK_DIR}/check.jsonl` (already exist). - -**Format**: one JSON object per line — `{"file": "<path>", "reason": "<why>"}`. Paths are repo-root relative. - -**What to put in**: -- **Spec files** — `.trellis/spec/<package>/<layer>/index.md` and any specific guideline files (`error-handling.md`, `conventions.md`, etc.) relevant to this task -- **Research files** — `{TASK_DIR}/research/*.md` that the sub-agent will need to consult - -**What NOT to put in**: -- Code files (`src/**`, `packages/**/*.ts`, etc.) — those are read by the sub-agent during implementation, not pre-registered here -- Files you're about to modify — same reason - -**Split between the two files**: -- `implement.jsonl` → specs + research the implement sub-agent needs to write code correctly -- `check.jsonl` → specs for the check sub-agent (quality guidelines, check conventions, same research if needed) - -These manifests do not replace `implement.md`. `implement.md` is the human-readable execution plan for a complex task; jsonl files only list context files to inject or load. - -**How to discover relevant specs**: - -```bash -python3 ./.trellis/scripts/get_context.py --mode packages -``` - -Lists every package + its spec layers with paths. Pick the entries that match this task's domain. - -**How to append entries**: - -Either edit the jsonl file directly in your editor, or use: - -```bash -python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" implement "<path>" "<reason>" -python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" check "<path>" "<reason>" -``` - -Delete the seed `_example` line once real entries exist (optional — it's skipped automatically by consumers). - -Ready gate: both `implement.jsonl` and `check.jsonl` must contain at least one real `{"file": "...", "reason": "..."}` entry before `task.py start`. The seed `_example` row alone is not ready. - -Skip this step only when both files already have real curated entries. - -[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code] - -[codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness] - -Skip this step. Context is loaded directly by the `trellis-before-dev` skill in Phase 2. - -[/codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness] - -#### 1.4 Activate task `[required · once]` - -After artifact review, flip the task status to `in_progress`: - -```bash -python3 ./.trellis/scripts/task.py start <task-dir> -``` - -For lightweight tasks, `prd.md` can be enough. For complex tasks, `prd.md`, `design.md`, and `implement.md` must exist and be reviewed before start. On sub-agent-dispatch platforms, `implement.jsonl` and `check.jsonl` must both have real curated entries before start. Runtime consumers tolerate missing or seed-only manifests for compatibility, but that tolerance is not a planning-ready state. - -After this command succeeds, the breadcrumb auto-switches to `[workflow-state:in_progress]`, and the rest of Phase 2 / 3 follows. - -If `task.py start` errors with a session-identity message (no context key from hook input, `TRELLIS_CONTEXT_ID`, or platform-native session env), follow the hint in the error to set up session identity, then retry. - -#### 1.5 Completion criteria - -| Condition | Required | -|------|:---:| -| `prd.md` exists | ✅ | -| User confirms task should enter implementation | ✅ | -| `task.py start` has been run (status = in_progress) | ✅ | -| `research/` has artifacts (complex tasks) | recommended | -| `design.md` exists (complex tasks) | ✅ | -| `implement.md` exists (complex tasks) | ✅ | - -[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code] - -| `implement.jsonl` and `check.jsonl` each contain at least one real curated entry (seed row does not count) | ✅ | - -[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code] - ---- - -## Phase 2: Execute - -Goal: turn reviewed planning artifacts into code that passes quality checks. - -#### 2.1 Implement `[required · repeatable]` - -[Claude Code, Cursor, OpenCode, codex-sub-agent, CodeBuddy, Droid, Pi, ZCode, Snow, Oh My Pi] - -Spawn the implement sub-agent: - -- **Agent type**: `trellis-implement` -- **Task description**: Implement the reviewed task artifacts, consulting materials under `{TASK_DIR}/research/`; finish by running project lint and type-check -- **Dispatch prompt guard**: The prompt MUST start with `Active task: <task path>`, then tell the spawned agent it is already the `trellis-implement` sub-agent and must implement directly, not spawn another `trellis-implement` / `trellis-check`. - -The platform hook/plugin auto-handles: -- Reads `implement.jsonl` and injects referenced spec/research files into the agent prompt -- Injects `prd.md`, `design.md` if present, and `implement.md` if present -- For Codex, `SubagentStart` supplies native context injection; the agent profile keeps child-side loading as the fallback - -[/Claude Code, Cursor, OpenCode, codex-sub-agent, CodeBuddy, Droid, Pi, ZCode, Snow, Oh My Pi] - -[Gemini, Qoder, Copilot, Reasonix, Trae, Grok, Kimi Code] - -Spawn the implement sub-agent: - -- **Agent type**: `trellis-implement` -- **Task description**: Implement the reviewed task artifacts, consulting materials under `{TASK_DIR}/research/`; finish by running project lint and type-check -- **Dispatch prompt guard**: The prompt MUST start with `Active task: <task path>`, then explicitly say the spawned agent is already `trellis-implement` and must implement directly without spawning another `trellis-implement` / `trellis-check`. - -The pull-based sub-agent definition auto-handles the context load requirement: -- Resolves the active task with `task.py current --source`, then reads `prd.md`, `design.md` if present, and `implement.md` if present -- Reads `implement.jsonl` and requires the agent to load each referenced spec/research file before coding - -[/Gemini, Qoder, Copilot, Reasonix, Trae, Grok, Kimi Code] - -[Kiro] - -Spawn the implement sub-agent: - -- **Agent type**: `trellis-implement` -- **Task description**: Implement the reviewed task artifacts, consulting materials under `{TASK_DIR}/research/`; finish by running project lint and type-check -- **Dispatch prompt guard**: Tell the spawned agent it is already the `trellis-implement` sub-agent and must implement directly, not spawn another `trellis-implement` / `trellis-check`. - -The platform prelude auto-handles the context load requirement: -- Reads `implement.jsonl` and injects referenced spec/research files into the agent prompt -- Injects `prd.md`, `design.md` if present, and `implement.md` if present - -[/Kiro] - -[codex-inline, Kilo, Antigravity, Devin, 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 - -[/codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness] - -#### 2.2 Quality check `[required · repeatable]` - -[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code] - -Spawn the check sub-agent: - -- **Agent type**: `trellis-check` -- **Task description**: Review all code changes against specs and task artifacts; fix any findings directly; ensure lint and type-check pass -- **Dispatch prompt guard**: The prompt MUST start with `Active task: <task path>`, then tell the spawned agent it is already the `trellis-check` sub-agent and must review/fix directly, not spawn another `trellis-check` / `trellis-implement`. - -The check agent's job: -- Review code changes against specs -- Review code changes against `prd.md`, `design.md` if present, and `implement.md` if present -- Auto-fix issues it finds -- Run lint and typecheck to verify - -[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code] - -[codex-inline, Kilo, Antigravity, Devin, 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. - -[/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. - -#### 2.3 Rollback `[on demand]` - -- `check` reveals a prd defect → return to Phase 1, fix `prd.md`, then redo 2.1 -- Implementation went wrong → revert code, redo 2.1 -- Need more research → research (same as Phase 1.2), write findings into `research/` - ---- - -## Phase 3: Finish - -Goal: ensure code quality, capture lessons, record the work. - -#### 3.2 Debug retrospective `[on demand]` - -If this task involved repeated debugging (the same issue was fixed multiple times), load the `trellis-break-loop` skill to: -- Classify the root cause -- Explain why earlier fixes failed -- Propose prevention - -The goal is to capture debugging lessons so the same class of issue doesn't recur. - -#### 3.3 Spec update `[required · once]` - -Load the `trellis-update-spec` skill and review whether this task produced new knowledge worth recording: -- Newly discovered patterns or conventions -- Pitfalls you hit -- New technical decisions - -Update the docs under `.trellis/spec/` accordingly. Even if the conclusion is "nothing to update", walk through the judgment. - -#### 3.4 Commit changes `[required · once]` - -**Spec-sync preamble**: before drafting commits, ask: did this task fix a bug or surface non-obvious knowledge that should land in `.trellis/spec/` so future-you (or future-AI) doesn't repeat the mistake? If yes, return to Phase 3.3 first — spec writes belong in the same task's commit batch, not as a forgotten follow-up. - -The AI drives a batched commit of this task's code changes so `/finish-work` can run cleanly afterwards. Goal: produce work commits FIRST, then bookkeeping (archive + journal) commits land after — never interleaved. - -**Step-by-step**: - -1. **Inspect dirty state**: - ```bash - git status --porcelain - ``` - Snapshot every dirty path. If the working tree is clean, skip to 3.5. - -2. **Learn commit style** from recent history (so drafted messages blend in): - ```bash - git log --oneline -5 - ``` - Note the prefix convention (`feat:` / `fix:` / `chore:` / `docs:` ...), language (中文/English), and length style. - -3. **Classify dirty files into two groups**: - - **AI-edited this session** — files you wrote/edited via Edit/Write/Bash tool calls in this session. You know what changed and why. - - **Unrecognized** — dirty files you did NOT touch this session (could be the user's manual edits, leftover WIP from a previous session, or unrelated work). Do NOT silently include these. - -4. **Draft a commit plan**. Group AI-edited files into logical commits (1 commit per coherent change unit, not 1 commit per file). Each entry: `<commit message>` + file list. List unrecognized files separately at the bottom. - -5. **Present the plan once, ask for one-shot confirmation**. Format: - ``` - Proposed commits (in order): - 1. <message> - - <file> - - <file> - 2. <message> - - <file> - - Unrecognized dirty files (NOT in any commit — confirm include/exclude): - - <file> - - <file> - - Reply 'ok' / '行' to execute. Reply with edits, or '我自己来' / 'manual' to abort. - ``` - -6. **On confirmation**: run `git add <files>` + `git commit -m "<msg>"` for each batch in order. Do not amend. Do not push. - -7. **On rejection** (user replies "不行" / "我自己来" / "manual" / any pushback on the plan): stop. Do not attempt a second plan. The user will commit by hand; you skip ahead to 3.5 once they confirm. - -**Rules**: -- No `git commit --amend` anywhere — three-stage three-commit flow (work commits → archive commit → journal commit). -- Never push to remote in this step. -- If the user wants different message wording but accepts the file grouping, edit the message and re-confirm once — but if they reject the grouping, exit to manual mode. -- The batched plan is one prompt; do not prompt per commit. - -#### 3.5 Wrap-up reminder - -After the above, remind the user they can run `/finish-work` to wrap up (archive the task, record the session). - ---- - -## Customizing Trellis (for forks) - -This section is for developers who want to modify the Trellis workflow itself. All customization is done by editing this file; the scripts are parsers only. - -### Changing what a step means - -Edit the corresponding step's walkthrough body in the Phase 1 / 2 / 3 sections above. Critical invariants: -- No active task must triage first and ask for task-creation consent before creating a Trellis task. -- Planning must distinguish lightweight PRD-only tasks from complex tasks that require `prd.md`, `design.md`, and `implement.md` before start. -- Every required execution path must keep the Phase 3.4 commit reminder reachable before `/trellis:finish-work`. - -All tag blocks live in the `## Phase Index` section above, immediately after each phase summary: - -| Scope | Corresponding tag | -|---|---| -| No active task (before Phase 1) | `[workflow-state:no_task]` (after the Phase Index ASCII art) | -| All of Phase 1 (task created → ready for implementation) | `[workflow-state:planning]` (after Phase 1 summary) | -| Codex inline Phase 1 | `[workflow-state:planning-inline]` | -| Phase 2 + Phase 3.2–3.4 (implementation + check + wrap-up) | `[workflow-state:in_progress]` (after Phase 2 summary) | -| Codex inline Phase 2 + Phase 3.2–3.4 | `[workflow-state:in_progress-inline]` | -| After Phase 3.5 (archived) | `[workflow-state:completed]` (after Phase 3 summary; **currently DEAD**) | - -### Changing the per-turn prompt text - -Directly edit the body of the corresponding `[workflow-state:STATUS]` block. After editing, run `trellis update` (if you're a template maintainer) or restart your AI session (if you're customizing your own project) — no script changes required. - -### Adding a custom status - -Add a new block: - -``` -[workflow-state:my-status] -your per-turn prompt text -[/workflow-state:my-status] -``` - -Constraints: -- STATUS charset: `[A-Za-z0-9_-]+` (underscores and hyphens allowed, e.g. `in-review`, `blocked-by-team`) -- A lifecycle hook must write `task.json.status` to your custom value, otherwise the tag is never read -- Lifecycle hooks live in `task.json.hooks.after_*` and bind to one of `after_create / after_start / after_finish / after_archive` - -### Adding a lifecycle hook - -Add a `hooks` field to your `task.json`: - -```json -{ - "hooks": { - "after_finish": [ - "your-script-or-command-here" - ] - } -} -``` - -Supported events: `after_create / after_start / after_finish / after_archive`. Note that `after_finish` ≠ a status change (it only clears the active-task pointer); use `after_archive` for "task is done" notifications. - -### Full contract - -For the workflow state machine's runtime contract, the locations of all status writers, pseudo-statuses (`no_task` / `stale_<source_type>`), the hook reachability matrix, and other deep details, see: - -- `.trellis/spec/cli/backend/workflow-state-contract.md` — runtime contract + writer table + test invariants -- `.trellis/scripts/inject-workflow-state.py` — actual parser (reads workflow.md only, no embedded text) diff --git a/.trellis/workspace/Leon/index.md b/.trellis/workspace/Leon/index.md deleted file mode 100644 index 5d36a8d..0000000 --- a/.trellis/workspace/Leon/index.md +++ /dev/null @@ -1,66 +0,0 @@ -# Workspace Index - Leon - -> Journal tracking for AI development sessions. - ---- - -## Current Status - -<!-- @@@auto:current-status --> -- **Active File**: `journal-1.md` -- **Total Sessions**: 26 -- **Last Active**: 2026-09-14 -<!-- @@@/auto:current-status --> - ---- - -## Active Documents - -<!-- @@@auto:active-documents --> -| File | Lines | Status | -|------|-------|--------| -| `journal-1.md` | ~622 | Active | -<!-- @@@/auto:active-documents --> - ---- - -## Session History - -<!-- @@@auto:session-history --> -| # | Date | Title | Commits | Branch | -|---|------|-------|---------|--------| -| 26 | 2026-09-14 | Fix Mihomo subscription refresh | `f458670`, `f1fa0a9` | `main` | -| 25 | 2026-08-22 | Improve production onboarding docs | `e68b255` | `main` | -| 24 | 2026-08-21 | Local production container cutover | `b76a687` | `main` | -| 23 | 2026-08-21 | Publish versioned GHCR deployment candidate | `901eabe`, `bc05259`, `04dc171` | `main` | -| 22 | 2026-08-20 | Patch Go and nanoid security baselines | `97ad0f4` | `main` | -| 21 | 2026-08-20 | Restore local Compose deployment | `47f273e` | `sms-to-feishu-content` | -| 20 | 2026-08-20 | Forward SMS content to Feishu | `47f273e` | `sms-to-feishu-content` | -| 19 | 2026-08-18 | Close layer boundary audit remediation | `dce8356` | `main` | -| 18 | 2026-08-18 | Retire dormant resource lease application | `1fe33a7` | `main` | -| 17 | 2026-08-17 | Narrow HTTP application service ports | `aa8b41f` | `main` | -| 16 | 2026-08-15 | Isolate notification Webhook delivery | `04e1770` | `main` | -| 15 | 2026-08-15 | Make Mihomo supervisor composition explicit | `ffbc69b` | `main` | -| 14 | 2026-08-15 | Make Setup dependencies explicit | `d66860f` | `main` | -| 13 | 2026-08-14 | Move background policy into application coordinators | `024cbfb` | `main` | -| 12 | 2026-08-14 | Audit layer boundaries and retire native deployment | `0ca25d4`, `7013707` | `main` | -| 11 | 2026-08-14 | Line phone number observations | `97b06e5` | `main` | -| 10 | 2026-08-13 | 清理 QDC507 HIL 并接入模块序列号 | `5cc4214` | `main` | -| 9 | 2026-08-13 | QDC507 原生蜂窝短信 | `5cc4214` | `main` | -| 8 | 2026-08-12 | Fix recurring Feishu binding expiry rejection | `9579ea0`, `57f9f1e` | `main` | -| 7 | 2026-08-12 | Fix Feishu binding result compatibility | `60e4066`, `9f9ac73` | `main` | -| 6 | 2026-08-12 | Feishu private-message channel binding | `6fbef74`, `fa20c82` | `main` | -| 5 | 2026-08-11 | 优化短信页布局并更新本地容器 | `c4c4dd5` | `main` | -| 4 | 2026-08-11 | 修复短信会话时间乱序 | `f20cd83`, `d36c2d6`, `b815a7b`, `06cab9d` | `main` | -| 3 | 2026-08-10 | 重做短信会话页面 | `f8391ad`, `2dc4241`, `107b1de` | `main` | -| 2 | 2026-08-10 | 完成前端与通信重构 | `cc02810`, `588f6c4`, `83feb39`, `6a9ceef`, `8527529` | `main` | -| 1 | 2026-08-07 | Bootstrap and repair Trellis project context | `28b413e`, `3687001` | `main` | -<!-- @@@/auto:session-history --> - ---- - -## Notes - -- Sessions are appended to journal files -- New journal file created when current exceeds 2000 lines -- Use `add_session.py` to record sessions \ No newline at end of file diff --git a/.trellis/workspace/Leon/journal-1.md b/.trellis/workspace/Leon/journal-1.md deleted file mode 100644 index d9519a0..0000000 --- a/.trellis/workspace/Leon/journal-1.md +++ /dev/null @@ -1,622 +0,0 @@ -# Journal - Leon (Part 1) - -> AI development session journal -> Started: 2026-08-07 - ---- - - - -## Session 1: Bootstrap and repair Trellis project context - -**Date**: 2026-08-07 -**Task**: Bootstrap and repair Trellis project context -**Package**: core -**Branch**: `main` - -### Summary - -Initialized Trellis and Codex integration, corrected core/web package resolution, restored concise project safety boundaries, and added source-backed backend, infrastructure, documentation, and frontend specs with full implement/check verification. - -### Git Commits - -| Hash | Message | -|------|---------| -| `28b413e` | (see git log) | -| `3687001` | (see git log) | - -### Status - -[OK] **Completed** - - -## Session 2: 完成前端与通信重构 - -**Date**: 2026-08-10 -**Task**: 完成前端与通信重构 -**Package**: core -**Branch**: `main` - -### Summary - -以 Vite、React Router、直接使用 Ant Design 和 TanStack Query 替换 Umi/Pro 前端运行时,新增 HTTP 权威快照加鉴权 SSE 失效同步,修复首次配置与页头布局,并按产品决定隐藏暂缓的 eUICC 界面;完成测试、构建和本地容器验证。 - -### Git Commits - -| Hash | Message | -|------|---------| -| `cc02810` | (see git log) | -| `588f6c4` | (see git log) | -| `83feb39` | (see git log) | -| `6a9ceef` | (see git log) | -| `8527529` | (see git log) | - -### Status - -[OK] **Completed** - - -## Session 3: 重做短信会话页面 - -**Date**: 2026-08-10 -**Task**: 重做短信会话页面 -**Package**: core -**Branch**: `main` - -### Summary - -完成按收件人聚合的短信会话、持久化未读状态、响应式主从聊天界面、线路故障闭锁与联系人管理;全量质量门禁和桌面/移动端 Chromium E2E 通过,并将本地 Compose 服务更新到 107b1de。 - -### Git Commits - -| Hash | Message | -|------|---------| -| `f8391ad` | (see git log) | -| `2dc4241` | (see git log) | -| `107b1de` | (see git log) | - -### Status - -[OK] **Completed** - - -## Session 4: 修复短信会话时间乱序 - -**Date**: 2026-08-11 -**Task**: 修复短信会话时间乱序 -**Package**: core -**Branch**: `main` - -### Summary - -引入 messages v8 单调 record sequence 与 SMS v2 cursor,统一后端会话/历史顺序并让 Web 只消费服务端顺序;补齐迁移、分页、并发、桌面/移动 Chromium 回归和独立检查,将本地 Compose 更新到 b815a7b,并只读确认既有缺陷记录已按发送到收到排序。 - -### Git Commits - -| Hash | Message | -|------|---------| -| `f20cd83` | (see git log) | -| `d36c2d6` | (see git log) | -| `b815a7b` | (see git log) | -| `06cab9d` | (see git log) | - -### Status - -[OK] **Completed** - - -## Session 5: 优化短信页布局并更新本地容器 - -**Date**: 2026-08-11 -**Task**: 优化短信页布局并更新本地容器 -**Package**: core -**Branch**: `main` - -### Summary - -让短信工作区填满主内容区并补充桌面布局边界回归;Web 类型检查、单元测试、生产构建和 Chromium E2E 通过,并将本地 Compose 服务更新到 c4c4dd5。 - -### Git Commits - -| Hash | Message | -|------|---------| -| `c4c4dd5` | (see git log) | - -### Status - -[OK] **Completed** - - -## Session 6: Feishu private-message channel binding - -**Date**: 2026-08-12 -**Task**: Feishu private-message channel binding -**Package**: core -**Branch**: `main` - -### Summary - -Implemented and independently verified zero-form Feishu private-message binding with create-only minimal permissions, test-before-persist encrypted app credentials, local-only unbind, Web/API/storage/UI coverage, ADR/docs, and synthetic-only validation. - -### Git Commits - -| Hash | Message | -|------|---------| -| `6fbef74` | (see git log) | -| `fa20c82` | (see git log) | - -### Status - -[OK] **Completed** - - -## Session 7: Fix Feishu binding result compatibility - -**Date**: 2026-08-12 -**Task**: Fix Feishu binding result compatibility -**Package**: core -**Branch**: `main` - -### Summary - -Diagnosed the live Begin shape without retaining temporary values, fixed Feishu opaque device-code, expires_in, exact verification-authority, and registration-only non-2xx JSON compatibility, added synthetic/race regressions, independently reviewed and codified prevention rules, and updated healthy local dev containers without real authorization or delivery. - -### Git Commits - -| Hash | Message | -|------|---------| -| `60e4066` | (see git log) | -| `9f9ac73` | (see git log) | - -### Status - -[OK] **Completed** - - -## Session 8: Fix recurring Feishu binding expiry rejection - -**Date**: 2026-08-12 -**Task**: Fix recurring Feishu binding expiry rejection -**Package**: core -**Branch**: `main` - -### Summary - -Closed the recurring Feishu Begin rejection by accepting the observed one-hour expiry through an overflow-safe 24-hour local bound, added cross-architecture and full regression coverage, strengthened provider-probe guidance, deployed healthy local dev containers, and recorded user-confirmed successful private-chat binding without retaining sensitive evidence. - -### Git Commits - -| Hash | Message | -|------|---------| -| `9579ea0` | (see git log) | -| `57f9f1e` | (see git log) | - -### Status - -[OK] **Completed** - - -## Session 9: QDC507 原生蜂窝短信 - -**Date**: 2026-08-13 -**Task**: QDC507 原生蜂窝短信 -**Package**: core -**Branch**: `main` - -### Summary - -完成 QDC507 中国联通原生蜂窝短信纵切、统一 typed capability/Line transport、durable recovery 与生产容器装配;受控入站和出站 HIL 通过,完整质量门禁通过,并更新本地 dev Compose。后续清理一次性 HIL 源码时重建了该未推送提交。 - -### Git Commits - -| Hash | Message | -|------|---------| -| `5cc4214` | (see git log) | - -### Status - -[OK] **Completed** - - -## Session 10: 清理 QDC507 HIL 并接入模块序列号 - -**Date**: 2026-08-13 -**Task**: 清理 QDC507 HIL 并接入模块序列号 -**Package**: core -**Branch**: `main` - -### Summary - -移除全部一次性 QDC507 HIL 源码及孤立支撑,保留生产原生蜂窝短信;按实测固定 CGSN 协议接入模块序列号并贯通 Agent、Managed Modem/API/Web;重建三条未推送本地历史并 prune 旧对象;重建 dev 三镜像、原地更新 Compose,服务与 HTTP health 全部通过。 - -### Git Commits - -| Hash | Message | -|------|---------| -| `5cc4214` | (see git log) | - -### Status - -[OK] **Completed** - - -## Session 11: Line phone number observations - -**Date**: 2026-08-14 -**Task**: Line phone number observations -**Package**: core -**Branch**: `main` - -### Summary - -Added typed QDC507 subscriber-number observation, propagated it through Agent and inventory, made Line the unified cellular/IMS owner, rendered all distinct values in Web, verified the real local Line UI, and deployed healthy dev images for revision 97b06e5. - -### Git Commits - -| Hash | Message | -|------|---------| -| `97b06e5` | (see git log) | - -### Status - -[OK] **Completed** - - -## Session 12: Audit layer boundaries and retire native deployment - -**Date**: 2026-08-14 -**Task**: Audit layer boundaries and retire native deployment -**Package**: core -**Branch**: `main` - -### Summary - -Completed repository-wide layer-boundary audit, removed the unsupported native Debian production path, strengthened Compose host conflict checks, synchronized docs/specs, and preserved legacy private state for explicit follow-up. - -### Git Commits - -| Hash | Message | -|------|---------| -| `0ca25d4` | (see git log) | -| `7013707` | (see git log) | - -### Status - -[OK] **Completed** - - -## Session 13: Move background policy into application coordinators - -**Date**: 2026-08-14 -**Task**: Move background policy into application coordinators -**Package**: core -**Branch**: `main` - -### Summary - -Resolved audit V-02 by moving SMS synchronization and Agent change semantics from cmd/simplusd into application-owned coordinators with narrow ports, preserving realtime, notification, retry and cancellation behavior, and adding deterministic race-tested coverage plus executable backend specs. - -### Git Commits - -| Hash | Message | -|------|---------| -| `024cbfb` | (see git log) | - -### Status - -[OK] **Completed** - - -## Session 14: Make Setup dependencies explicit - -**Date**: 2026-08-15 -**Task**: Make Setup dependencies explicit -**Package**: core -**Branch**: `main` - -### Summary - -Resolved audit V-03 by replacing hidden Setup store assertions and concrete security/filesystem defaults with explicit application-owned dependencies and values, moving production adapter assembly into cmd/simplusd, preserving the fixed instance key path and active Setup behavior, and adding deterministic integration/race coverage plus backend specs. - -### Git Commits - -| Hash | Message | -|------|---------| -| `d66860f` | (see git log) | - -### Status - -[OK] **Completed** - - -## Session 15: Make Mihomo supervisor composition explicit - -**Date**: 2026-08-15 -**Task**: Make Mihomo supervisor composition explicit -**Package**: core -**Branch**: `main` - -### Summary - -Resolved audit V-04 by moving local/socket Mihomo supervisor selection into cmd/simplusd, requiring a validated typed application dependency, replacing process-launching application tests with a fake, and recording the executable boundary contract. Independent review and safe race, Go, lint, docs, and ownership checks passed without runtime or hardware actions. - -### Git Commits - -| Hash | Message | -|------|---------| -| `ffbc69b` | (see git log) | - -### Status - -[OK] **Completed** - - -## Session 16: Isolate notification Webhook delivery - -**Date**: 2026-08-15 -**Task**: Isolate notification Webhook delivery -**Package**: core -**Branch**: `main` - -### Summary - -Resolved audit V-05 by introducing an application-owned typed Webhook port, moving fixed WeCom/Feishu bot target validation and HTTP protocol into a concrete adapter assembled by cmd/simplusd, preserving legacy ciphertext/API/outcome behavior, and strengthening credential-safe error tests. Independent implementation/spec review and safe race, broad Go, lint, generated-drift, docs, ownership, and privacy gates passed without any external delivery or runtime action. - -### Git Commits - -| Hash | Message | -|------|---------| -| `04e1770` | (see git log) | - -### Status - -[OK] **Completed** - - -## Session 17: Narrow HTTP application service ports - -**Date**: 2026-08-17 -**Task**: Narrow HTTP application service ports -**Package**: core -**Branch**: `main` - -### Summary - -Replaced concrete Health, Setup, Inventory, and Realtime HTTP dependencies with exact consumer-owned ports; preserved typed-nil compatibility, added focused boundary tests, synchronized backend specs, and passed independent safe validation. - -### Git Commits - -| Hash | Message | -|------|---------| -| `aa8b41f` | (see git log) | - -### Status - -[OK] **Completed** - - -## Session 18: Retire dormant resource lease application - -**Date**: 2026-08-18 -**Task**: Retire dormant resource lease application -**Package**: core -**Branch**: `main` - -### Summary - -Removed the unassembled application/resourcelease package that exposed SQLite-owned lease types, retained the released runtime migration and SQLite compatibility fixture unchanged, aligned architecture and backend specs, and passed focused/broad offline validation plus independent review. - -### Git Commits - -| Hash | Message | -|------|---------| -| `1fe33a7` | (see git log) | - -### Status - -[OK] **Completed** - - -## Session 19: Close layer boundary audit remediation - -**Date**: 2026-08-18 -**Task**: Close layer boundary audit remediation -**Package**: core -**Branch**: `main` - -### Summary - -Closed the parent layer-boundary audit after all five violations and two architecture concerns were independently repaired and archived; added the historical-baseline remediation table, revalidated cumulative source and repository gates, and archived the 7/7 parent task. - -### Git Commits - -| Hash | Message | -|------|---------| -| `dce8356` | (see git log) | - -### Status - -[OK] **Completed** - - -## Session 20: Forward SMS content to Feishu - -**Date**: 2026-08-20 -**Task**: Forward SMS content to Feishu -**Package**: core -**Branch**: `sms-to-feishu-content` - -### Summary - -Implemented one independent Feishu message per newly persisted SMS with sender and complete body, preserved count-only summaries for non-Feishu channels, added replay/failure/privacy/multipart coverage, updated the application-boundary spec, and rebuilt the local dev Compose deployment at commit 47f273e. - -### Git Commits - -| Hash | Message | -|------|---------| -| `47f273e` | (see git log) | - -### Status - -[OK] **Completed** - - -## Session 21: Restore local Compose deployment - -**Date**: 2026-08-20 -**Task**: Restore local Compose deployment -**Package**: core -**Branch**: `sms-to-feishu-content` - -### Summary - -Recovered the local Compose deployment after an update was launched from a temporary worktree and bound an empty relative data directory. Restarted Compose from the canonical deployment root with the original environment and persistent data, verified the instance was ready, all long-running services were healthy on feature commit 47f273e, and the user-confirmed SMS notification succeeded. No persistent data was deleted and no additional SMS, RF, modem-write, or HIL action was initiated. - -### Git Commits - -| Hash | Message | -|------|---------| -| `47f273e` | (see git log) | - -### Status - -[OK] **Completed** - - -## Session 22: Patch Go and nanoid security baselines - -**Date**: 2026-08-20 -**Task**: Patch Go and nanoid security baselines -**Package**: core -**Branch**: `main` - -### Summary - -Upgraded the audited Go and Web dependency baselines, verified the complete local and GitHub quality gates, and rebase-merged PR #2 into main. - -### Main Changes - -- Upgraded repository-local and container Go pins from 1.26.5 to 1.26.6 with official archive checksums and the pinned image digest. -- Forced nanoid versions below 3.3.18 to the patched release and refreshed the pnpm lockfile. -- Created and self-reviewed PR #2, waited for all checks, rebase-merged it, and confirmed the post-merge main CI. - -### Git Commits - -| Hash | Message | -|------|---------| -| `97ad0f4` | (see git log) | - -### Testing - -- [OK] make security and the production-only pnpm audit -- [OK] make verify-modules, make lint, CI-equivalent make test, make build-go, and container contract checks -- [OK] Web typecheck, 70 Vitest tests, production build, generated drift, and 2 Playwright regressions -- [OK] PR checks and post-merge main CI - -### Status - -[OK] **Completed** - - -## Session 23: Publish versioned GHCR deployment candidate - -**Date**: 2026-08-21 -**Task**: Publish versioned GHCR deployment candidate -**Package**: core -**Branch**: `main` - -### Summary - -Published the deterministic Release-bundle/GHCR installation flow; preserved the failed v0.1.0 attempt and completed v0.1.1 with anonymous image and bundle verification. - -### Main Changes - -- Added deterministic versioned Compose bundles, strict tag-only GHCR publication, immutable digest manifest, docs, specs, and contract tests. -- Fixed curl HEAD semantics after v0.1.0 failed closed before image publication; released v0.1.1 from a new merge commit without moving the old tag. - -### Git Commits - -| Hash | Message | -|------|---------| -| `901eabe` | (see git log) | -| `bc05259` | (see git log) | -| `04dc171` | (see git log) | - -### Testing - -- [OK] PR CI, local lint/test/security, redacted worktree/history/assets scan, source/license review, eight anonymous Release assets, OCI/digest checks, and empty-credential Compose pull passed. - -### Status - -[OK] **Completed** - -### Next Steps - -- Complete the separately scoped clean Debian VM Compose lifecycle validation before claiming stable Runtime evidence. - - -## Session 24: Local production container cutover - -**Date**: 2026-08-21 -**Task**: Local production container cutover -**Package**: core -**Branch**: `main` - -### Summary - -Migrated the local Simplus Compose deployment from development images to the verified v0.1.1 GHCR release using a stopped snapshot restored into the production data root. Preserved source state and private rollback copies, verified health, public digests and exact mounts, and documented authoritative rollback-gate discipline. - -### Git Commits - -| Hash | Message | -|------|---------| -| `b76a687` | (see git log) | - -### Status - -[OK] **Completed** - - -## Session 25: Improve production onboarding docs - -**Date**: 2026-08-22 -**Task**: Improve production onboarding docs -**Package**: core -**Branch**: `main` - -### Summary - -Clarified the single deployment bundle plus checksum workflow, durable /opt/simplus installation, fail-closed lifecycle commands, bootstrap completion, and sanitized existing-host evidence without changing immutable v0.1.1 assets. - -### Git Commits - -| Hash | Message | -|------|---------| -| `e68b255` | (see git log) | - -### Status - -[OK] **Completed** - - -## Session 26: Fix Mihomo subscription refresh - -**Date**: 2026-09-14 -**Task**: Fix Mihomo subscription refresh -**Package**: core -**Branch**: `main` - -### Summary - -Use the provider-compatible Mihomo client identifier, classify refresh failures with bounded typed errors, show actionable Web guidance, and protect the flow with synthetic cross-layer regression tests and executable specs. - -### Git Commits - -| Hash | Message | -|------|---------| -| `f458670` | (see git log) | -| `f1fa0a9` | (see git log) | - -### Status - -[OK] **Completed** diff --git a/.trellis/workspace/index.md b/.trellis/workspace/index.md deleted file mode 100644 index f132a77..0000000 --- a/.trellis/workspace/index.md +++ /dev/null @@ -1,125 +0,0 @@ -# Workspace Index - -> Records of all AI Agent work records across all developers - ---- - -## Overview - -This directory tracks records for all developers working with AI Agents on this project. - -### File Structure - -``` -workspace/ -|-- index.md # This file - main index -+-- {developer}/ # Per-developer directory - |-- index.md # Personal index with session history - |-- tasks/ # Task files - | |-- *.json # Active tasks - | +-- archive/ # Archived tasks by month - +-- journal-N.md # Journal files (sequential: 1, 2, 3...) -``` - ---- - -## Active Developers - -| Developer | Last Active | Sessions | Active File | -|-----------|-------------|----------|-------------| -| (none yet) | - | - | - | - ---- - -## Getting Started - -### For New Developers - -Run the initialization script: - -```bash -python3 ./.trellis/scripts/init_developer.py <your-name> -``` - -This will: -1. Create your identity file (gitignored) -2. Create your progress directory -3. Create your personal index -4. Create initial journal file - -### For Returning Developers - -1. Get your developer name: - ```bash - python3 ./.trellis/scripts/get_developer.py - ``` - -2. Read your personal index: - ```bash - cat .trellis/workspace/$(python3 ./.trellis/scripts/get_developer.py)/index.md - ``` - ---- - -## Guidelines - -### Journal File Rules - -- **Max 2000 lines** per journal file -- When limit is reached, create `journal-{N+1}.md` -- Update your personal `index.md` when creating new files - -### Session Record Format - -Each session should include: -- Summary: One-line description -- Branch: Which branch the work was done on -- Main Changes: What was modified -- Git Commits: Commit hashes and messages -- Next Steps: What to do next - ---- - -## Session Template - -Use this template when recording sessions: - -```markdown -## Session {N}: {Title} - -**Date**: YYYY-MM-DD -**Task**: {task-name} -**Branch**: `{branch-name}` - -### Summary - -{One-line summary} - -### Main Changes - -- {Change 1} -- {Change 2} - -### Git Commits - -| Hash | Message | -|------|---------| -| `abc1234` | {commit message} | - -### Testing - -- [OK] {Test result} - -### Status - -[OK] **Completed** / # **In Progress** / [P] **Blocked** - -### Next Steps - -- {Next step 1} -- {Next step 2} -``` - ---- - -**Language**: All documentation must be written in **English**. diff --git a/AGENTS.md b/AGENTS.md index 94638f8..83efa71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,24 +1,33 @@ -<!-- TRELLIS:START --> -# Trellis Instructions - -These instructions are for AI assistants working in this project. - -This project is managed by Trellis. The working knowledge you need lives under `.trellis/`: - -- `.trellis/workflow.md` — development phases, when to create tasks, skill routing -- `.trellis/spec/` — package- and layer-scoped coding guidelines (read before writing code in a given layer) -- `.trellis/workspace/` — per-developer journals and session traces -- `.trellis/tasks/` — active and archived tasks (PRDs, research, jsonl context) - -If a Trellis command is available on your platform (e.g. `/trellis:finish-work`, `/trellis:continue`), prefer it over manual steps. Not every platform exposes every command. - -If you're using Codex or another agent-capable tool, additional project-scoped helpers may live in: -- `.agents/skills/` — reusable Trellis skills -- `.codex/agents/` — optional custom subagents - -Managed by Trellis. Edits outside this block are preserved; edits inside may be overwritten by a future `trellis update`. - -<!-- TRELLIS:END --> +# Simplus Agent Instructions + +## Project Map + +- Start with [the documentation map](docs/README.md). +- Product scope and non-goals: [product.md](docs/product.md). +- Process ownership and data flow: [architecture.md](docs/architecture.md). +- Current work and implementation status: [active plan](docs/plans/active/mvp.md) and [handoff](docs/handoff.zh-CN.md). +- Before editing code, read the relevant [engineering guidelines](docs/engineering/README.md) and nearby tests. +- Development commands and their side effects: [development.md](docs/development.md). +- Production deployment: [installation.md](docs/installation.md). +- Evidence levels and public records: [compatibility.md](docs/compatibility.md) and [privacy-and-publication.md](docs/privacy-and-publication.md). + +## Working Conventions + +- Keep product scope, architecture, and operational knowledge in their canonical documents; update links and summaries when those owners change. +- Use [decision records](docs/decisions/) for durable scope or architecture changes and follow [the plan guide](docs/plans/README.md) for complex or high-risk work. +- Search for existing narrow ports, helpers, and tests before adding abstractions. Keep application services separate from hardware protocols and persistence adapters. +- Start public API changes in `api/openapi.yaml`. Update generated Go, TypeScript, and sqlc outputs through their declared generators. +- Keep HTTP snapshots authoritative; browser realtime events only invalidate queries or signal attention. + +## Validation + +- Choose checks for the affected surface; start with focused tests and expand according to risk. +- Documentation: `make check-docs`. +- Go: affected package tests, then `make check-format`, `make lint`, and `make test` as needed. +- Web: `corepack pnpm --dir web typecheck`, `test`, `build`, and relevant fixture-based `e2e` checks. +- API, sqlc, or generator changes: `make verify-generated`. +- Container contracts: `make check-container-files` and `go test ./internal/containercontract`. +- Ordinary validation does not authorize deployment or real hardware side effects. ## Simplus Safety Boundaries diff --git a/docs/README.md b/docs/README.md index af36cb7..30fdd4b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ | [`plans/active/mvp.md`](plans/active/mvp.md) | 唯一活跃执行计划和下一步 | 规范性 | | [`handoff.zh-CN.md`](handoff.zh-CN.md) | 脱敏后的当前实现进度和接手提示 | 现场摘要 | | [`development.md`](development.md) | 通用 Linux 本地开发、测试和受控 HIL 命令 | 操作指南 | +| [`engineering/README.md`](engineering/README.md) | 后端、前端与基础设施的编码约定、边界合同和回归检查 | 工程规范 | | [`installation.md`](installation.md) | Docker Compose 生产部署、宿主准备和生命周期 | 操作指南 | | [`compatibility.md`](compatibility.md) | 公开兼容性结论和证据等级 | 参考证据 | | [`troubleshooting.md`](troubleshooting.md) | 不包含私人现场信息的稳定错误码和复查顺序 | 操作指南 | @@ -22,6 +23,7 @@ - 改产品范围:先读 `product.md`,并新增或更新 decision; - 改进程、领域或数据流:读 `architecture.md`; - 开始功能实现:读 `plans/active/mvp.md` 和 `handoff.zh-CN.md`; +- 修改代码或构建配置:按 `engineering/README.md` 阅读相关规范; - 启动、测试或连接真实硬件:读 `development.md`; - 判断某项能力是否真实验证:读 `compatibility.md`; - 排查正式运行态:读 `troubleshooting.md`; diff --git a/docs/engineering/README.md b/docs/engineering/README.md new file mode 100644 index 0000000..7bfedc9 --- /dev/null +++ b/docs/engineering/README.md @@ -0,0 +1,13 @@ +# 工程规范 + +这里保存项目专属的编码约定、边界合同和回归检查。修改代码前,按涉及的目录读取对应索引,再阅读相关专题和就近测试。 + +| 修改范围 | 规范入口 | +| --- | --- | +| `cmd/**`、`internal/**`、`api/**`、Go 测试、数据库 | [后端规范](backend/index.md) | +| `web/**`、路由、组件、查询缓存、浏览器测试 | [前端规范](frontend/index.md) | +| `Makefile`、构建与生成、容器、打包、硬件验证 | [基础设施规范](infra/index.md) | + +产品范围、架构、公开证据和执行计划仍由[文档地图](../README.md)列出的文档负责。这里记录实现合同;代码、测试和规范不一致时,先核实当前实现及已接受的决策,再同步受影响的文档。 + +验证命令及其副作用见[本地开发工作流](../development.md)。先运行与改动直接相关的检查,再按风险扩大范围;硬件操作和公开资料始终遵守[安全与发布边界](../privacy-and-publication.md)。 diff --git a/.trellis/spec/core/backend/api-contracts.md b/docs/engineering/backend/api-contracts.md similarity index 100% rename from .trellis/spec/core/backend/api-contracts.md rename to docs/engineering/backend/api-contracts.md diff --git a/.trellis/spec/core/backend/application-boundaries.md b/docs/engineering/backend/application-boundaries.md similarity index 100% rename from .trellis/spec/core/backend/application-boundaries.md rename to docs/engineering/backend/application-boundaries.md diff --git a/.trellis/spec/core/backend/directory-structure.md b/docs/engineering/backend/directory-structure.md similarity index 100% rename from .trellis/spec/core/backend/directory-structure.md rename to docs/engineering/backend/directory-structure.md diff --git a/.trellis/spec/core/backend/index.md b/docs/engineering/backend/index.md similarity index 97% rename from .trellis/spec/core/backend/index.md rename to docs/engineering/backend/index.md index 4e0818a..d62b837 100644 --- a/.trellis/spec/core/backend/index.md +++ b/docs/engineering/backend/index.md @@ -7,7 +7,7 @@ These guides apply to `cmd/**`, `internal/**`, `api/**`, `sqlc.yaml`, and Go tests in the root module declared by `go.mod`. The React workspace has its own -guidance under `.trellis/spec/web/frontend/`. +guidance under `docs/engineering/frontend/`. The current runtime is assembled as three responsibilities: `simplusd` owns the HTTP control plane and SQLite state, `simplus-agent` owns modem endpoints, @@ -29,7 +29,7 @@ data-flow map is `docs/architecture.md`; concrete assembly is in - For public HTTP changes, start at `api/openapi.yaml`; for Agent or netd Unix protocols, inspect the protocol types and both client/server validation. - For modem work, also read - `.trellis/spec/core/infra/hardware-and-hil-safety.md` before running anything + `docs/engineering/infra/hardware-and-hil-safety.md` before running anything against real hardware. ## Guide Index diff --git a/.trellis/spec/core/backend/quality-and-testing.md b/docs/engineering/backend/quality-and-testing.md similarity index 99% rename from .trellis/spec/core/backend/quality-and-testing.md rename to docs/engineering/backend/quality-and-testing.md index 83b5306..2125e31 100644 --- a/.trellis/spec/core/backend/quality-and-testing.md +++ b/docs/engineering/backend/quality-and-testing.md @@ -127,7 +127,7 @@ Unit, integration, Simulator, and container-contract checks must stay deterministic without a modem, SIM, subscription, privileged namespace, or private log. HIL-0 and side-effecting HIL are separate evidence levels defined in `docs/compatibility.md` and governed by -`.trellis/spec/core/infra/hardware-and-hil-safety.md`. +`docs/engineering/infra/hardware-and-hil-safety.md`. Do not run a real RF, messaging, call, SIM/eUICC, AKA/network, device-write, or HIL workflow merely because a unit test is missing. Add a fixture first and diff --git a/.trellis/spec/core/backend/storage-and-migrations.md b/docs/engineering/backend/storage-and-migrations.md similarity index 100% rename from .trellis/spec/core/backend/storage-and-migrations.md rename to docs/engineering/backend/storage-and-migrations.md diff --git a/.trellis/spec/web/frontend/component-guidelines.md b/docs/engineering/frontend/component-guidelines.md similarity index 100% rename from .trellis/spec/web/frontend/component-guidelines.md rename to docs/engineering/frontend/component-guidelines.md diff --git a/.trellis/spec/web/frontend/directory-structure.md b/docs/engineering/frontend/directory-structure.md similarity index 100% rename from .trellis/spec/web/frontend/directory-structure.md rename to docs/engineering/frontend/directory-structure.md diff --git a/.trellis/spec/web/frontend/hook-guidelines.md b/docs/engineering/frontend/hook-guidelines.md similarity index 100% rename from .trellis/spec/web/frontend/hook-guidelines.md rename to docs/engineering/frontend/hook-guidelines.md diff --git a/.trellis/spec/web/frontend/index.md b/docs/engineering/frontend/index.md similarity index 100% rename from .trellis/spec/web/frontend/index.md rename to docs/engineering/frontend/index.md diff --git a/.trellis/spec/web/frontend/quality-guidelines.md b/docs/engineering/frontend/quality-guidelines.md similarity index 100% rename from .trellis/spec/web/frontend/quality-guidelines.md rename to docs/engineering/frontend/quality-guidelines.md diff --git a/.trellis/spec/web/frontend/state-management.md b/docs/engineering/frontend/state-management.md similarity index 100% rename from .trellis/spec/web/frontend/state-management.md rename to docs/engineering/frontend/state-management.md diff --git a/.trellis/spec/web/frontend/type-safety.md b/docs/engineering/frontend/type-safety.md similarity index 100% rename from .trellis/spec/web/frontend/type-safety.md rename to docs/engineering/frontend/type-safety.md diff --git a/.trellis/spec/core/infra/build-and-generated-files.md b/docs/engineering/infra/build-and-generated-files.md similarity index 99% rename from .trellis/spec/core/infra/build-and-generated-files.md rename to docs/engineering/infra/build-and-generated-files.md index 391dfe4..5164877 100644 --- a/.trellis/spec/core/infra/build-and-generated-files.md +++ b/docs/engineering/infra/build-and-generated-files.md @@ -85,7 +85,7 @@ Do not replace this contract with unvalidated `X-Forwarded-Host` handling. Keep `web/src/viteConfig.test.ts` and a real proxy smoke when changing this wiring. Authentication-context and redirect assertions are specified in -[`web/frontend/state-management.md`](../../web/frontend/state-management.md#scenario-separate-setup-and-administrator-authorization). +[`frontend/state-management.md`](../frontend/state-management.md#scenario-separate-setup-and-administrator-authorization). ## Third-Party and Packaging Inputs diff --git a/.trellis/spec/core/infra/containers-and-privileges.md b/docs/engineering/infra/containers-and-privileges.md similarity index 99% rename from .trellis/spec/core/infra/containers-and-privileges.md rename to docs/engineering/infra/containers-and-privileges.md index 2751983..f51e7ba 100644 --- a/.trellis/spec/core/infra/containers-and-privileges.md +++ b/docs/engineering/infra/containers-and-privileges.md @@ -227,7 +227,7 @@ make container-build CONTAINER_IMAGE_TAG=dev Do not proceed from build/config to `docker compose up`, host preparation, clean-VM smoke, or hardware HIL without the task's explicit scope and the approval rules in -`.trellis/spec/core/infra/hardware-and-hil-safety.md`. +`docs/engineering/infra/hardware-and-hil-safety.md`. ## Scenario: live Compose cutover acceptance and rollback gates diff --git a/.trellis/spec/core/infra/hardware-and-hil-safety.md b/docs/engineering/infra/hardware-and-hil-safety.md similarity index 100% rename from .trellis/spec/core/infra/hardware-and-hil-safety.md rename to docs/engineering/infra/hardware-and-hil-safety.md diff --git a/.trellis/spec/core/infra/index.md b/docs/engineering/infra/index.md similarity index 100% rename from .trellis/spec/core/infra/index.md rename to docs/engineering/infra/index.md diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8afce38..5897d37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,7 @@ overrides: js-yaml@>=4.0.0 <4.3.2: 4.3.2 nanoid@<3.3.18: 3.3.18 postcss@<=8.5.22: 8.5.25 + undici@>=7.0.0 <7.29.1: 7.29.1 importers: @@ -1288,8 +1289,8 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} - undici@7.29.0: - resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + undici@7.29.1: + resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} engines: {node: '>=20.18.1'} vite@8.1.5: @@ -2364,7 +2365,7 @@ snapshots: saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.2 - undici: 7.29.0 + undici: 7.29.1 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 @@ -2624,7 +2625,7 @@ snapshots: undici-types@8.3.0: {} - undici@7.29.0: {} + undici@7.29.1: {} vite@8.1.5(@types/node@26.1.2)(jiti@2.7.0): dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 626c55d..7410f63 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,6 +6,7 @@ overrides: js-yaml@>=4.0.0 <4.3.2: 4.3.2 nanoid@<3.3.18: 3.3.18 postcss@<=8.5.22: 8.5.25 + undici@>=7.0.0 <7.29.1: 7.29.1 audit: level: moderate