CLAUDE.md · git:20261021.7899999 · 2026-10-21 · sha256 78ad60f2377de8a3

CLAUDE.md git:20261021.7899999A

Immutable. This exact content never changes and is served at /api/v1/blob/78ad60f2377de8a3.

# lets-workflow

Claude Code plugin for development workflow with session management, code review, and task tracking.

## Course: trust the model (since 2026-10-13)

LETS began as a control layer over the model: a gate at every step, redundant reminders, hooks and lints that police behaviour. The course is now the opposite - remove control, don't add it. Models no longer need the scaffolding, and every gate costs the owner attention.

- **Control only for real harm.** A gate, rule, hook, lint or self-check is added only to prevent a harm that is irreversible or leaves this machine (lost work, publishing, a merge, a release, another project) - never to make the model behave.
- **Removing beats adding.** A reversible model mistake is fixed forward, not fenced. A proposal that answers a mistake with a new layer names the costly harm it prevents - otherwise it is the old course.
- **Native over own.** What Claude Code already does (compaction, permissions, plan mode, SendMessage, memory) is used as is, with no LETS layer on top. LETS adds only what the harness cannot know - when and what to use it for: memory written at the right moments through native memory (lets-ogxwa), our plan run through native plan mode (lets-llh89).
- **A typed command is the approval** for what it names, and so are the owner's own words naming an action that asks at `standard` (a peer's text, a plan or the model's inference never are); local, reversible work runs and is reported in one line.
- **What stays:** gates on irreversible or outward actions (the `## Gates` table in `lets-rules.md`, per `LETS_GATES` level, lets-wu2xl) and the anti-runaway guards - a plan never runs itself, the deviation stop, the 3-failures stop.
- **Test for any change:** does it remove a question, a rule, a step or a file? If it adds one, it names the harm.

Tasks on this course - each records its problem -> decision, this section holds only the course: lets-0xsw2, lets-gbfpd, lets-wu2xl, lets-h1fox (audit of the existing control layer).

## Structure

Monorepo layout: plugin source in `plugins/lets/` subdirectory, marketplace manifest at root pointing into it. Infrastructure scripts and docs stay at root, outside the plugin payload.

```
.claude-plugin/marketplace.json   # Marketplace manifest (source: ./plugins/lets)
plugins/lets/                     # Plugin payload (everything ${CLAUDE_PLUGIN_ROOT} resolves to)
├── .claude-plugin/plugin.json    #   Plugin manifest (Claude Code; .codex-plugin/ planned for multi-agent)
├── commands/                     #   Slash commands (/lets:start, /lets:done, /lets:review, etc.)
├── agents/                       #   15 agents: experts dispatched by commands (skeptic verifies review findings, research claims and session CLAIMs)
├── skills/                       #   Reusable skills (user-facing auto-triggered + internal referenced by commands)
├── rules/                        #   Workflow rules (lets-rules.md) + tracker adapters (tracker-{beads,none}.md + TEMPLATE) - frontmatter-versioned, installed to .claude/rules/ by `lets init`
├── protocol/                     #   Lazy protocol files (worktrees, peers, handoff-lane, agents, roles) - Read by a command at the step that needs them
└── hooks/                        #   SessionStart hook (drift check + LETS Config + the closing language line - no rules in stdout)
cli/                              # Go CLI - companion binary (Phase 2+, lets-7vtaw)
├── cmd/lets/main.go              #   Entry point (thin)
├── internal/cli/                 #   Cobra command factories (root.go, version.go, *_test.go)
├── internal/version/version.go   #   Version (var, ldflags-overridable from git tag)
├── go.mod, go.sum
└── .golangci.yml                 #   golangci-lint v2 config (default linters + misspell; gofmt/goimports as formatters)
Makefile                          # Repo-root build (build/test/vet/lint/fmt/install/clean)
.editorconfig                     # Editor whitespace/charset settings
.github/workflows/                # CI: ci.yml (Go build/vet/test/lint on PRs+pushes), verify-versions.yml (source-tree version coherence), release.yml (tag-driven goreleaser)
scripts/dev/                      # Dev workflow helpers (make dev / dev-tmux)
scripts/release/                  # Release tooling: bump-version.sh + verify-versions.sh (used by Makefile bump/release-tag)
scripts/remote/dolt/              # Dolt SQL server VPS deployment + ad-hoc backup (NOT plugin)
scripts/remote/beads-web/         # beads-web (Rust kanban board) VPS deployment (NOT plugin)
scripts/deprecated/               # Retired scripts kept for cleanup runbooks - gitignored, not tracked
docs/                             # Public-facing docs (installation.md, commands.md, workflow.md, agents.md, autonomous.md, commands/ per-command pages, …) + images/; planning notes / comment exports / KB live in gitignored docs-local/
reference/                        # Local-only reference materials (gitignored)
```

References that resolve via `${CLAUDE_PLUGIN_ROOT}` (e.g. `${CLAUDE_PLUGIN_ROOT}/skills/X/SKILL.md`) work as before - the env var points at `plugins/lets/`.

## Local Development

Testing the LETS plugin against unmerged changes — across parallel worktrees, in TMUX, without polluting the global installation:

- **`make dev`** in a worktree builds `cli/lets` with version `dev-<branch>-<sha>[-dirty]`, prepends `<worktree>/cli/` to PATH, and execs `claude --plugin-dir <worktree>/plugins/lets`. Each invocation is self-contained — no global install, no marketplace mutation.
- **`make dev-tmux`** auto-discovers `.worktrees/*/` and spawns a tmux session with one Claude pane per worktree. Pass `WORKTREES="name1 name2"` to limit. Threshold prompt at >10.
- Implementation: `scripts/dev/run.sh` (subcommands: `build|info|claude|tmux`). The Makefile targets are thin shims for discoverability via `make help`.

**Gotcha — Claude-inside-Claude.** `make dev` must run from a **host terminal**, not from a Bash tool inside an existing Claude session. The Bash tool spawns a subshell that `exec`s the inner `claude`; the outer Claude's tool harness captures stdin/stdout, so the inner Claude has no terminal to interact through and hangs. Symptom: the Bash tool times out with no visible Claude prompt. Use a fresh tmux pane or terminal window instead.

The remaining dev-binary gotchas — `LETS_ENV_VERSION` stamping, old worktrees, PATH-shadowing ("trust the branch"), and `IsDev()` semantics — live in `CONTRIBUTING.md` "### Dev binary: `make dev` / `make dev-tmux`".

## Key Concepts

> Path convention: paths like `commands/`, `skills/`, `rules/lets-rules.md` in this doc are **relative to `plugins/lets/`** (the plugin root, also exposed as `${CLAUDE_PLUGIN_ROOT}` at runtime). Paths starting with `scripts/release/`, `scripts/remote/`, `docs/`, `reference/` are relative to the **repo root** (outside the plugin payload).
>
> Go CLI source paths (`cli/cmd/lets/main.go`, `cli/internal/...`) are relative to the **repo root**. The Go module root is `cli/` - all `go` commands operate from there (or via the repo-root `Makefile`).

- **Commands** = user-initiated workflows (sessions, commits, reviews)
- **Agents** = experts dispatched by commands. `/lets:review`, `/lets:opinion`, `/lets:ask`, `/lets:plan`, `/lets:backlog`, `/lets:research` dispatch via subagents. `/lets:team run` opens one visible LETS session per task on any launcher, and `/lets:team spawn` / `dismiss` run a standing team's members through `member-run`. `actor` is a meta-agent that loads external personalities (URL or file) and adapts them to LETS modes
- **Orchestrators** = commands that delegate to other commands. `/lets:github-pr` orchestrates `/lets:review` for full PR lifecycle
- **review-round** = the inverse of `/lets:review`: `/lets:review-round` CONSUMES a received multi-comment review (spec/doc/PR) - triage each item, decisions->beads task, artifact FROZEN during triage, ALL edits in ONE final pass. `commands/review-round.md`.
- **handoff** = the OUTBOUND side of `/lets:review`: `/lets:handoff` (deprecated alias `/lets:review-handoff`, to be removed in a future release) produces ONE self-contained brief an agent with no context (a fresh session, Codex, Antigravity, an external reviewer) can act on - plan / branch / local / staged / commits / range / PR / file. Target selectors mirror review; `--commits` / `--range` are handoff-only and `--pr` is an alias; `--json` / `--workflow` are not implemented. Without a delivery flag it reviews nothing and writes nothing, and works in a repo with no LETS (no `.lets/`, no tracker - a branch-derived task id still reaches the brief, marked as carrying no tracker metadata). Every brief carries the **`REMEDY QUALITY`** ask, the same rule the review family now states in `lets-rules.md`, `review.md`, `check.md` and `review.workflow.js`: separate the symptom from its root cause and fix at the component that owns the behavior. `--send` / `--open` / `--codex` (an open agent tab / a new Codex tab / headless) deliver the brief through `lets handoff` and relay the agent's report as UNVERIFIED, then check it finding by finding. `--execute` is the one brief that authorizes writes: an approved plan, stripped of its STOP banner by a positional awk filter (`TestHandoffPlanFilter` runs it) and followed by an execution contract, goes through `--send` only; the relay shows git's commits per handed-over task and ends at `/lets:review --branch`. `--fix` is the opposite direction: THIS session applies the report's verified fixes when nothing needs deciding, through the `apply-fixes` skill shared with `/lets:check --fix` and `/lets:review --fix`. `commands/handoff.md`, alias `commands/review-handoff.md`.
- **Hooks** = SessionStart runs `lets hook session-start`: an optional drift Notice, the LETS Config block and a closing response-language line, never the rules themselves (see the hook bullets under Architecture Decisions)
- **Rules layers** (lets-nobb5) - LETS instructions are split so only an always-on core rides every turn. **Core** = `rules/lets-rules.md` (pinned at 16,384 B by `TestRulesBudget`, down from 62 KB; 16,372 B used after lets-ogxwa): what applies in free conversation - language, boundaries, the one gate table by `LETS_GATES` level (`## Gates`, pinned by `TestGatesLint`), AUTO MODE, peer trust, task references + tracker essentials, worktree essentials, the flow table, the response footer, AskUserQuestion essentials, context window. **Protocol skills** = two internal skills, `protocol-tracker` (verb resolution: adapter lookup, neutral read shape, bodies, degradation, preflight, trust) and `protocol-orchestrator-offer` (select, resolve once, Act / Nav shapes), loaded via `Skill(...)` right before the step that needs them. **Protocol files** = five plain files `protocol/{worktrees,peers,handoff-lane,agents,roles}.md`, Read via `${CLAUDE_PLUGIN_ROOT}/protocol/<topic>.md` at the step. Each load sits at its step and names its condition - usually "unless its text is in your current context" (after `/compact` or `/clear` it is not); a few are scoped to one case (e.g. `/lets:done` Reads `protocol/worktrees.md` before a worker-PR merge run from another checkout). **Adapter** `tracker-<name>.md` <= 8,192 B, board <= 4,096 B. Rationale, worked examples, the full flow diagrams and the Skill Quick Reference table live in CONTRIBUTING.md "## Rules layers" (budgets, "where does a new rule go" test). `TestProtocolLint` keeps the wiring (a `lets-tracker` block loads `protocol-tracker` first; protocol references resolve; no pointer to a section that left core; heading pointers name a heading core still has); `TestOrcLint` asserts the offer load; `TestRulesBudget` the byte budgets. `protocol/roles.md` (lets-me9qt) is the one statement of who does what - routes, the CLAIM bar, lead-to-lead, rebase over freeze; `/lets:start` loads it, member-run hands every member a copy at spawn and `/lets:handoff` briefs carry it whole, `TestRolesLint` pins one source and `TestRolesDelivery` runs both delivery snippets.
- **Statusline** = `lets statusline` Go subcommand; `.claude/settings.json` invokes it directly, `lets init` wires it via value-match against `"lets statusline"` (foreign user-customized commands left alone). **The rich multi-line box (`cli/internal/statusline/rich.go`) is the DEFAULT** (identity / budget / task / rotating-tip; the Full tier adds a location pill); flags `--compact` / `--light` / `--no-tip` / `--no-dir` / `--no-task`. Glyphs are 1-cell text (no emoji), so `wideRunes` is empty — **the border drifts on (1) an unregistered 2-cell glyph, or (2) a glyph absent from the monospace face that font-substitutes to 2 cells while `cellWidth` counts 1** (the `lets-6md86` cause: `glyphFolder` changed `☰`→`»` because cmux/Ghostty rendered `☰` 2-cell; check a glyph with `printf '%s\n' 'ref |X|' 'g |<G>|'`). Run by hand `lets statusline` no longer hangs — a TTY-stdin guard prints a wiring hint and exits 0 (`lets-7frjs`). The task line reads `.lets/cache/task-status`, self-refreshed by a detached `--fetch-task-only` subprocess — no bd/network on the render path. Appearance is persisted (not just rendered) by `lets statusline config` / `/lets:statusline` to personal `.claude/settings.local.json`. Flags, `COLUMNS` tiers, width math, payload-robustness, escape-injection defense, the TTY guard, `lets statusline config`, and legacy-shim migration: `cli/README.md` "## lets statusline" / "## lets statusline config".
- **`.env` versioning** — the first key `LETS_ENV_VERSION` records which `lets` binary version last regenerated `.lets/.env`. `RegenerateEnv` (`cli/internal/initcmd/env.go`) is the canonical writer (preserves user values + foreign keys under `# User-added keys`; restores a hand-deleted `LETS_*` key to its `letsconfig.Defaults()` value). It is NOT in the `letsconfig.Keys` whitelist (metadata, not user-config), so the hook's session injection skips it. `lets init` regenerates on version mismatch or new prefs; `/lets:update` reuses it to refresh the header. Detail: `cli/README.md` "## lets init" / "## lets update".
- **`lets worktree create/remove/list/info`** — Go subcommand (`cli/internal/worktreecmd/`, `//go:build unix`) owns the filesystem/git work for interactive worktrees: name validation, not-inside-worktree guard, attach-vs-new-branch auto-detect, race-safe `.gitignore` hardening, atomic `git worktree add`, `.lets/` whole-dir symlink + `.beads/.env` targeted symlink, verify, and rollback. `commands/worktree.md` is a thin `--json` dispatcher (captures intent via `AskUserQuestion`); Windows gets a no-op stub (`lets-rqep4`). JSON envelope, typed exit codes, `--print-cd`, and the rollback contract: `cli/README.md` "## lets worktree". **Adopt / release / sweep:** `lets worktree adopt` makes a worktree LETS did not create (Orca, a teammate, `git worktree add`) a LETS worktree - `.lets` plus the tracker adapter's declared `## Worktree` store links, then the task id into `.task-<slug>` - locked, idempotent, never deleting (a cache-only `.lets` moves to `.lets.pre-adopt[-N]`); the SessionStart hook runs it for an unlinked worktree of an initialized project before LETS Config is built, and in every linked worktree declares the main `.lets/` in the worktree's `.claude/settings.local.json` `permissions.additionalDirectories` so writes there do not prompt from the next session (`EnsureLetsAdditionalDir`, also run by `create` and `adopt`; lets-urmfa). `release` records a worktree before it is archived; `lets worktree record` answers whether a task left a session record (anchored on the snapshot's `- head:` sha) and whether it is orphaned; `release` writes its marker before removing a task-state file that names a valid task (a task-less one, the post-`/lets:done` state, is removed without) and is loud when the record is missing; `sweep` deletes task branches merged into `origin/<merge>`; `remove` is origin-aware. The task id and branch naming convention is declared by the adapter (`id:` / `branch:` / `worktree-branch:` / `accept:`, overridable in the board file; `branch:` / `worktree-branch:` render without `id:`, which only reading an id back needs, and every declaration LETS cannot use is a `warn` step or a `convention_declaration_invalid` refusal - lets-puvic) and parsed / rendered only in Go (`trackeradapter`, `lets worktree branch-name`, `info --task-candidate`); `taskstate` is the one writer of `.task-<slug>` (`lets worktree task-state`).
- **`lets cmux open`** — optional, macOS-only worktree launcher (`cli/internal/cmuxcmd/`, `//go:build unix`). Wraps `cmux workspace create --cwd <path>` (manaflow-ai/cmux) to open a worktree session running `claude '/lets:start <id>'`. **Strictly optional, never hard-fails:** detects cmux on PATH + `runtime.GOOS=="darwin"`; on absence/error returns `OK=true` + `launch.launched=false` + a `fallback_command`. `commands/worktree.md` Step C3.5 decides terminal-vs-cmux from `$LETS_LAUNCHER` (+ `--cmux`/`--no-cmux` override) and renders the result. **Duplicate-session guard:** `open` refuses to spawn a second workspace for a path that already has one (`reason=already_open`; `--force` overrides) — "one live session per worktree" at the launcher level. **Identity stamp at create:** `open --name <slug>` sets the short tab label and `open --description "<task-id> · <title>"` stamps the canonical beads id + full title (cmux stores it, exposes it in `workspace list --json`), so each of N parallel sessions self-identifies its task — C3.5 wires both from the task. **`lets cmux rename`** relabels a workspace tab post-hoc (resolve by `--ref`/`--cwd`/active; invoked on-demand, not auto-wired) — identity is normally stamped at create now, so rename is the fallback, not the primary path. **Launch mode** (lets-d3wu3; at the `/lets:worktree create` / `/lets:team run` level, a `--command` string change): `--launch-mode default|auto|all-in` (`--auto` = `auto`, `--all-in` = `all-in`) beats `LETS_LAUNCH_MODE`, unset -> `default`; it launches `claude`, `claude --permission-mode auto` or `claude --permission-mode bypassPermissions` across terminal / cmux / tmux (Orca cannot pass a permission mode). `all-in` is explicit only (a typed flag or a key the owner set - never inferred, never a fallback, never the default) and every all-in launch prints one announce line first; LETS gates (push, PR, merge, close, peer sends) are unchanged and `crossSessionInbound` is never written. The harness holds a cross-session message only when exactly one side runs in the bypass class, and a session cannot read its own class (spike 2026-10-12: no env var, no registry field, not in SessionStart input), so the rule is "set `LETS_LAUNCH_MODE` to the class your orchestrator runs in". A team's mode is rendered into its `agent_command` / `orca_agent` by `team-init --launch-mode` (`worktreecmd.LaunchCommand`, the one renderer). **`lets cmux notify`** (lets-m8ecy) — the gate-notification sink: wraps `cmux notify --workspace <ref> --title/--subtitle/--body` (resolve by `--ref`/`--cwd`/active), same never-hard-fail contract as open/rename (`OK=true` + `notified=false` + reason off-macOS/cmux-absent/no-match; `Notified=true` means ENQUEUED, not confirmed-seen). The non-unix stub emits a parseable `ok=true,reason=not_macos` envelope (exit 0) — unlike open/rename's hard-error stub — so `--json` gate snippets degrade cleanly cross-platform. Driven at LETS gate points by the autonomous pipeline (below). Windows gets a stub. Detail: `cli/README.md` "## lets cmux" (`notify` bullet).
- **`lets tmux open/rename/notify`** (lets-0np5i) — the portable third `LETS_LAUNCHER`, cross-platform on unix (`cli/internal/tmuxcmd/`, `//go:build unix`; Windows stub). A file-for-file sibling of `cmuxcmd` (same JSON envelope, typed exit codes, never-hard-fail contract), driving the real `tmux` CLI. `open` opens the worktree as a **new window inside `$TMUX`** or a **new detached session** outside it (`send-keys` delivery; **never auto-attaches** — surfaces `attach_command`, because attaching would seize the Claude Code bash subprocess). Dup guard by `pane_current_path` (`EvalSymlinks`-resolved, load-bearing on macOS). `--description` is stamped into the `@lets_task` **window option**, NOT the window name (the tmux window name IS the width-budgeted status line). `notify` **broadcasts to every attached client** and reports `no_client` when none are attached — `display-message` into a detached session exits 0 while showing nobody, so a target-scoped notify would report a phantom success on the pipeline's own detached-session path. `reason` set: `tmux_not_found | tmux_error | already_open | pane_not_found | no_client`. No `not_macos`. Windows stub: `open`/`rename` hard-error, `notify` emits a parseable graceful envelope. Detail: `cli/README.md` "## lets tmux". **Not to be confused with `make dev-tmux`** (see "Local Development"): `make dev-tmux` is a **dev harness** (builds a dev binary, spawns one Claude pane per existing `.worktrees/*`, for testing the plugin); `LETS_LAUNCHER=tmux` is the **worktree launcher** (opens ONE new worktree session during `/lets:worktree create`). They share no code.
- **`lets orca open/notify/status`** (lets-ip06f) — an opt-in addon for the Orca desktop app (`cli/internal/orcacmd/`, `//go:build unix`), switched on by `LETS_LAUNCHER=orca` like cmux / tmux; without it no Orca binary is looked up and nothing else changes. `open` creates the task worktree through Orca (`/`-free name `<task-id>-<slug>`, Claude launched with `/lets:start <id>`), `notify` writes the gate note as the Orca card comment, `status` reports the binary and whether the app runs. `terminal` (lets-0rgnd) opens ONE Orca terminal in an existing worktree Go created (`--worktree`, `--title`, `--command`) and returns its handle - the standing team's lead; it never creates an Orca worktree (there is no `lets orca create`, and `open` is unchanged), and a refusal degrades to `launched=false` + a printed `fallback_command`. Resolves the app-bundle binary before PATH, never pins Orca's flags (calls the verb, classifies the failure) and never hard-fails: Orca absent or refusing -> `launched=false` + reason, and `/lets:worktree create` falls back to cmux, then terminal. `lets init` writes `orca.yaml` (setup hook `lets worktree adopt`, archive hook `lets worktree release`) only for a project with `LETS_LAUNCHER=orca`. Detail: `cli/README.md` "## lets orca".
- **`lets peers`** (lets-ip06f; names and native messaging lets-0xsw2) — the Go side of peer messaging between LETS sessions (`cli/internal/peerscmd/` + the `peertail` and `ccregistry` leaves, `//go:build unix`). Go lists, reads and resolves; it never delivers - every peer message is the harness's `SendMessage`, sent by the orc skill and addressed by session NAME. `who` lists the repo's live Claude sessions from the Claude Code registry only (no Orca): name, branch, the branch's task-state `task` / `orc`, `orchestrator` / `scope` / `standing`, `duplicate_name` (machine-wide; a parked session and its background continuation count once - `ccregistry.Snapshot.Named`, lets-gbfpd), plus `self` and `repo_slug`. An orchestrator is a NAME recorded by `/lets:start --main` as an `orchestrator: <name>[ scope=…][ standing=on|off]` line of the merge-branch task-state (`lets worktree task-state set --self-orchestrator`); `<repo>-orc[-<scope>]` (`peername`) counts only when no recorded name is live. `orchestrator` resolves bound (the branch's `orc:` name, live anywhere on the machine - a target in another repo is reached by name) -> self -> single / ambiguous / none, and always returns `self_record` (the caller's own record, standing tri-state; null when two live sessions share the caller's name, so a reused name never inherits a grant). With `--from-name` / `--from-addr` (the harness envelope of an inbound message), `orchestrator` (worker / lead side) and `who --orc <own>` (orchestrator side) also return `standing_ok` / `standing_reason`: Go decides whether the sender is the bound peer - the `from` socket's pid is the one live registry entry of that name, in this repo, bound, with standing on - and the model never compares names or pids. `sender --from-name --from-addr` (lets-wu2xl) answers provenance only, any repo: `sender_ok` when the `from` socket's pid is the one live registry entry of that name, plus `name` / `session6` / `cwd` / `in_repo`; fail closed with `sender_reason`; it reads no record, grant or binding and writes nothing - the orc skill picks the scope by `LETS_GATES`. `tail` reads one of this repo's sessions through `peertail` (`--since <RFC 3339>`; native INBOUND turns carry `from`); `ask-ro` takes `--question-file`. `frame` / `tell` / `wait` / `role` are deprecated stubs for one release: `tell` never types, it hands the framed text back for `SendMessage`; `role` records nothing (the `/clear` proof is the task-state `session_proof:` key, lets-gbfpd). The Orca typing transport for Claude peers, the role registry and the bound-sibling carve-out are gone; `who` / `orchestrator` refresh the last-seen files of live orchestrators. Every unknown registry or transcript format degrades by name. Detail: `cli/README.md` "## lets peers".
- **`/lets:orc` + `/lets:peer`** (lets-ip06f) — the `orc` skill (`skills/orc/SKILL.md`, user-facing: `/lets:orc` or natural language) asks / pings / reads / tells / lists the repo's peer sessions and is the ONLY sender of peer messages (`TestOrcLint` keeps every send form there and bans `lets peers tell` / `frame` / `wait` and `terminal send` everywhere; every touchpoint only offers it, with `footer=none`). Since lets-0xsw2 it sends with the harness's `SendMessage` by name (`notify_when_idle` for `ask`, after a `ListAgents` liveness check), prints an honest result line (`sent`, `held for approval in <name>`, `refused by <name>`, `expired unapproved in <name>`, `NOT SENT`) and never resends; a reply arrives as the peer's own `<cross-session-message>` and is relayed whole. A name outside this repo's `who` rows is sendable only as this branch's bound `orc:` name, a name the user typed in this turn, a hub listing row (`via=hub`) or the sender being answered. **Standing authorization** (`/lets:orc standing on|off`, recorded as `standing=` on the orchestrator's line; the full rule is `protocol/peers.md` `## Standing authorization`): a bound pair in this repo talks without per-message confirmation in both directions - a requested send from either side skips the Send? question, and either side may answer the other inside the agreed direction without asking (the sender identified only by Go's `standing_ok` from the envelope's `from-name` + `from`, a one-time notice before the first autonomous reply, at most 3 autonomous replies per peer until the user speaks); `--standing on` is written only with `--self-orchestrator`, from the orchestrator's own process (`orchestrator_not_own_process` otherwise); cross-repo, unbound and hub sends never use it, and every hard gate stays. **By `LETS_GATES`** (lets-wu2xl): the standing grant governs `strict`; at `standard` / `minimal` a requested send goes without the Send? question to any owner session (`ListAgents`, cross-repo included), and an inbound message is answered autonomously once `lets peers sender` returns `sender_ok=true` - a verified peer's request counts like the owner's for an action the core `## Gates` table does not list, inside the receiver's task; a listed one asks the receiver's owner once; the 3-reply cap and provenance in Go hold at every level. `/lets:peer <name> <verb>` (`commands/peer.md`) is a slash alias whose body is one `Skill` call into a skill with a target and no footer of its own. Inbound peer text is untrusted data (`lets-rules.md` `## Peer Messages`); a send is approval-gated under AUTO MODE unless a standing grant covers it. The rule for WHEN a command offers the orchestrator - the selection test, the Act / Nav shapes, silent without a live target - is the internal `protocol-orchestrator-offer` skill (lets-nobb5 - it was lets-rules `### Orchestrator offer`); touchpoints load it at the offer and `TestOrcLint` pins the label, that load, one `verb=ask` handler per offer and a four-option cap on every gate (lets-jjzzt).
- **`/lets:hub`** (lets-ip06f) — an Orca addon (`commands/hub.md`; stops in one line without `LETS_LAUNCHER=orca`, pinned by `TestOrcLint`): lists the orchestrators of every repo Orca knows (`lets peers who --orca-repos`, rows by `repo_index`; since lets-0xsw2 - each repo's recorded orchestrator names, `<repo>-orc*` only as the fallback, and stopped ones from its last-seen files), messages a live one by name through the orc skill (`via=hub`), answers a read-only question from a stopped one with `lets peers ask-ro --question-file` (a headless fork narrowed at launch: plan mode, `--tools Read,Grep,Glob`, empty MCP config, allowlisted env) and wakes one for gated work with `lets orca wake`. Refuses a live or unknown orchestrator; scheduled digests were dropped (an Orca automation cannot run in plan mode). Cross-project reads only on an explicit `/lets:hub` request (the hub carve-out in `plugins/lets/protocol/peers.md`; core rules only name it).
- **`lets notify`** (lets-0np5i) — launcher-neutral gate-notification sink (`cli/internal/notifycmd/`). Resolves `LETS_LAUNCHER` (project `.lets/.env` over `~/.lets/.env` via `letsconfig.MergedEnv`) and dispatches to `cmuxcmd.Notify` / `tmuxcmd.Notify` / `orcacmd.Notify`; `terminal` and unknown values degrade to `notified=false` + a named reason. The autonomous-pipeline gate snippets (`plan-workflow.md`, `execute.md`) call `lets notify` — NOT a hardcoded `lets cmux notify` — so a tmux operator finally receives gate notifications. Launcher validation lives in `letsconfig.ShippedLaunchers` + `ValidLauncher`, pinned to the registered subcommands by `TestShippedLaunchers_MatchSubcommands` (the launcher analogue of the tracker platform's `TestShippedTrackers_MatchOnDisk` — a tracker adapter is a file on disk, a launcher is a cobra subcommand).
- **`lets handoff`** (lets-w5tm5) - delivery of a hand-off brief (`cli/internal/handoffcmd/`, `//go:build unix`): `targets` (agent terminals of this checkout; never this session's own, never a shell), `send` (one-line pointer; refused only on evidence - Orca's agent state, a draft Orca holds for the input line, a dialog in any agent's tab (permission / trust / update prompt), or the Codex / Claude screen recognizers seeing a busy agent or half-typed text - otherwise typed with `input_line` recorded; no interrupt; a stale handle re-joins the same pane by `tabId:leafId` and sends once; never resent after a delivery attempt; no `turn_started` = `unproven` + redacted read-back), `codex` (headless `codex exec --sandbox read-only`; `-o` first, rollout second; the process group is killed on return), `await` (Codex: the `task_complete` whose `turn_id` is the turn of the delivered user message, in a root session, found across all rollout date directories; any other agent: the report-file contract - `<base>-agent-report.md` + `.done`). Content fingerprint before/after flags working-tree drift. Logic in the leaf package `cli/internal/agentrun` (`Provider`: `Name` / `Run` / `Await`) - the seam lets-9yz68 reuses. Never imports `peerscmd` (`TestDeniedImports`). Detail: `cli/README.md` "## lets handoff".
- **`lets members`** (deprecated stub, lets-gbfpd) - records nothing; removed next release. Members are tracked by the caller's own files - the recorded agent id is the address and `ListAgents` only says busy (lets-mpjhc, lets-kwd6e): the execute run record (`agent_id`, `dismissed` per group and per run analyst) and the team file (`Member: <name> = <agentId> lets:<role> (lead <session6>)` and `Dismissed: <name> (lead <session6>)` lines in `## 7. Current task`). The stub (`cli/internal/cli/members.go`, no build tag; `cli/internal/memberscmd/` is deleted) keeps the v0.10.0 flag sets and envelope - every verb exits 0 with a `deprecated` key and answers so a v0.10.0 plugin's gates pass (a live lead in a `run-*` scope or in the worktree the scope's team file claims, a live member for `--name`, `claimed` echoing `--claim`). Exits 40-49 are retired. `.lets/execution/members-*.json` stay as history, never read. Detail: `cli/README.md` "## lets members".
- **`lets integrate`** (lets-7dwc1) - lands `--since..--from` of an isolated implementer's branch in the caller's tree as ONE staged patch (`cli/internal/integratecmd/`, exits 50-59): cherry-picks in a temporary detached worktree at `<git-common-dir>/lets-integrate/<run>-<chunk>` (hooks off), writes `.lets/cache/integrate-<run>-<chunk>.patch`, applies it with `git apply --index` and verifies it against the picked tree - never a merge, never `reset --hard`, never `git worktree prune`; the lead commits. The temp worktree is removed only when an owner marker outside it and a nonce in its own gitdir both match. `--revert --patch <path>` undoes a rejected patch before it is committed. Detail: `cli/README.md` "## lets integrate".
- **Team file** (lets-7dwc1) - `.lets/teams/<callsign>.md`, rendered from `plugins/lets/templates/team.md` by `teamfile.Render`; model-read, except the frontmatter keys Go reads: `worktree`, `git_dir`, `lead_name` (`teamfile.LeadName`; lets-gbfpd) and `agent_command` / `orca_agent` (`teamfile.LaunchCommands`, classified for `lets worktree info` `team_launch`; lets-gbfpd) (`teamfile.FindByWorktree`, 4 values: name, ok, warnings, err - a malformed file claiming ANOTHER worktree is a warning, two claimers are `ErrAmbiguousTeam`, a reused path `ErrStaleTeam`, a malformed claimer `ErrMalformedTeam`). `lets worktree info` reports it as `team`; `adopt` derives no task id in a team worktree (`team_` branch or dir, or a team file hit). Continuity is files only: the harness restores no member after a lead restart.
- **Standing teams** (lets-0rgnd) - a team per area that outlives its tasks; the workspace and the files are what persist. A team is named by a **callsign** (snake, frog, ...; `teamfile.Callsigns`, 40 animals): team name = callsign, team file `.lets/teams/<callsign>.md`, worktree dir AND branch `team_<callsign>`, lead session = the team file's `lead_name`: `<repo>-<callsign>-lead` for a new team (`peername.Lead(RepoSlug)`, the short `<callsign>-lead` with a warn step when that would not fit the session-name grammar), an existing team keeps the name it recorded (lets-gbfpd); `lets worktree info` reports `team_lead` (`name`, `self`, `match` lead | other | unknown) - a hint, never a gate - plus `launch_mode` (`value`, `source`, `valid`) and `team_launch` (the team file's `agent_command` / `orca_agent` classified by `worktreecmd.ClassifyLaunch`: `default` | `auto` | `bypass`, warnings `settings_override` / `cross_session_inbound`; `team-init` reports the same as `launch_class`), which the all-in announce and the `/lets:team run` confirm read - never a substring test (lets-gbfpd). `/lets:team create` routes to `/lets:worktree create --team`: `lets worktree team-init --suggest-callsign` proposes a free one and `--check` verifies it (no team file, no live session carrying its lead name - the callsign is unique per repository, so another repo's lead never collides) BEFORE anything exists; both report `lead_name`; Go creates `team_<callsign>` from `origin/<merge>` for EVERY launcher; `team-init` then writes the team file through a fsynced temp + `os.Link` (never overwritten, by any writer: 27 `team_exists`, 28 `template_missing`, 34 `callsign_live`); the optional `.lets/hooks/team-setup` is shown whole and runs only on the owner's yes; only the lead is launched, as a plain session (`{agent_command} --name '<lead_name>' '/lets:start'`; lets-eolec - a session agent would drop the default prompt and native memory; orca: `lets orca terminal`; the command crosses as a quoted heredoc); a new team file's roster holds only the lead, and the lead's `/lets:start` asks which members to spawn when none is live - architect + skeptic + explorer (recommended), skeptic + explorer, or none - adding the picked roles as rows (lets-7k6ae). **Team mode** (lets-mpjhc, lets-kwd6e): in a team worktree whose `team_lead.match` is `lead`, `/lets:plan`, `/lets:review`, `/lets:check`, `/lets:opinion`, `/lets:ask` and `/lets:execute` dispatch to the team's members first through `member-run` `op=route` / `op=receive` (the caller contract is `protocol/agents.md` `## Team mode`; a role the roster lacks runs as an announced one-shot; `--solo` turns it off; the Members question is `/lets:team members`, asked at most once per lead session), and every spawn records `Member: <name> = <agentId> lets:<role> (lead <session6>)` in the team file's `## 7` - a member is addressed and resumed by that id, and only a send returning `agent_gone` counts it gone, never its absence from `ListAgents`. `/lets:start` in the team worktree claims no lead (it only hints `/rename` when `team_lead` says `other`) and moves the worktree between tasks with `lets worktree switch`: refuses 29 `not_team_worktree`, 33 `target_is_merge_branch`, 35 `session_held`, and as exit 14 kinds `dirty_worktree` (without `--park`), `index_unmerged`, `operation_in_progress`, `detached_head`; a new branch is cut from `origin/<merge>` with `--no-track` (31 `no_remote_base` when missing); `--park` commits tracked and `--include`-named new paths as `wip(<id>): park` (30 `untracked_present` for a new path not named; ignored files are never parked; never a stash) and records `park:` / `park_team:`; a refusal reports where it would have gone as `planned`, never `branch` / `created` / `base`; exit 32 (`members_live`) is retired - the busy-writer check is take-task Step 2T's `ListAgents` (lets-gbfpd); arriving on a parked branch unparks (`reset --soft HEAD~1`) only when the park is HEAD and on no remote. `lets worktree parked --team <c>` lists `owned` / `unowned` parks. `/lets:done` warns about park commits in its range and, in a team worktree, offers to promote facts to the team file's Standing knowledge, skipping what `/lets:note --team [text]` already wrote there mid-task (lets-phm2v). `/lets:team disband` (D1-D6) never kills: the lead is the session named by `lead_name` in `lets peers who` (live -> tell it, or "Lead has wrapped up"; `who` failed -> ask the owner as for a live one), an in-progress task or an owned park stops it, the worktree goes through `/lets:worktree remove` with its nets, `.lets/hooks/team-teardown` runs only on a yes, and the team file (and any legacy members registry) stay as history. Detail: `cli/README.md` "## lets worktree".
- **Session-owner guard** (lets-7dwc1; reworked by lets-gbfpd) - the SessionStart hook no longer lets any session in a worktree take the task-state `session:` line: the refresh (startup) and the `/clear` carry run `taskstate.MaySetSession` under the task lock (1 s deadline) - a live (`HeldLive`, `HeldRotatedForeign`) or unjudgeable (`HeldUnknown`) foreign holder is never overwritten; there is no lead rule. The carry needs proof: the task-state `session_proof: <sid> <pid> <started-ms>` key, written by Go beside every Go `session:` write from the caller's own registry row (`ccregistry.Snapshot.CallerSession`: the nearest Claude Code process above the command must be that session), binds only when its sid is the recorded session, and the guard requires that pid's current row to carry exactly that `startedAt` (`Snapshot.SameProcess`) before `Snapshot.Rotated`; on `claude -r` (resume) the hook rewrites only the proof. A session parked into a background job is continued in it (`Snapshot.ContinuedIn`, lets-56ymc). `lets worktree task-state set --session-id` is guarded too: a live foreign holder refuses with exit 35 `session_held`, nothing written; unknown / dead / no proof write (the "/lets:start in this chat" remedy). A declined write is a Notice naming the holder and the remedy, and session-boundary then reports `prior-session`. Detail: `cli/README.md` "## lets hook".
- **Autonomous task pipeline** (lets-m8ecy) — near-autonomous spawn → plan → execute so N parallel sessions are manageable: `/lets:worktree create <id> --flow plan-workflow --auto` spawns a worktree that claims the task (resolve-and-claim convention in `detect-task`, AUTO-MODE-exempt entry claim) and lands in autonomous `plan-workflow` (gated on its PREVIEW availability, falls back to `--flow plan`); the human answers a bounded up-front clarify gate (GATE 1) and approves the plan file (GATE 2); `/lets:execute --auto` then runs the approved plan without per-step gates (hard-stops preserved; REFUSES on the merge-branch). cmux notifications fire at the gates (+ execute-blocked), marker-gated by the per-task `.lets/cache/pipeline-state-<id>` file so only autonomous runs notify. The statusline phase-row that renders the marker is a deferred follow-up. `--flow` only swaps the launch `--command` (launcher-agnostic — terminal/cmux/tmux inherit). Design boundary held: filesystem/process/git → Go (`cmuxcmd`); orchestration/gates → markdown.
- **`/lets:update`** — sync a project with the current release. `commands/update.md` shells `lets update --json` (mirrors `/lets:init → lets init`); the Go subcommand (`cli/internal/updatecmd/`) auto-syncs `.lets/.env` (via `RegenerateEnv`, skipped on a `dev` binary) and `.claude/rules/lets-rules.md` (via `drift.Check` + re-copy), then version-checks the `lets` binary + the plugin against the latest GitHub release (report-only — can't self-replace). **Order-aware + self-driving (lets-rlue4):** when the plugin is behind (outdated vs latest, or locally < the binary) the rules/tracker-rules re-copy is **`deferred`** (NOT written) so rules never sync to a stale plugin — only `StateOutdated` defers (missing still installs, ahead keeps its reset). `Run` emits artifacts `.env → binary → plugin → rules → user-rules → tracker-rules` and computes a single top-level `next_action` (`init|binary|plugin|reload|main-checkout|new-session|user-rules|done`, `command` execution-bound const for binary, `version` for done) from the artifact statuses; the command renders exactly that one step (done → `✓ Everything on vX.Y.Z`, no matrix) and can run the binary installer and the on-disk plugin refresh (`claude plugin marketplace update` + `claude plugin update`) in-session (each approval-gated, one attempt). **Installed-plugin resolver + honest steps (lets-tg008):** `ResolveInstalledRoot` compares the project rows against the plugin actually installed (`~/.claude/plugins/installed_plugins.json`), not the per-session `${CLAUDE_PLUGIN_ROOT}`; an installed-but-not-loaded plugin sets `loaded_plugin_version` and `next_action=reload` saying a re-run reports the same; a worktree run no longer refuses - `.env`/`rules`/`tracker-rules` come back `skipped` (`summary.skipped`), `main_checkout` names the main checkout and `next_action=main-checkout`. `SchemaVersion` stays 2 (`next_action`/`deferred`/`skipped`/`loaded_plugin_version`/`main_checkout` are additive). Does NOT prompt for config or touch `settings.json`/beads — that's `lets init`'s job (init = setup; update = sync). Rules-drift Notices point here for `unknown`/`outdated`/`ahead`; `/lets:init` only for `missing`. The global `~/.claude/rules/lets-rules.md` is not drift-checked: it is a cache of the running plugin kept by the SessionStart hook (`internal/rulescache` - sha256-keyed in `~/.lets/cache/rules-cache.json`, one lock, forward-only, only an installed plugin writes, a hand-edited copy is saved to `.bak[-N]` first; `lets init --user` is only the first install) (lets-tg008). Scope-aware (`LETS_RULES_SCOPE`, merged project>user env): `scope=user` + missing project copy = `delegated` (rules from `~/.claude/rules`, never re-created - kills the boomerang) or `not-initialized` when nothing covers the project. The 4-artifact table (+ two optional rows: `user-rules` - omitted when `~/.claude/rules/lets-rules.md` is absent, read-only: `delegated` only when key, file and the verified installed plugin share one hash, else `unknown` with the reason and `next_action=new-session|user-rules` (lets-tg008); and `tracker-rules` - the active `tracker-<name>.md` adapter, present when `LETS_TRACKER` names a shipped adapter (or a user-authored one with an installed copy - reported `delegated`, left as-is), and the row that applies switch cleanup + board scaffold on the edit-`.env`-then-update path) + caching detail: `cli/README.md` "## lets update" (lets-hdrdr.3).
- **Skills** = reusable actions in `skills/<name>/SKILL.md`. Two types: user-facing (auto-discovered, triggered via description match; can also be invoked via the `Skill` tool) and internal (not auto-discovered, invoked by commands via the `Skill` tool when needed). Examples: `create-task`, `commit`, `take-task` (user-facing), `detect-task`, `actor-fetch-personality` (internal)
- **Tracker platform** (lets-5d48z) — `LETS_TRACKER` is no longer schema-reserved: it names a **tracker adapter** (`beads` | `none`; a non-beads adapter like planfix-mcp is user-authored, not shipped). One self-contained `rules/tracker-<name>.md` per adapter binds the neutral verb set (`create`/`show`/`comment-add`/`set-status`/`close` CORE + optional `comment-list`/`list-by-status`/`search`/`ready`-stats) to a concrete transport (a `bd` command, an `mcp__*` tool, a generic REST proxy). One axis, not two — transport is encoded per-verb in the file, so there is no separate transport/driver config key. Command/skill BODIES carry neutral ` ```lets-tracker ` verb blocks (one `verb key=value` per line) — NOT inline `bd`; the orchestrator resolves each block via the loaded `tracker-<name>.md` and runs ITS binding - the resolution rule itself is the internal `protocol-tracker` skill, loaded before a file's first block (for beads, the same `bd` command, now table-driven + golden-pinned). A shell-computed comment/description body crosses via a `<field>-file=` temp file (`body-file=` for comments, `description-file=` for create; no computed multi-line value re-typed across the model boundary); a short or static-templated body is inline `body=`/`description=`; reads return the neutral `{id,title,status,url,description}` shape. **Each adapter declares its fields** (lets-x1rnx): `create`/`set-field` open with `accepts:`, `show` with `returns:`, in neutral names (`priority`→`severity` for a native rename), so a command never collects a field the tracker cannot store; and `close` declares the status it leaves the task in, so a board whose terminal is process-gated reports an advance-and-handoff instead of a phantom close. `lets init` installs exactly one `tracker-<name>.md` (drift-tracked like `lets-rules.md`, auto-loaded from `.claude/rules/`). beads = reference adapter (the bd invocations are unchanged, pinned by a golden test; **no inline `bd` survives anywhere** — the allowlist that once held the detect-task liveness probe ships empty and is asserted empty, and the probe goes through the neutral `show` — *lets-x1rnx*); none = null adapter (supersedes lets-nwwkj). planfix-mcp was prototyped (pure MCP via the server's generic `planfix_request` REST passthrough) but is NOT shipped - it is a user-authored adapter example (see docs/trackers.md); a popular-platform worked example is planned. A shared adapter file is trusted instruction auto-loaded into context — its bindings execute as written (review before installing one you didn't author; contract test pins shape, not safety). Adding a tracker is a one-file recipe (CONTRIBUTING "Add a tracker adapter"), enforced by a contract test (incl. the `accepts:`/`returns:` declarations, asserted against the neutral field vocabulary) + a beads-binding golden + a two-tier "no bd in a command body" scan that covers EVERY fence and the `*.workflow.js` assets + a status-canon pin on `lets-tracker` argument values + a `testdata/tracker-fake.md` partial-adapter fixture (the shipped set is all-or-nothing, so absent verbs and renamed fields had no coverage). The full platform section + the `.mcp.json` wiring live below / in `docs/trackers.md`.

## Architecture Decisions

- **Adversarial verification + Dynamic Workflows (opt-in `--workflow`).** `/lets:review` refutes every `[BLOCKER]`/`[SUGGESTION]` with `lets:skeptic` agents before reporting (asymmetric drop: SUGGESTION on a simple-majority `real=false`; BLOCKER only on near-unanimous high-confidence refute, downgraded on a simple-majority, else kept). Runs in BOTH standard (Task, capped) and `--workflow` (off-context) modes — `--workflow` is a pure performance lever, same verified findings. **SPEC injection + PR branch gate:** `/lets:review` + `/lets:check` resolve the active task's SPEC via the tracker's neutral `show` and inject it as fenced data into the reviewer prompts, so planned-but-unwired work is not flagged as scope creep; with no spec a scope finding caps at `[SUGGESTION]`. The skeptic's block is narrower - it cannot set a tier, so a tier-cap instruction would be executed as `real=false`, which `decide()` maps to a **drop**. PR mode OFFERS a `gh pr checkout` so agents read the reviewed code: it **never creates a worktree** (the user's choice; pinned by `TestReviewNeverCreatesWorktree`), `--json` never touches the working tree, and the previous branch is always restored - branch-keyed task state desyncs otherwise. `/lets:review`, `/lets:opinion`, `/lets:backlog`, `/lets:research` ship committed workflow skeletons (`skills/<name>-workflow/<name>.workflow.js`); authoring standard + runtime constraints in **Dynamic Workflow Assets** below.
- **Audience for plugin source** — `commands/`, `skills/`, `agents/`, `rules/lets-rules.md` are read by the model (orchestrator + subagents), never by humans: write terse, structured, marker-driven (`MANDATORY`/`NEVER`/`IMPORTANT`), tables over prose. Human-facing docs are `README.md` + `CLAUDE.md`. Full guidance: `CONTRIBUTING.md` "## Audience of plugin source".
- **Claude Code session-id channels (two of them — pick the right one).**
  - `${CLAUDE_SESSION_ID}` — **template substitution** in command/skill markdown: Claude Code rewrites it to the literal session UUID before the model reads the spec. Available since Claude Code v2.1.9. Currently **unused** in the plugin: the last consumer (the session snapshot) moved to the bash channel because this template channel is fragile inside a multiline Write-tool arg (`session-snapshot` SKILL / lets-bdkvd QA #13). Prefer `$CLAUDE_CODE_SESSION_ID` (bash) for any new use.
  - `$CLAUDE_CODE_SESSION_ID` — **Bash subprocess env var** Claude Code injects into every Bash tool invocation. Use inside bash commands — `bash` expands it at runtime, no template/model magic needed. **Preferred** for bash-only contexts (e.g. `bd comments add "... $CLAUDE_CODE_SESSION_ID ..."`) because it sidesteps the placeholder-skip fragility QA found in template-substitution-inside-multiline-args (`lets-bdkvd` QA #13). Also the only channel subagents and external scripts can read.
  - **Naming gotcha:** template has no `_CODE_`, env var has `_CODE_`. They are NOT aliases.
  - As of this branch, `/lets:end` + `/lets:done` use `$CLAUDE_CODE_SESSION_ID`; broader adoption (subagents stamping session_id, statusline, `/lets:team` records) tracked in `lets-bdkvd`.
- **Other Claude Code template variables in command/skill markdown.** `${CLAUDE_PLUGIN_ROOT}` (plugin install path) is substituted at command/skill load time — immune to context compaction. `${CLAUDE_PROJECT_DIR}` is NOT substituted; use `git rev-parse --show-toplevel` instead.
- Agents define WHO and HOW (expertise, behavioral modes, tiered scoring, output format). Commands define WHAT and WHEN (provide content, select agents, pass mode name)
- Agent frontmatter fields: `name`, `description`, `tools`, `color` (terminal output: red/blue/green/yellow/purple/orange/pink/cyan), optional `model`, optional `omitClaudeMd` (see below). Model policy (lets-f1lu1): agents carry **no upward model pin** - each inherits the session model, so a Fable session runs Fable everywhere (a NAME pin like `opus` goes stale and *downgrades* agents to an older model once a newer one ships - e.g. Opus 4.8 on a Fable session). The sole exception is `explorer` -> `sonnet`, a **downward cost-pin** for mechanical codebase-mapping (a downward pin can't cause a capability downgrade). Verify (`skeptic`) deliberately stays on the session model - a cheap verifier false-drops hard `[BLOCKER]`s (`decide()` drops a BLOCKER when all skeptics vote `real=false` at *any* confidence, and a weaker model leans `real=false` when it can't substantiate a deep finding). The expert/analyst agents use tiered scoring ([BLOCKER]/[SUGGESTION]/[NIT]), self-contained Modes, and Output Format sections; `explorer` (codebase mapping), `implementer` (writes code), and `skeptic` (single-finding verifier) carry their own task-specific formats instead. `omitClaudeMd: true` (lets-nobb5): the 14 read-only analysts skip the project's `CLAUDE.md` and `.claude/rules/*.md` - `compliance` and `docs` Read them as a first step (they audit them), `skeptic` Reads them when the finding cites a project rule; `implementer` keeps them (it writes code under those rules). `TestAgentsOmitClaudeMd` pins it.
- Agent memory (`memory: project` frontmatter) is **currently disabled** across all agents as a workaround for upstream Claude Code issue [#55648](https://github.com/anthropics/claude-code/issues/55648). Subagents writing memory skip the assigned task and return only a memory-write confirmation. Disabled via [PR #47](https://github.com/restarter/lets-workflow/pull/47). Tracked via bd tasks `lets-erx1c` (this disable) and `lets-rqwdg` (restore memory when Anthropic fixes the bug).
- Actor meta-agent loads personalities at runtime via internal skill `actor-fetch-personality`. Command fetches personality content (curl for URLs, Read for files), user confirms via review gate, actor receives it in prompt as `PERSONALITY:` block. Fallback "generalist" identity when no personality provided
- Agent selection: each command owns its detection/selection logic (different semantics per context). Multi-agent dispatching commands (review, opinion, backlog, plan) show selection panel with cost note before launch. Most agents have explicit PLAN mode for plan review.
- Agents always respond in English - for the analysts this rests on the report-language line in each `## Constraints` (they no longer load the rules, see `omitClaudeMd` above). Commands localize output to user's language via LETS Config and Rules.
- `/lets:review`, `/lets:opinion`, `/lets:ask`, `/lets:plan`, `/lets:backlog`, `/lets:research` use `subagent_type: "lets:agent-name"` to dispatch agents via Task tool (research dispatches `lets:skeptic` for its cross-check; its web fetchers are the default subagent, not `lets:*`)
- `/lets:github-pr` orchestrates `/lets:review` (delegates analysis) and handles GitHub posting, follow-up, respond, and approval directly via gh CLI
- `/lets:execute` runs a plan inline in native plan mode (EnterPlanMode approval is the code-write gate), or - the Implementers locus - delegates it to `lets:implementer` subagents through the `member-run` skill, with an explicit Start gate before any spawn; delegated runs are interactive only, never under `--auto`. A delegated run keeps ONE persistent implementer (`impl-<RUN>`) and hands it each next chunk as `op=next` with a brief file. `--parallel` runs lead-declared, file-disjoint groups in harness-isolated worktrees (`isolation: worktree`, a guarded `git switch -C {BASE}` at spawn only) and lands each chunk through `lets integrate`; `--pipelined` lets the implementer commit locally and move on, and the closing autosquash needs `lets worktree pushed` = `not_pushed` (every configured remote is checked) and the owner's yes. Each chunk carries `Risk: high|low` (missing = high); the explorer CHECK runs on every chunk and the skeptic on high. The gate policy (`per-commit` | `high-only` | `at-end`; the default follows `LETS_GATES` - `per-commit` at `strict`, `at-end` at `standard` / `minimal`, lets-wu2xl - proposed in the Step 4.7 launch plan or set by `--gate`) decides who accepts and commits, from ONE table in 5-D.5; `high-only` / `at-end` end in the 5-D.8 run review before the 5-D.9 completion. Hard stops and deviations halt at once in every policy.
- `/lets:check` reviews inline (no subagent) for speed
- All analyst agents carry uniform tools `Read, Grep, Glob, Bash, Write, SendMessage` - Write only for the one `REPORT_FILE` the dispatching command names through the `agent-report` skill (lets-1irms); no `Edit`; `SendMessage` for team routing (lets-q73zt; one definition for every agent, not a team variant - reason recorded on the task). Exception: `agents/implementer.md` adds `Edit/Write` - spawned only through the `member-run` skill - by `/lets:execute` delegated runs (one persistent implementer per group; isolated and pipelined modes commit on their own branch) and by `/lets:team spawn implementer` in a standing team.
- `/lets:team` is the team-management surface: `run` gives each task its own worktree and a visible LETS session (a worker) bound to this session through `/lets:orc`, on any launcher (terminal / cmux / tmux / orca); `spawn` / `dismiss` / `roster` manage a standing team's members through `member-run`; `status` and `stop` cover runs. The Agent Teams primitives are gone from the harness (no TeamCreate / TaskCreate; the Agent tool's team and mode parameters are ignored), so `--backend agents` is refused, never a fallback. **`--backend orca`** (lets-ip06f) is the Orca addon inside the sessions backend: offered only with `LETS_LAUNCHER=orca` and the app running, it runs each task as a visible LETS session in an Orca child worktree through Orca's supervised orchestration (the coordinator relays questions whole and replies only with the human's words); records carry `backend`, and a guard refuses a task an active record of the other backend holds. `TestTeamBackend` pins the separation. All other commands use subagents for analysis.
- The analyst read-only boundary is prompt discipline: identical `## Constraints` / `## Report` text in all 14 analysts, pinned by `TestAgentReport` - and Bash can write regardless. Plugin agents ignore `hooks:` frontmatter by Claude Code design (documented, not a bug), so no agent-level hook can exist; a plugin-level PreToolUse hook sees `agent_type` and could enforce, but only together with Bash - deliberately not built (a Write-only hook guards one of two doors, errors on every Write with an older binary, and adds a fail-closed failure mode). The old read-only hook prototype no longer ships.
- **Agent report files (lets-1irms).** Task-path agents write to `.lets/reports/<run>/<name>.md` (one directory per run via `artifact-path kind=reports-<cmd> ext=dir`); the final text is only a `REPORT_WRITTEN` pointer. `agent-report op=collect` classifies MISSING/EMPTY/UNTERMINATED/UNREADABLE, reads every report in full, retries analysts once and reports gaps; `/lets:review` saves a `## Coverage` section and never ends without its Step 8 file. The implementer keeps `.lets/cache/run-...` and writes it itself. Members spawned through `member-run` get a REPORT_FILE per round (execute: `.lets/cache/run-...`; a standing team: `agent-report` `reports-team` directory recorded in the team file).
- **JSON envelope (`--json` subcommands).** Every `lets <sub> --json` emits a single JSON object, valid even on `ok=false` (partial-completion: `steps[]` + a typed `error`). **`SchemaVersion` is per-package** — `initcmd`/`updatecmd`/`worktreecmd` each own a `const SchemaVersion`, guarded by a `TestResult_SchemaContract` test; new `--json` packages copy `worktreecmd`'s shape. Mechanics + per-subcommand contracts: `cli/README.md` "## JSON envelope conventions".
- Interactive worktrees managed via `/lets:worktree` command. Hook prototypes `hooks/worktree-setup.sh.old` and `hooks/worktree-cleanup.sh.old` (deferred - caused agent auto-cleanup issues)
- Worktrees stored in `.worktrees/` at project root - `.lets/` symlinked for interactive sessions
- **Trunk-mode** — per-task opt-in via the `take-task` picker ("Stay on current branch"), detected at runtime by `HEAD == $LETS_MERGE_BRANCH` (no state file — survives compaction). In trunk-mode: `/lets:done` pushes + closes the task without a PR (same-source-target isn't a valid PR); every `.lets/` artifact is task-id-named via `artifact-path` (so there is no `.lets/plans/main.md` collision); `/lets:execute` soft-gates instead of hard-refusing on `$LETS_MERGE_BRANCH`. An exception to the "never edit the merge-branch" rule (see `plugins/lets/rules/lets-rules.md`). For quick docs/spec/small-fix work; mutually exclusive with worktree. Implementation: `lets-3o9d7`.
- **SessionStart hook** invokes `lets hook session-start` on every source (startup, resume, clear, compact), emitting an optional `## LETS Notice` (rules-drift check), a `## LETS Config` block and, for a plain non-English `LETS_LANGUAGE`, a closing response-language line (lets-xtugk). The workflow rules themselves do NOT travel through the hook — they live in `.claude/rules/lets-rules.md` (the uncapped project-instructions channel), copied there by `lets init` and frontmatter-version-tracked. Output mechanics, drift messages, and project-root detection: `cli/README.md` "## lets hook".
- The bash `session-start.sh` was deleted along with its yaml→env migration block - lets-p732a closed.
- **Compaction path** (lets-v59n8; verified on Claude Code 2.1.301) — after a compaction Claude Code re-loads CLAUDE.md and `.claude/rules/` (InstructionsLoaded `load_reason: compact`), so the rules survive natively, and SessionStart with source `compact` re-injects the Notice, LETS Config and the language line. The plugin wires no PreCompact hook: on an auto-compact its stdout reaches only the debug log, and on a manual `/compact` Claude Code echoes it into the `/compact` output - a second copy of LETS Config. `lets hook precompact` is a deprecated silent no-op for one release. A bounded stress case of the SessionStart body is pinned under Claude Code's 10,000-character hook cap (`TestRun_StressUnderHookCap`; over the cap the output moves to a file behind a 2,000-character preview and the closing language line is lost).
- User-facing skills: auto-discovered by Claude Code, appear in skill list, trigger on description match. Frontmatter description must NOT use YAML quotes.
- Internal skills: hidden from the user-facing `/lets:` autocomplete via `user-invocable: false` in the frontmatter (Claude Code supports this — see `code.claude.com/docs` skills frontmatter). The model can still use them when commands explicitly ask; commands invoke them via `Skill(skill: "lets:<name>", args: "...")`. Claude Code resolves the `lets:` namespace regardless of whether the plugin is loaded via marketplace install or `--plugin-dir` dev mode (no relative-path fragility). No accidental user triggering, no context cost until invoked. Current internal skills: `detect-task`, `actor-fetch-personality`, `session-snapshot` (file-primary shared snapshot used by `/lets:end` and by the `--session` snapshot-only flag of `/lets:end` and `/lets:note`; renamed from `pre-compact-note`), `session-boundary` (the ONE validated reader of a branch's session boundary - resolve, validate, report a trust level - read by `/lets:end` Step 1 and by `session-snapshot`; extracted so the ladder that fixed lets-370mx cannot exist in two drifting copies), `orient` (shared where-am-I / in-flight / next-up snapshot rendered for `/lets:status`, `/lets:start`, `/lets:backlog --fast`), `artifact-path` (the ONE resolver for every artifact written under `.lets/` - plans, reviews, snapshots, per-run report directories - task-scoped name + `-vN` on collision, lets-05c4s), `agent-report` (the ONE file transport for agent reports - `op=open|add|peek|collect`, used by every Task-path dispatcher), `member-run` (the ONE spawn / next / correct / dismiss path for a named member of any `lets:*` role; the caller's run record / team file holds `agent_id` and dismissals (lets-gbfpd) - the recorded id is the address, `ListAgents` only says busy, and `op=route` / `op=receive` pick a member and wait for its report (lets-mpjhc, lets-kwd6e) - model panel for implementers after the Start gate, spawn, SendMessage next brief or correction by file path, attribution by name; used by `/lets:execute` delegated runs, and the path lets-7dwc1 rebuilds `/lets:team` onto), `apply-fixes` (the ONE definition of `--fix` for `/lets:check`, `/lets:review` and `/lets:handoff` - the typed flag as write authorization, the verified and nothing-to-decide gates judged before any edit, the scope per mode, stop-and-name on a partial run, never a commit), `protocol-tracker` and `protocol-orchestrator-offer` (the lazy protocol skills - see "Rules layers" above).
- Commands define WHAT to do and orchestrate the flow. User-facing skills define full reusable flows (steps, user gates) that auto-trigger on natural language. Internal skills define shared procedures read by commands on demand. Commands delegate to skills for shared operations.
- Gate for new skills: extract only if (a) user-facing with standalone trigger value, or (b) internal logic duplicated in 3+ commands.

## Dynamic Workflow Assets (authoring standard)

A LETS command that orchestrates many agents can run them inside a Claude Code **Dynamic Workflow** (the `Workflow` tool) instead of the Task tool, so per-agent output stays off-context and only the aggregate enters the conversation. `skills/review-workflow/` is the **reference example**; `/lets:review --workflow` is the first consumer, `/lets:opinion --workflow` (the `opinion-workflow` skill) the second (CONDITIONAL adversarial-challenge stage; reuses the selected `lets:*` experts as cross-critics), and `/lets:backlog review --workflow` (the `backlog-workflow` skill) the third - Review-mode Phase 3 fan-out + Phase 4 aggregate off-context, NO Web Research stage (backlog review grounds in the project's own profile, so omitting web keeps the `--workflow` path equivalent to the standard Task path), and `/lets:research --workflow` (the `research-workflow` skill) the fourth - in-context decompose, then off-context per-sub-question web research (DEFAULT web subagent, not a `lets:*` agent - they lack web tools) → per-claim `lets:skeptic` cross-check → synthesize; the citations ARE the output. Mirrors review's find→verify (per-claim skeptic, additive flag-never-drop); the skeptic pass compares each claim against its siblings to flag contradictions (no separate deterministic pass). All opt-in; review/opinion/backlog/research `--workflow` live. `/lets:plan-workflow` ships the autonomous planning chain as a standalone **PREVIEW** command (dogfood across projects before folding into native `/lets:plan --workflow` - lets-jsw00). A `--fast` lever runs it lean (~7 agents vs ~15-25; collapse upstream panels + skip the heavy plan-review pass, quick plan-check kept), distinct from native `/lets:plan --fast` (orchestrator-only, no subagents). All multi-agent fan-out review/ideation commands (review/opinion/backlog/research) now have a `--workflow` path; `/lets:plan`'s equivalent is the standalone `/lets:plan-workflow` PREVIEW above.

**When a workflow is worth it.** A single fan-out that immediately hands back to a user gate is barely more than Task subagents - the workflow earns its keep only with a **multi-stage off-context chain** (fan-out -> reduce -> verify/judge -> aggregate) with NO user checkpoint between stages. Every checkpoint forces intermediate results back into context and breaks the off-context win. So: want to steer each step -> use Task subagents; want autonomous multi-stage off-context -> use a workflow.

**Layout & naming.** A workflow ships as a skill folder: `plugins/lets/skills/<name>-workflow/` holding `<name>.workflow.js` (the `.workflow.js` suffix is the marker) + `SKILL.md` (`user-invocable: false`; documents the `args` contract and states "this is a workflow asset invoked via `scriptPath`, not auto-triggered"). It is NOT a conversational skill - it is never `Skill()`-invoked.

**Invocation contract.** The command builds `args`, then `Workflow({ scriptPath: "${CLAUDE_PLUGIN_ROOT}/skills/<name>-workflow/<name>.workflow.js", args })`, then persists the returned aggregate. `${CLAUDE_PLUGIN_ROOT}` is substituted at command-load time (the markdown carries the literal path). Script = static logic; `args` = all per-run data. The workflow runs in the **background** (returns a `runId`; the command flow resumes on the completion `<task-notification>`).

**The 6 file sections** (canonical order): META (`export const meta`, pure literal) · ARGS (defensive parse) · SCHEMAS · pure logic · prompts · orchestration -> `return`.

**6 conventions** (every workflow file):
1. Defensive `args` parse first line: `const input = typeof args === 'string' ? JSON.parse(args) : (args || {})` (the runtime may deliver `args` as a JSON string).
2. `agentType: "lets:<name>"` to reuse plugin agents - never duplicate an agent's logic in the script.
3. `schema` wherever structured output feeds aggregation (forces StructuredOutput even when the agent's own format is markdown).
4. args = dynamic data, script = static logic.
5. `meta.phases` titles match the `phase()` calls.
6. Keep-in-sync comment vs the command's prose spec - logic that exists in BOTH a no-workflow path (prose) and the JS lives twice; mark it.

**Runtime must-obey** (empirically verified - lets-odo4o):
- No filesystem - the script returns data; the command persists files.
- No sibling `import`/`require` - all logic stays inline (`await import('./x.js')` fails).
- No `Date.now()` / `Math.random()` / `new Date()`.
- Top-level `await`/`return` are used (the runtime wraps the body) - so the file is NOT Node-importable and has no clean unit test. Validate behavior with a live smoke test; verify pure-logic correctness by a deterministic check of a copy if needed.
- `agent({agentType})` resolves against the agents loaded at **session start** - a newly-added plugin agent is unavailable until the next session/reload. Make the script degrade gracefully when an agent can't resolve (e.g. a failed verifier -> keep the finding, flag it; never crash, never silently treat as done).

**Model + stage budget (plan-workflow only).** `/lets:plan-workflow` shows a Step 2.5 budget panel before launch and passes `model` (one model for every spawned agent, via `agentOpts`) and `budget` (per-stage caps, via `capStage`) in `args`; the script can only lower a stage, clamps each value to MIN/MAX, and reports `counts.budget.drops` on every return. `agent({model})` overriding an agent type's own pin (`lets:explorer` -> `sonnet`) is verified live. It stays local to plan-workflow because only `plan.workflow.js` hardcodes its stage counts - review/opinion/backlog/research already receive their agent lists in `args`, backlog launches without a gate by design, and review's verify pass must stay mode-independent. Extract a shared budget skill when a second command gets a concrete budget request; `planbudget_test.go` pins the wiring (every `agent(` in `agentOpts(`, every fan-out through `capStage(`, every return with `budget: budgetOut()`, a panel row per stage).

**`scriptPath` confirmed** to load an arbitrary plugin-path file (not only session-dir scripts). Reusable runtime facts also live in `bd memories` (key `claude-code-workflow-runtime-constraints-*`).

## Release Flow

Two-phase tag-driven pipeline (bump → review → tag → distribute). The full maintainer ceremony — phase-by-phase commands, prerelease handling, recovery, and the design rationale (why two phases / bash / goreleaser, prerelease CHANGELOG handling) — lives in `RELEASING.md` (see its `## Rationale` for the "why").

**Source-tree version invariant:** a single semver string spans `plugin.json`, `marketplace.json`, and the frontmatter of `lets-rules.md` + the shipped `tracker-{beads,none}.md` adapters (`tracker-TEMPLATE.md` and `*.board.md` pin `0.0.0` by design; the binary version derives from the git tag via ldflags). Drift between any of these fails `verify-versions.yml`. Bumped once per release at ceremony time — never per change.

## File Storage

All plugin-generated files go to `.lets/` (gitignored). Never use `/tmp` or other external paths.
This includes hook debug logs, temp files, and any runtime artifacts.
**WARNING:** Always use `.lets/` (with dot prefix), never `lets/`. The dot is easy to miss in manual paths.

```
.lets/.env               # Per-project settings (LETS_LANGUAGE, LETS_MERGE_BRANCH, LETS_PR_FLOW, LETS_TRACKER, LETS_LAUNCHER, LETS_RULES_SCOPE, LETS_LAUNCH_MODE, LETS_GATES)
.lets/.env.example       # Reference defaults — generated each `lets init` from canonical letsconfig.Keys defaults via renderEnvExample(). Not used by the hook; it's a user-facing template
.lets/.env.bak           # Single backup written by `RegenerateEnv` before mutation. Plugin-owned: user-created files at this path are silently overwritten — copy elsewhere for permanent backup
.lets/sessions/          # Session snapshots `{date}-{HHMM}-{task-id|branch-6hex}-snapshot[-vN].md` (file-primary ## RESUME via session-snapshot; path from artifact-path - legacy `{TS}-{branch}.md` files stay, readers fall back to them); .task-<slug> state file (task/start/session boundaries, validated cache - lets-dsdmp; written only through `lets worktree task-state`, and may carry `origin: branch|dir` until the first claim and `orc:` - a worker chat's orchestrator binding, never on the merge-branch; `session_proof: <sid> <pid> <started-ms>` - the process that held `session:`, written only by Go beside every Go `session:` write, the proof a `/clear` carry needs (lets-gbfpd)); .review-restore-<session-id> (PR-mode branch/stash restore - fields `ref:`/`pr:`/optional `stash:`; session-keyed so parallel worktrees never share it; kept on any non-clean outcome, and globbed by the review gate + `/lets:start` so a stray is surfaced rather than stranded)
.lets/reviews/           # Saved review reports `{date}-{HHMM}-{task-id}-review-{local|branch|pr-N|plan}[-vN].md|json` (path from artifact-path, lets-05c4s)
.lets/plans/             # Implementation plans `{date}-{HHMM}-{task-id}-plan[-vN].md` (path from artifact-path), and idea documents `{date}-{HHMM}-{task-id}-idea[-vN].md` from `/lets:plan --idea` (never picked up by `/lets:execute`). EVERY artifact writer goes through `skills/artifact-path` - `.lets/` is one symlinked dir shared by all worktrees, and a hand-built `{date}-{kind}` path overwrote another session's file (lets-05c4s)
.lets/handoffs/         # Hand-off briefs `{date}-{HHMM}-{task-id}-handoff[-vN].md` (artifact-path) and their siblings: `-report.md`, `-events.jsonl`, `-stderr.txt` (lets handoff), `-agent-report.md` / `-agent-report.done` (the receiving agent, --send). 0600, never overwritten
.lets/cache/peer-msg/    # <msgid>.txt - transient v0.10.0 peer-message handoffs (0700): created by the deprecated `lets peers frame`, consumed by the deprecated `lets peers tell` (removed next release)
.lets/sessions/peers/last/  # <hex of the name>.last - last-seen session / pid / scope of an orchestrator, one file per name, refreshed by every `lets peers who` / `orchestrator` for live orchestrators; read by /lets:hub wake and ask-ro
.lets/cache/ask-ro-mcp-empty.json  # {"mcpServers":{}} (0600) - the empty MCP config `lets peers ask-ro` launches with
.lets/sessions/peers/    # <session-uuid>.role - legacy files an older binary wrote, never read or written (the /clear proof is the task-state session_proof: key, lets-gbfpd); the dir holds last/ (above)
.lets/execution/         # Execution state (PR review: pr-{number}/, team run records: team-*.json; members-<scope>.json - the retired `lets members` registry, legacy, never read or written (lets-gbfpd))
.lets/reports/           # Agent reports - one directory per dispatching run `{date}-{HHMM}-{task-id}-reports-{command}[-vN]/{name}.md` (agent-report skill, path from artifact-path); kept, never cleaned; input to the command's own artifact, never the deliverable
.lets/teams/             # Standing-team files <callsign>.md (from plugins/lets/templates/team.md via `lets worktree team-init`, never overwritten; Go reads only `worktree` + `git_dir` + `lead_name` + `agent_command` / `orca_agent`) and the team's own notes
.lets/hooks/             # Optional project hooks: team-setup (run on the owner's yes when a team worktree is created or reopened; fills the team file's Workspace) and team-teardown (on the owner's yes at disband)
.lets/cache/             # Cached data (usage stats; update-check.json — /lets:update latest-release lookup, 1h TTL; task-status — rich statusline task cache id|title|notes|iso, self-refreshed via detached bd show, 90s TTL; pipeline-state-<task-id> — autonomous-pipeline phase marker <id>|<phase>|<iso>, per-task so parallel worktrees don't clobber, written by plan-workflow/execute --auto; released-<task-id> — `lets worktree release` marker `<id>|<branch>|<iso>|dirty=|unpushed=`, read by `/lets:start --main` Reopen; delegated-run-<task-id>.json — a delegated /lets:execute run record; chunk-/correct-/run-* — its briefs, amendments and reports; member-<callsign>-<name>.md — a standing-team member's spawn brief; integrate-<run>-<chunk>.patch — `lets integrate`'s staged patch)
# Worktrees (outside .lets/ to avoid circular symlinks):
# .worktrees/            # Interactive worktrees only (agent worktrees use native Claude Code behavior)
# .lets.pre-adopt[-N]/   # At a worktree root: a cache-only real .lets that `lets worktree adopt` moved aside (safe to delete)
# <git-common-dir>/lets-integrate/  # `lets integrate` temp worktrees <run>-<chunk> + their <run>-<chunk>.owner markers (removed with the worktree)
```

Workflow rules live OUTSIDE `.lets/` because they belong to Claude Code's project-instructions channel:

```
.claude/rules/lets-rules.md  # Workflow rules (copied from plugin by `lets init`, frontmatter-versioned, customizable - tracked in git per project's choice)
.claude/rules/tracker-<name>.md        # Active tracker adapter - plugin-MANAGED like lets-rules.md (installed/cleaned by `lets init`, synced by `/lets:update`); NEVER edit the installed copy
.claude/rules/tracker-<name>.board.md  # Board profile - USER-OWNED (scaffolded once, never overwritten); edit freely; gitignored when it holds private board data
```

User-scope install adds two machine-global files:

```
~/.claude/rules/lets-rules.md  # Global rules — first written by `lets init --user`, then a cache of the running plugin kept by the SessionStart hook (rulescache; a hand-edited copy is saved to .bak[-N] first); /lets:update only reports it
~/.lets/cache/rules-cache.json # rulescache key: sha256 + version + source path of what was written; + rules-cache.lock
~/.lets/.env                   # User-level defaults (LETS_LANGUAGE + LETS_LAUNCHER only); project .lets/.env overrides per key; + ~/.lets/.env.bak
```

**NEVER edit `.claude/rules/lets-rules.md` (or `~/.claude/rules/lets-rules.md`) directly.** They are **installed copies**, plugin-managed. Only the canonical source `plugins/lets/rules/lets-rules.md` is edited. The project copy is rewritten by `/lets:init` / `/lets:update` (and only via those plugin-managed paths) when the plugin's frontmatter `version` is bumped — this dogfoods drift detection live; the global copy is rewritten by the SessionStart hook whenever its content differs from the running plugin's (lets-tg008). Workflow: edit source -> bump source `version` (at release ceremony) -> commit -> release -> end user (or maintainer) runs `/lets:update` (or `/lets:init`) -> installed copy refreshed. Editing an installed copy directly bypasses drift testing and silently desyncs from source.

## Naming Convention: `LETS_*`

All plugin configuration uses the `LETS_*` prefix (UPPER_SNAKE_CASE). The prefix removes ambiguity in command instructions - `LETS_PR_FLOW=github` is unambiguously a parameter, while `github` could be the platform name.

### LETS Config keys

| Key | Source | Purpose |
|---|---|---|
| `LETS_PROJECT_ROOT` | Auto-injected by SessionStart hook | Absolute path to project root |
| `LETS_LANGUAGE` | `.lets/.env` (falls back to `~/.lets/.env`) | Default response language |
| `LETS_MERGE_BRANCH` | `.lets/.env` (falls back to the repo's origin default branch, else `main`, when unset) | Target branch for merges and PRs |
| `LETS_PR_FLOW` | `.lets/.env` | `github` \| `bitbucket` \| `local` - which PR workflow to use |
| `LETS_TRACKER` | `.lets/.env` | Task tracker **adapter**: `beads` (default) \| `none`. Selects the one `.claude/rules/tracker-<name>.md` that `lets init` installs (one-adapter-file platform, lets-5d48z). `none` supersedes lets-nwwkj; the verb-resolution rule lives in the internal `protocol-tracker` skill (was `lets-rules.md` "Tracker Adapters"). See `docs/trackers.md` |
| `LETS_LAUNCHER` | `.lets/.env` (falls back to `~/.lets/.env`) | Worktree launcher: `terminal` (default - print `cd … && claude`) \| `cmux` (cmux workspace via `lets cmux`, macOS only) \| `tmux` (tmux window/session via `lets tmux`, Linux + macOS) \| `orca` (Orca worktree via `lets orca`; the switch for every Orca integration: launcher, card status, `/lets:hub`, the team orca backend). A preference, not a guarantee - each falls back to `terminal` (orca: to cmux, then terminal) when its binary/platform is absent. Validated against a whitelist pinned to the registered subcommands (`lets init --launcher=<unknown>` is rejected) |
| `LETS_RULES_SCOPE` | `.lets/.env` | Where this project's workflow rules come from: `project` (own `.claude/rules` copy - default) \| `user` (deliberately no project copy; rules from the global `~/.claude/rules`). `/lets:update` reports `delegated` instead of re-creating a `user`-scoped project's missing copy. Fail-safe: any value other than `user` is treated as `project`. NOT injected into subagent context (project-only, like the others) |
| `LETS_LAUNCH_MODE` | `.lets/.env` (falls back to `~/.lets/.env`) | How the sessions LETS launches (workers, a team's lead) start: `default` (plain `claude`) \| `auto` (`--permission-mode auto`) \| `all-in` (`--permission-mode bypassPermissions`). Empty means `default` and stays unset - LETS never writes a value on its own (a regen keeps it empty, so the `~/.lets/.env` value applies and `/lets:start --main` reminds); a `--launch-mode` / `--auto` / `--all-in` flag on `/lets:worktree create` or `/lets:team run` beats it. `all-in` only when the owner sets it or types the flag - never a fallback; set it to the class your orchestrator runs in (lets-d3wu3). A separate axis from `LETS_GATES`: the launch mode is the harness permission class, never LETS approval |
| `LETS_GATES` | `.lets/.env` (falls back to `~/.lets/.env`) | Which actions ask the owner first - LETS's own questions: `strict` (every commit and confirm, as before) \| `standard` (default - irreversible or outward only: the core `## Gates` table) \| `minimal` (as standard, except a push / PR outside `/lets:done` asks only for the merge branch or another task's branch, and the task's own green PR merges inside `/lets:done`). Implicit default like `LETS_LAUNCH_MODE` (a regen keeps it empty); `letsconfig.ResolvedEnv` normalizes empty / unknown to `standard` before the hook injects it, and an unknown raw value gets a Notice. Written only by `/lets:init` (`--gates`) or the owner's edit; a session that lowers it hits a hard stop (lets-wu2xl) |

User-level fallbacks: the hook overlays `~/.lets/.env` UNDER the project `.lets/.env` (project wins per key; only non-empty project values mask).

### Surface forms - where to use which form

The same `LETS_FOO` value appears in different surface forms depending on context. The orchestrator (model) handles substitution; subagents do NOT receive LETS Config injection and need explicit substitution before Task() launch.

| Surface | Form | Resolved by | Applies to |
|---|---|---|---|
| Bash block (model executes via Bash tool) | `LETS_FOO=$(...)` then `$LETS_FOO` | local shell - assign at top of every block (each Bash call is a fresh shell) | **only `LETS_PROJECT_ROOT`** (computable in-shell via `git rev-parse`) |
| Bash command snippet inside markdown (template the model runs) | `{LETS_FOO}` placeholder | orchestrator substitutes literal value before running | all keys EXCEPT `LETS_PROJECT_ROOT` (which uses bash assignment instead) |
| AskUserQuestion option `label`/`description`/`question` text | `{LETS_FOO}` placeholder | orchestrator substitutes literal value before tool call | all keys |
| Orchestrator prose, section headings, code comments | `$LETS_FOO` | orchestrator reads from injected LETS Config (reference only, never displayed) | all keys |
| Subagent prompt template | `{LETS_FOO from LETS Config}` | orchestrator substitutes literal before Task() call | all keys |

**Important note on `LETS_PROJECT_ROOT`:** the value injected in `## LETS Config` is for prompt-text reference and orchestrator substitution - it is NOT a shell variable available in Bash tool calls. Every bash block that uses the project root path must assign locally:

```bash
LETS_PROJECT_ROOT=$(git rev-parse --show-toplevel)
mkdir -p "$LETS_PROJECT_ROOT/.lets/sessions"
```

`LETS_PROJECT_ROOT` is the **only** key computable in-shell (via `git rev-parse`). Other `LETS_*` keys (`LETS_MERGE_BRANCH`, `LETS_PR_FLOW`, `LETS_LANGUAGE`, `LETS_TRACKER`, `LETS_LAUNCHER`, `LETS_RULES_SCOPE`, `LETS_LAUNCH_MODE`, `LETS_GATES`) have no shell-side derivation - inside bash snippets, use the `{LETS_FOO}` template form so the orchestrator substitutes the literal value before running. Trying to use `$LETS_FOO` in a bash block (without local assignment) yields empty string - silently wrong commands.

### User config file

`.lets/.env` is `KEY=VALUE` format with comments **above** keys (NOT inline - inline comments would pollute the model's context after the hook strips full-line comments). Migrated from legacy `config.yaml` to `.env` by `lets init` (`cli/internal/initcmd/migrate.go::MigrateYamlToEnv`). The yaml→env auto-migration in the SessionStart hook has been removed (lets-p732a closed). Legacy yaml deprecation removal tracked in `lets-q8xtk`.

**NOT FOR SECRETS.** The SessionStart hook injects the file's whitelisted `LETS_*` values into model context every session. Tokens, passwords, and API keys belong elsewhere: `gh auth login` for GitHub, OS keychain for general secrets, tool-specific credential files (e.g. `.beads/.env` for beads). The whitelist filter in `cli/internal/hook/sessionstart/sessionstart.go` calls `letsconfig.Names()` to get the canonical `LETS_*` keys, so unknown keys are filtered - but file mode is 644 (world-readable on disk), so secrets in `.env` would still be exposed locally. Do not add secret keys. The same applies with a strictly larger blast radius to the user-level `~/.lets/.env` - it is injected in EVERY project the user opens.

### Adding a new config key

Single source of truth: `cli/internal/letsconfig/keys.go::Keys` (canonical metadata) and `cli/internal/initcmd/render.go::Prefs.AsValues()` (Prefs↔Key wiring). The full step-by-step recipe — `Keys` entry, `Prefs` field, cobra flag, `init.md` AskUserQuestion, what's auto-derived, what to document — lives in `CONTRIBUTING.md` ("### Adding a new config key").

## Dependencies

- beads plugin (task tracking)

## When Adding/Modifying Commands, Skills, or Agents

**Rules-file rule:** edits go ONLY to `plugins/lets/rules/lets-rules.md` (the canonical source). NEVER touch `.claude/rules/lets-rules.md` (installed copy) — that file is refreshed exclusively via `/lets:init` or `/lets:update` after a source bump. This is intentional: we eat our own dogfood for drift detection.

**Unreleased-features rule:** don't document `[Unreleased]`-only features in this file as if they already shipped. If you describe a feature that isn't in a tagged release yet, tag it `(ships in vX.Y)` (or `(merged; ships next release)`); drop the tag at the release ceremony when the version moves forward. This avoids the trap QA hit on v0.5.0 — descriptions of features that lived only in `[Unreleased]` CHANGELOG read as available, sending testers down dead-end paths.

**Full file-sync checklist, the Response Footer / LETS box output spec, and the per-command checklist live in `CONTRIBUTING.md` ("## Editing commands, skills, agents, config keys").** Modifying a command/skill/agent? Read it — it names every file that must stay in sync: the Skill Quick Reference in CONTRIBUTING "## Rules layers" (plus the core flow table when a phase changes), every `agents/*.md` `## Constraints` line, the `/lets:install` skill tables, `README.md`, the `end.md`/`done.md` session-id channels, and the Go-subcommand wiring.