# Protocol: why a Pi agent team is organised this way

This document is the "why" behind the team protocol section of `AGENTS.md`. The rules themselves are short; the
reasons are what stop a future you (and future agents) from treating them as bureaucracy to route around.

> **The belief layer lives in [philosophy.md](philosophy.md)** (8 judgement standards plus their failure modes). This
> file explains "why the rules are what they are", that one explains "why this role should think this way". When the
> two disagree the creed wins, and the rule gets fixed afterwards.

---

## 1. Roles: the PM is not "a smarter agent", it is the only role allowed to merge

| | PM (orchestrator) | worker agent |
|---|---|---|
| Context | long-lived, across tasks, holds the roadmap and the decision history | one stretch per task, focused on implementation |
| Output | briefs, decision log, verification records, merges | code, tests, reports, PR/MR |
| Authority | the protected branch, forge writes, repository settings | its own task branch |

Splitting "writing code" from "judging whether the code is acceptable" across different sessions is what creates
**independent verification**: the agent's report is a **claim**, the gate results the PM obtains on an independent
worktree are **evidence**.

> A real CEP lesson: a report claimed 28/28 tests passing, the PM re-ran it and 4 specs failed (the cause was a value
> import used to import a pure type under `verbatimModuleSyntax`). Conclusion: a report ≠ evidence, verification has
> to be institutionalised.

## 2. The evidence model (three layers, each more trustworthy than the last)

1. **Claim**: the conclusion an agent writes in its report (least trustworthy).
2. **Checkable artefact**: commits, test files, log files, diffs.
3. **Independent verification**: the result the PM gets by running the same commands on **another checkout** (most
   trustworthy).

Any "passes/done" has to land in layers 2 and 3. The acceptance commands in a brief plus the PM's `team review` are
the implementation of that chain.

## 3. Isolation: worktree + branch + a long-lived cwd

- **One worktree per agent** (long-lived), with a new branch per task inside it.
- Why long-lived: a Pi session is keyed by **cwd**. Move the worktree path and the old session cannot be found again
  (CEP has been there).
- Why not "one worktree per task": sessions fragment, and both follow-up questions and resuming from a checkpoint get
  harder; a long-lived worktree lets the agent keep its memory.
- The main worktree (main) belongs to the PM alone: merges, verification and documentation happen there, so nobody
  fights an agent over the workspace.
- **A merged branch is deleted at once.** Landing is `merge --squash` + commit + push, then `close`, then
  `git branch -D <branch>`: the commit on `main` is the deliverable and the branch is only the vehicle. Nothing of
  value lives only on a branch, and keeping them turns the branch list into an archeology site (134 branches removed
  in one pass on 2026-10-18, none of them an ancestor of `main` because squash merges leave no ancestry).

## 4. The wake-up loop: an asynchronous agent must be able to wake the PM by itself

The PM cannot poll (wastes context, high latency) and should not wait for the user to relay things. The mechanism
(`extension/team-notify.ts`):

```
agent turn ends (Pi's agent_settled: the point where it will not continue on its own)
   ├─ append <root>/<docs>/inbox/<agent>.md      ← durable, the PM can read it at any time
   └─ enqueue into <root>/.pi/team/state/outbox/ (one immutable entry) and drain it
```

Guards and limitations:
- It only fires when the cwd is under `<worktrees>/`; when the window name equals the PM window name it is skipped (to
  prevent a self-triggering loop).
- **In a linked worktree Pi does not auto-discover the project-local `.pi/extensions/`**, so `team dispatch` must load
  it explicitly with `-e <skill>/extension/team-notify.ts` (measured on CEP).
- **The knock goes through the delivery guard** (`delivery-guard`, see `troubleshooting.md` §3): the extension calls
  `team outbox enqueue --dedup <its own key>` + one `team outbox flush` instead of typing, so a notice can no longer
  be concatenated with what the human is typing. On a dirty PM box the entry waits in `state/outbox/`; the extension
  never falls back to `send-keys`, and a failed enqueue is logged while the inbox line stays.
- The inbox is transient state (gitignored); the durable record is still the report + verification + decision log.
- **Settles inside one stretch of work become one line, not one line each**: consecutive notices with the same
  merge key (seat, task, state class, `awaiting=` set) fold into the line at the end of the inbox file, which then
  carries `×N first=<ISO> last=<ISO>` and the **field values of the last event** (`tip=`, `branch=`, `uncommitted=`).
  The line is a **window summary**, not the seat's current state: `last=` names the event whose revision it
  describes, and the run's earlier events are read from the detail file with `team inbox <agent> --detail` (raw
  records in `state/notify.log`, bounded by `TEAM_NOTIFY_DETAIL_MAX` and rotated once). A different key, a manual
  notice in between, or a record write that failed starts a new line; a byte-identical briefing inside
  `TEAM_NOTIFY_DEDUP_SEC` is still suppressed outright.
- **A notice names the revision it describes** (R2/P139; width and branch proof fixed by P160): the durable inbox
  line and the knock payload carry `task=<ID> tip=<12-hex>` — the **first twelve hex characters of the branch tip,
  truncated by the sender itself** (`git rev-parse HEAD | cut -c1-12`; the extension's `TIP_WIDTH`), never a Git
  abbreviation whose width follows `core.abbrev` or the object count, so one HEAD has one spelling in both senders.
  The stamp is written only when the branch can **prove** it is a task: `task/<ID>-…` and the ID exists in
  `state/<agent>.env` (`task=<ID>`), on the board, or as a task brief. An ordinary seat branch (`agent/<seat>`), a
  protected-branch or detached worktree and a manual `team notify` outside a worktree stamp nothing — the identifier
  is never invented from a branch or seat name.
- **The receiver never needs a checkout**: the notice is stale when the board row is `done`/`closed`, or when the
  HEAD recorded in `docs/team/reviews/<ID>.md` and the tip name the same revision — one is a prefix of the other
  (the record `team review` writes today is nine hex wide; a hand-written one may carry the full id; the twelve-wide
  wire form is what keeps the decision stable across Git settings).
- For a proactive notification (blocked, someone else's bug) use `team notify <agent> "<one line>"`, which arrives
  earlier than the automatic one. The **recipient** names the durable inbox file (`docs/team/inbox/<agent>.md`);
  the PM is still the **knock destination** — the queue entry's declaration and the wake pointer both name that
  same recipient file, and a durable append that fails exits non-zero without a wake.
- **The turn-end notice separates three endings**, the same three turn-end states the worker prompt names: a
  **completed** turn with no job of that session still running is `[auto]`; the same turn with live jobs is
  `[auto·suspended]`, carrying `awaiting=<id>[,<id>…]`; an **unfinished** turn stays `[auto·interrupted]` (with
  `awaiting=` too when its jobs are alive). A suspension omits the `⚠ 未提交` prefix — it is not a delivery claim —
  while `uncommitted=<n>` stays. The three states are **delivered**, **hard-blocked** and
  **suspended on background jobs**, and the lane rule is the suspend default: `team_bg_run` starts a long job and
  the turn ends; the completion wake-up is the normal harvest point; in-turn waiting is the bounded exception,
  only inside the inline-wait budget (about 2 minutes), when the result blocks the next step and nothing else can
  advance. A seat that suspended and then died needs no new patrol clause: an unfinished task with no live window
  is already pending work (§8).
- The **other** injected extension is `extension/team-bg.ts` (M27) — not notification, but the team's own
  background lane: `team_bg_run` starts a detached job (bounded `state/bg/<id>.log`), `team_bg_wait <id>` harvests
  it, a harvested job stays silent, several finishing jobs arrive as one message, and every turn end appends
  `settled-with-unharvested=<n>` to `state/bg.log` (runbook: `workflows.md` §E2).
  - **The identity record** (P159): the extension writes `state/bg/<id>.job` **before** it returns the id — `id`,
    `pid`, `pgid`, `start` (the start-time fingerprint from `/proc/<pid>/stat`), `cwd`, `log`, `cmd`.
    `team bg list` prints one row per record of **this** project's state directory and signals nothing;
    `team bg stop <id>` signals exactly the recorded job and prints what it signalled. Identity is
    `(pid, start-time)`: the pid must be alive **and** the fingerprint must match, so a reused pid is refused
    (exit 5) instead of killed. With `pgid == pid` it signals the process group `TERM`, then `KILL` after
    `TEAM_BG_STOP_GRACE` seconds; with a different group it signals the pid alone and says descendants were not
    reached. One `stop` line lands in `state/bg.log`; exit codes are 0 stopped-or-already-gone / 2 usage /
    3 no record / 4 malformed record / 5 identity mismatch / 6 still alive. Every refusal signals nothing, and the
    lane never searches by name, command line, process tree, another worktree or another project.
  - **The signal gate** (P159/P167; the shape behind D37/D57/D72): the launch prefix puts `scripts/shim` first on a
    window's `PATH`, where `pkill`, `killall`, `pgrep` and `pidof` are the gate. It classifies the **whole argv**,
    never the presence of one flag. `pkill` and `killall` refuse every selecting form (`-f`, `-x`, `-P`, `-u`,
    `killall <name>`). `pgrep` passes only two explicit positive scalar IDs (`-g N`, `-P N`) and the three
    full-command count spellings (`-fc P`, `-f -c P`, `-c -f P`) that return a number rather than a PID list;
    `pidof` selects by name by definition, so only its informational token passes. Everything else is refused with
    exit 64 and an empty stdout — default name selection, a PID-producing pattern, owner selection (`-u`/`-U`/`-G`),
    zero or implicit IDs, lists, inversion, added predicates, unrecognised spellings, `pidof` selection, and any
    inherited environment token. A refusal prints the tool, its full argv, why a name or a command-line pattern is
    not an identity, and the safe routes (`team bg list`, `team bg stop <id>`, `kill <recorded pid>`), and it
    resolves and executes nothing. An allowed call passes through to the real executable of the **same name** —
    the inherited `TEAM_SIGNAL_REAL` pin, normally the real `pkill`, never routes a `pgrep`/`pidof` call — with
    argv, stdout, stderr and exit status preserved. A `-g`/`-P` query is a diagnostic, not proof: the PIDs it
    prints were not recorded by any spawner, and a count is not a PID, so neither is signalling authority. The gate
    deliberately does **not** wrap `kill`, `ps` or `fuser`, and it is a `PATH` shim: absolute paths
    (`/usr/bin/pgrep`), a shell without the gate directory first, shell functions and any other source of a PID
    list stay outside its reach. That half is held by the static lint over the repository's own scripts and
    fixtures (`tests/signal-lint.pl`: red on a name/pattern selection, clean on `kill "$pid"`). Every intercepted
    call appends one line to `state/signal-calls.log` (`TEAM_SIGNAL_CALLS_LOG`), and a refusal is also copied byte
    for byte into `<log>.forensics` so it outlives the call log's rotation.

## 4a. Two signal entries: `reap` the orphan, `interrupt` the turn

The tool has exactly two entries that end a *process*, and they answer different questions. `team reap` ends an
**orphan**: the process is alive but its pane is gone, so no turn is running and nothing is waiting for it
(troubleshooting §28). `team interrupt <seat>` ends the **turn of a live seat** — the window is alive, the agent
process is the one its assignment record names, and the seat is stuck inside one long tool call, which is exactly
why a queued message never reaches it: the wake-up loop delivers at turn end (§4). `team resume` is neither: it is
the way **back** into the same session, the step after either entry, never a third way to signal anything.

What the two entries share is the discipline, not the target: both pick the process from a **recorded pid**
(`reap`: the host census; `interrupt`: the seat's own window → pane → pid → identity and start time), both
re-verify immediately before the signal, both send `SIGTERM` → bounded wait → `SIGKILL`, both take `--yes` on the
same command line as the only authorization, and both append one audit line per authorized signal. `interrupt`
additionally requires `--reason` and writes it into the seat's thread and inbox **before** the signal: an
interrupted seat has to learn why it was stopped, and that entry is part of its reading order on resume.

Never swap them — an interrupt aimed at an orphan's pid is refused (no window), and a reap aimed at a live seat is
refused (its pane is alive; `reap` only judges orphans); each refusal names the other entry. Both are turn/process
entries only: `interrupt` leaves the window as a live shell, leaves the worktree, the session file and the
assignment record untouched, and does not touch the seat's detached background jobs (§4's `team-bg` lane).

## 4b. Key replies: publish the body before you answer

Publishing the body of a key conclusion is a **standing duty of the PM**, not a licence for answers that merely look
memorable: it travels with the tool, so it holds in every project and on every machine. The wake-up loop carries a
*pointer* (`inbox/<agent>.md` plus a knock) and the report carries the *process*; neither carries the **body**, and
both are transient or post-hoc — the inbox is gitignored, and a report is written after the work. **The reader
decides what is published**: whenever the reply is written for the user — a landed milestone, an answer to a
question, a status or progress report, a conclusion or a ruling, a decision you ask the user for, a delivery or
verification summary, however short — publish the **complete reply** — the substance the user needs to read, not a
play-by-play of the work — with:

```
team reply post --from <seat> --title "<short single line>" --stdin <<'EOF'
… the exact body you are about to send …
EOF
```

**The reader decides, so ask "who is this reply written for?"** A reply meant for the patrol or the watchdog, for
another seat, or for another project's PM stays in its own channel, and a receipt a tool generated is not a reply at
all. Length never decides: a one-line answer or a short progress report meant for the user is published in full.
A reply is never held back because of its kind: a status or progress report, a delivery summary and a decision
request are published exactly as a conclusion is, and reading like process never makes a reply unpublishable.
When the reader cannot be settled, treat the reply as meant for the user rather than risk the user missing it.

A batch of user-facing replies may be one reply. Then send the answer normally. Publication **never gates the
answer**: the same body goes out whether the post
reported `saved` (0), an input error (2), storage unavailable (4), a busy writer (5, the lock wait is bounded) or
`unconfirmed` (6, the entry is visible but its durability could not be confirmed — never re-post it automatically).
Failures are diagnostics on stderr; they must not be concatenated into the answer, and they must not stop it.

What a receipt does and does not mean:
- **A saved receipt is project-local durable publication only.** It is not user delivery, not approval, not read
  status, and not a refreshed console. Nothing else changes state: publishing creates no pending work, no BOARD row,
  no outbox/inbox entry, no patrol wake-up, and no confirmation/reminder/approval lifecycle.
- **No new configuration key.** The capability is part of the tool, not a project setting.
- **Storage is project state, outside the published tree**: `<main_root>/.pi/team/state/key-replies/` holds a
  fixed-size commit index plus the immutable originals (`team paths` resolves the root; the store never lands in
  `docs/team/` or in a checkout). `list` / `show` / the console's summary are **observation-only**: they never
  create, repair or lock anything, and a missing store stays missing.
- **Read it back with the tool, not by hand**: `team reply list [--before <no>] [--json]` pages the numbered slots
  (newest first), `team reply show <no>` prints safe display text, `team reply show <no> --raw` exports the original
  bytes to a non-TTY stdout, and the console's page 5 renders the latest reply plus the numbered history. Flags and
  exit codes are in `team reply --help`.
- **The honest limit**: the gate holds only that this duty is shipped on the instruction surfaces (the injected PM
  prompt template, this section, the command table) and that the example command printed there is accepted by the real
  parser. No tool can verify that a live PM follows the duty — whether a given reply was meant for the user, which
  conclusions a PM published, and whether the body matched the answer, stay unprovable from here.

## 5. Task briefs: written for "a weak model without context"

The `routine` category usually runs a cheap, fast model (e.g. `opencode-go/deepseek-v4.1-flash`). It **will not
correct an ambiguous requirement on its own**, so a brief has to be self-contained:

- Background: why this is being done, where the relevant docs are (do not paste large chunks of code).
- Deliverables: file paths plus key signatures/behaviour, item by item.
- Boundaries: spell out what **not** to do (this prevents more drift than saying what to do).
- Acceptance: **copy-pasteable commands**, plus the report requirements.
- A fixed report format (deliverables/evidence/deviations/next steps), so the PM can read it mechanically.

## 5b. The change is the assignment unit (four dispatch guards)

`1 change : N tasks` — the change (proposal, design, delta, tasks, archive) is the dispatch unit, and a task is one
batch or one phase inside it; several briefs, agents or apply batches may share a change. The brief's header is the
foreign key, and `team dispatch` reads it **before it opens a window**:

1. **One change id.** `change:` holds exactly one token (or `-`). A comma list, two whitespace-separated ids or a
   second `change:` line is refused with the offending line; there is **no override** — a task that implements two
   changes is a mis-dispatch, not a preference, and the old "silently use the first line" behaviour is what this
   rule removes.
2. **A change-less brief declares its anchor.** With `change: -` the brief must either name a `specs:` entry that
   resolves (`<capability>[#<requirement>]` in `openspec/specs/<capability>/spec.md`) or say out loud
   `anchor: none (infra) — <reason>`. An unresolvable capability, a missing requirement or a bare `none (infra)` is
   refused with both accepted forms and the path that was looked for; `--force` proceeds with a warning and one
   audit line. This is policy B: disagreement with a rule is expressed by anchoring it, not by silence.
3. **One delta file, one writer.** Two unfinished tasks of the same change must not write the same
   `openspec/changes/<change>/specs/<capability>/spec.md`. A brief declares what it will write with `deltas:`
   (comma-separated capabilities, `-` for none); **an absent line is not "none"** — it is read as the whole delta
   set, so silence can never be used to slip past the guard. The refusal names the sibling, its board status, the
   shared file and both declarations; `--force` overrides with one audit line. `team change status <id>` prints the
   declared and the actually-touched files per task, so a declaration that lies is visible.
4. **The verifier is not an author.** A `verify` dispatch is refused when its agent also authored an `apply` task
   of the same change (mapped tasks whose board status is `dropped` are excluded and named). `--force` proceeds
   with a warning that the verification is no longer independent. A mapped task whose brief cannot be read or whose
   header has no `agent:` is a **missing signal**: the guard says which signal is missing and proceeds — an
   unknowable author is never reported as a clean one. The same predicate puts `self-verify: <agent>` on the task
   in `team change status`.

All four run before the stack guard and before any window or board write, so a refusal leaves the task's status
exactly as it was. The readiness view and the archive gate share one predicate (`team change status <id>` exits 0
iff at least one task is mapped and every mapped task is finished): an `archive` task cannot be set `done` while a
sibling of its change is unfinished, and the existing `TEAM_BOARD_DONE_FORCE=1` override still records itself.

### 5b-1. One refusal, every blocker, a fix that runs

Every pre-launch guard that can be decided without side effects is judged in **one pass**, and the driver prints
**one** refusal: the blockers in guard order, each naming the artifact it is about (brief, worktree, seat, model or
session) and its reason, each followed by the lines that clear it — `修法：<command>` (a command with real values:
worktree path, branch name, task id) or `改行：<exact line>` (the brief header line to write) — and a final
`共 N 个阻塞项` line. The refusal opens with the first blocker's own first line (there is no separate banner), and
nothing is written before it passes: no window, no `state/<agent>.env`, no board change, not even a branch switch.
A guard whose precondition is missing says so instead of being reported as passed. In a printed route the command
stands **alone on its line** — an explanation, if any, is a separate line — because a trailing parenthetical is
exactly how a printed fix turned into `✗ dispatch: 未知参数 --force（…）` for real users.

`--force` keeps each guard's own meaning: an overridable blocker becomes a warning plus exactly one
`state/watchdog.log` audit line (written only after a launch really happened, never for `--print`), while a blocker
with no override still refuses. A printed route is a promise, not decoration: `tests/routes.sh` applies every
`修法：`/`改行：` family in a fixture and reddens when a route stops clearing its blocker or is no longer honored.

## 6. Model strategy: task categories; different families for verification

The contract maps **task categories** to models (`TEAM_TASK_MODELS`) and names one independent `TEAM_PM_MODEL`.
The brief's `category:` selects a new dispatch's model, `team dispatch --model` overrides one launch, and a
complete same-task recovery keeps the model/SID it was committed to (even if the category later leaves the map).
The engine never derives a model from a phase or a seat: "verify" is a project category name, not an automatic
phase→model mapping, so a project with other names says which one means independent review.

| Use | Category | Selection principle |
|---|---|---|
| Implementation/tests/docs | `routine` | a cheap model without concurrency limits; a good brief is enough |
| Complex planning / important architecture judgment | `analysis` | a strong model, with the reasoning recorded on disk (decision log/design), not only in a session |
| Independent verification/adversarial analysis | `verify` | switch model families (to avoid the same blind spots); a strong author model never waives independent review |
| Heavy concurrent work | any | the subscription token/quota is shared → cap concurrency with `TEAM_MODEL_LIMITS` and queue the rest; `team ps` shows occupancy |

Changing the policy changes **future** selections only. Redefining or dropping a category does not invalidate a
committed assignment: recovery uses the recorded model/SID, while a **new** task citing a dropped category is
refused. A running PM keeps its model until an explicit `team up --fresh-pm`, and a fresh takeover continues from
the files on disk (brief, report, thread, `state/`), never by replaying a conversation.

Private source verification, protected-branch source landing, this project's configuration/fresh launch and shared
skill-source adoption are **four separate recorded gates**; each is previewed and authorized per operation. A
shared skill link reads its target's current bytes, so changing that target can affect other projects without an
installer.

`team dispatch` checks `TEAM_MODEL_LIMITS` and capacity before dispatching: the rules (from two OOM incidents on CEP,
one of which hit RAM and zram bottom at the same time):

| Line | Default | Meaning |
|---|---|---|
| `TEAM_MIN_AVAIL_MB` | 1024 | **hard line**: refuse to dispatch below this `MemAvailable` (that CEP machine sets 4096) |
| `TEAM_MIN_FREE_SWAP_MB` | 1024 | **hard line**: refuse to dispatch below this much free **disk swap** (**zram excluded**) |
| `TEAM_MIN_TOTAL_MB` | 512 | hard line: `MemAvailable + free disk swap` |
| `TEAM_WARN_AVAIL_MB` | 4096 | warn only: RAM is tight, slowness is allowed |
| `TEAM_ZRAM_WARN_PCT` | 85 | warn only: zram usage is too high (zram pages live in RAM, so it is a source of slowness, not a safety net) |

Why zram is looked at separately: the "free space" of `/dev/zram0` is really compressed pages in RAM, and once full it
stays essentially full; counting it towards the concurrency budget systematically overestimates the headroom, and when
an OOM happens RAM and zram bottom out together.

## 7. Security red lines (non-negotiable)

- A token is injected only when the PM **calls a real tool** (e.g. `GH_TOKEN="$(< .gh-pat)" gh …`); it is never echoed
  and never lands in a project file or a log.
  The skill is fully decoupled from forges: it does not probe gh/glab, does not read tokens and does not open PR/MRs
  for you (v1.12.0).
- Agents may not: push the protected branch, force-push, merge, rebase/delete someone else's branch, change repository
  settings.
- Agents may not read credential/account files (such as `~/.pi/agent/auth.json`).
- Any operation that changes shared/remote state needs `--yes` (explicit user authorization). The skill never decides
  for the user.

## 8. Periodic patrol: no "watch everything", only waking people up

The pulse (called `watchdog` before v1.36.0 — the old command/window/variable names still work as aliases and
fallbacks until v2.0.0) is not a keep-alive heartbeat but a **metronome**: on a timer it asks "is there work for
the PM right now".

- Every 15 minutes by default (`TEAM_PULSE_INTERVAL=900`, 300~3600 recommended) it computes the pending work: unread
  notifications / reports awaiting verification / board todo·wip / blocked / agents with an unfinished task that
  stopped.
- **Pending work** → wake the PM (nudge it if it is running; if not, start it with `pi -c` and the kick-off prompt
  `@state/pm-prompt.md`); **nothing pending** → do not wake it, do not start it — the PM is not required to run
  continuously, and being quiet is a valid state.
- **Who resumes a stopped agent** (the one question this paragraph exists to settle): **the pulse never
  does.** A stopped agent with an unfinished task is *reported* as pending work; starting it is the PM's call
  (`team resume --agent <a>`, or `team resume` for all). The single exception is a human's rescue command:
  `team up` recovers the PM window and, **only with `--agents`**, also resumes the agents that stopped with an
  unfinished task (that is why a session restore can bring workers back without the PM asking). The watchdog
  spec pins the pulse half of this as a scenario ("A stopped agent is not resumed") — if you ever see a resumed
  worker you did not ask for, look for a `team up --agents` / `team resume` in the log, not in the patrol.
- **"The PM is running" is a proof, not an inference**: either `state/pm.pid` (recorded by the start path) points at
  a live process whose cwd is inside the project, or the process in the PM window is the configured agent binary and
  its cwd is in the project. A window occupied by anything else is `foreign:<cmd>` (another project's cwd — not
  overwritten without `TEAM_REPLACE_FOREIGN_PM=1`) or `unknown:<cmd>` (in-project cwd, but not the agent). `unknown`
  never suppresses starting the PM: a freshly created, empty pane is not a running PM, and claiming otherwise is how
  `team up` once printed "PM is running" while starting nothing (M6.5).
- **`starting` is a state of its own (M7.2)**: between `respawn-pane` and the moment a start's evidence lands, the PM
  pane is a shell running the start command — in-project, but not the agent. While that start is in flight the tool
  keeps `state/pm.pid.starting` (epoch, starter pid, target; fresh for `TEAM_PM_START_WAIT + 5s`) and every surface
  reports `starting:<age>`: it is not "running" (there is no proof yet) and it is **not** "no PM" either. A tick that
  sees it neither nudges nor starts a second PM — a second `respawn-pane` would kill the PM that is just coming up,
  and the restart quota would count one start twice. A stale marker (the starter crashed) is ignored: it is evidence,
  never a lock.
- **One tick, one state read**: the patrol derives every conclusion (nudge / start / stay quiet) from a single
  `team_pm_state` read, and `state/pm-restarts.log` records **real restarts only** — the quota is checked before
  starting, the line (epoch, timestamp, evidence) is written after a successful start. Every attempt is recorded in
  `state/pm-start-attempts.log` with the evidence, and the quota counts both files (`TEAM_PULSE_MAX_RESTARTS` per
  hour), so a start loop that spawns but never confirms cannot run away while "the PM was restarted N times in the
  last hour" stays a fact and not a count of attempts. A failed or timed-out attempt is logged in
  `state/watchdog.log` as not counted. `pulse status`, `digest`, `ps` and the monitor panel print the same state
  and name the evidence behind it (`state/pm.pid=<pid> proof=spawn|argv`, the starting marker, the empty prompt, the
  occupant).

What each liveness state means and what the PM should do:

| state | meaning | what to do |
|---|---|---|
| `running:<pid\|cmd>` | proof: the recorded pid is alive with an in-project cwd (`proof=spawn` / `proof=argv`), or the window's process is the configured agent binary | nothing; a nudge means "there is pending work" |
| `starting:<age>` | a start is in flight (fresh `state/pm.pid.starting`) | nothing — the tick will not start a second PM; wait for `running` (the marker expires after `TEAM_PM_START_WAIT + 5s`) |
| `idle:<shell>` | empty prompt | `team up`, or let the pulse start it when there is pending work |
| `unknown:<cmd>` | in-project occupant that is not the agent (fresh pane, `sleep`, editor) | `team up` replaces it; it never suppresses a start |
| `foreign:<cmd>` | occupant whose cwd belongs to another project | close that window, or `TEAM_REPLACE_FOREIGN_PM=1 team up` |
| `missing` | the PM window does not exist | `team up`; `TEAM_PULSE_REBUILD_TMUX=1` lets the pulse rebuild it |

`busy` is not a liveness state of its own: a pane whose foreground process is the agent is `running`, and a pane that is
merely busy with something else in this project (the pre-`exec` start command, a `sleep`, an editor) is `unknown:<cmd>`
— it does not suppress starting the PM. The internal pane-busy probe is only used to tell an empty prompt (`idle`) from
an occupant (`unknown`).
- The PM can stand down deliberately: `team standby on --reason "…"` (nothing to do / a human has to step in), after
  which it is not woken; a backlog is still logged; once a human has dealt with it, `team standby off`.
- Repeated reminders for the same batch are limited by `TEAM_PULSE_NUDGE_GAP`; a PM that is busy can simply ignore a
  reminder.
- Division of labour with instant notifications: the notification at the end of an agent's turn is **instant** (the
  notify extension: write the inbox + knock on the PM window); the pulse's reminder is a **timed fallback**: as
  long as that batch is unread/unhandled, the next round (or a change in pending work) raises it again.
- Boundaries: it does not manage tmux layout by default (`TEAM_PULSE_REBUILD_TMUX=0`, a lost session/window is only
  reported; with the knob set to 1 it rebuilds the session and the PM window **when it notices they are gone** --
  which cannot help if the tmux *server* died, because the patrol itself lives in a pane of that server). It does
  **not** leave agents alone, though: a seat that has stopped while its task is unfinished is auto-dispatched back
  onto the same task (`cmd-watch.sh`'s resume path, one `resume agent=<a> task=<id>` line in `watchdog.log` per
  attempt). It does not manage model quota and never merges automatically. Runaway protection: the auto-start quota
  of 5 per hour plus the pulse's own pid lock.

Why the boundaries are drawn this narrow: an "everything-managing" daemon would manipulate tmux layout, agent
lifecycles and model quota at the same time, and when something breaks nobody can tell who corrupted the state;
furthermore, the more "keep-alive" it does, the easier it is to silently paper over something that should have needed
a human.

## 8b. Branch model: one branch per task (default)

- `TEAM_BRANCH_MODE=task` (default): `dispatch` cuts `task/<ID>-<slug>` off the protected branch inside the agent's
  long-lived worktree; `review/merge/close` all operate on that task branch → **the verification scope is one task's
  diff**, the rollback granularity is one task, and the PR description is a reference to the brief. After a task is
  `close`d the worktree returns to `detached@protected branch`, so the next task starts clean.
- `TEAM_BRANCH_MODE=agent`: one long-lived branch `agent/<name>` per agent (fits long refactors, or a team where each
  agent only ever does one thing).
- **The branch a task runs on has one declared name**, resolved in this order: `dispatch --branch <name>`, else the
  brief's `branch:` line, else the derivation from the title; `team task` writes the derived name into the brief as
  its `branch:` line, so a later title change does not move the target under a running task. Dispatch prints the
  resolved name **and its source** before the launch (`--print` too). Without `--branch`, a worktree sitting on
  **this task's** branch is accepted even when its slug differs (both names are printed — the slug is a display
  detail, not the identity of the work); with `--branch` the worktree must be on exactly that name, because the
  explicit declaration wins. The launch prompt names the branch the guard actually accepted and
  `state/<agent>.env: branch=` records the same one, so the worker, the ledger and the worktree tell one story.
- When the worktree is dirty, switching branches is refused (otherwise the previous task's changes leak into the new
  task); that is a hard rule, not a reminder.

## 8c. The pulse's scope: it serves the current tmux session only

- The pulse (`team monitor` in the `pulse` window) only answers questions about this session: who is running,
  what task they are on, what is pending, how the capacity looks; it **never digs through another agent's session
  content** (that is what the PM reads via `inbox`/reports/`digest`).
- The session activity stream is opt-in: `team monitor --activity` (and it only lists live windows in this session).
  It is off by default for a very practical reason: 6 agents ≈ ~9MB of JSONL read per render (measured 7MB→67MB RSS,
  refreshed every 3s), and the noise covers up the state you actually needed to see.
- The panel refreshes every 5s by default while the patrol beat stays `TEAM_PULSE_INTERVAL` (900s by default).
- **What counts as "pending work"**: unread notifications / reports awaiting verification / a `blocked` row / agents
  that stopped (with a task that was never delivered).
  The board's `todo/wip` **do not count by default** — a backlog is always there and knocking every 15 minutes would
  be pure noise; to include the backlog in reminders set `TEAM_PULSE_PENDING_BOARD=1` (the panel and the digest always
  show them anyway).

## 8d. Board and report parsing must tolerate human edits

Three places CEP hit in practice, all of which now have explicit tolerance rules:

- **BOARD column count**: `ID/task/Agent/branch/dependency/status` are located **by header name**, so columns may be
  inserted by hand (e.g. an `Issue` column); `board ls` reports a "non-standard column layout" but keeps working;
  `team board set` only writes the status column and never touches a column you added; new rows are aligned to the
  existing column count (unknown columns stay empty). Parsing used to be positional, and after adding a column it
  would read a `—` as the title.
- **Duplicate ids**: a board row is identified by `(id, nth)` — the n-th row carrying that id — because a board
  may share an id between tasks. The console walks the focus positionally, so two rows with one id are two
  stops and each can be selected alone; `board add` refuses a second row for an existing id unless `--allow-dup`
  is passed (audited in `state/watchdog.log`), and `board ls` / `digest` / `doctor` report the duplicates
  (doctor warns, it does not fail). `board set <ID> <status>` and `board assign <ID> <agent>` address a row by
  **id** and write **every** row carrying it (that has always been the CLI's rule; the row-level identity above
  is the console's) — which is exactly why a *new* duplicate is refused at the door.
- **The pending-report heuristic**: a file under `reports/*.md` only counts as awaiting verification when its
  file-name prefix is a task ID **and** its title is `# <ID> · …` **and** that ID is on the BOARD (or has a brief);
  the PM's own milestone/closure reports (`P2-closure.md` and the like) are grouped by `digest` under "ignored
  non-task reports" — not silently dropped, and not nagged about either.
- **merge conflicts**: on failure the unmerged files are listed directly (`UU/AA/DD/AU/UA/DU/UD`) together with a hint
  about `--no-renames` (add/add is often git's rename detection pairing `reports/<ID>-x.md` with `reviews/<ID>.md`).
  It no longer just drops a "conflict/failed" on you to re-run by hand.

## 8e. A BOARD status only becomes done after the code really reached the protected branch

CEP has been there: on a failed conflict path the BOARD had already been marked `done` while the code never reached
`main` (the PR was still open) — a status contradicting the facts is more dangerous than the failure itself. The
current order and the closing move:

**Before the merge, land the worktree's records.** `git merge --squash` carries the branch's *committed* content
only, but a worker often leaves its report, evidence package or `<docs>/` edit **uncommitted** in its worktree
(`??`/` M`) — the merge succeeds, the record stays behind, and reusing the worktree destroys it (2026-09-22:
P36/P40/P42/P65/P67). The digest already warns about unlanded records (M31/P47), but the merge flow never runs the
digest, so the check lives on the merge step itself:

```bash
team review <ID> --pre-merge
```

It resolves the task branch (state → refs), finds the worktree checked out on it, and prints every `<docs>/**`
path there that is not committed — each line as `<agent>: <path>` with a copy-paste `git -C <worktree> add -A -- …`
+ `git commit` recipe carrying the `Agent: <agent>` trailer. It exits **non-zero while any exist** (usable as the
merge gate), prints nothing when clean, and **never commits for you** — who committed a record must stay true.
Ignored files (`state/`, build output) and dirty paths outside `<docs>/` are not part of the check; if no worktree
can be located it refuses (rc 2) rather than reporting clean — "not checked" must not look like "nothing wrong".

**After the merge, check whether the branch moved again.** A squash merge freezes the branch as it was *at that
moment*: commits the author lands afterwards are not in the protected branch (2026-09-22: P82 added two record-only
commits after the merge and `main` kept the older records). Any late delivery gets one command, before taking
anything:

```bash
team review <ID> --post-merge
```

It compares `main..task/<branch>` **per path** and reports only what the branch added *after* the merge: a path the
branch never changed since the fork, a path whose `main` version came later from elsewhere, and a path whose branch
version `main` already had at some point all belong to other work, not to this task. Without that filter a stale
branch would list every later task's files — and even advise "taking" records that would delete them. `<docs>/**`
paths → *records updated: take them* plus a copy-paste `git -C <root> checkout <branch> -- <paths>` (a record the
branch deleted comes as `git rm -f`), exit 0. Paths under `skills/**` (or anything else outside `<docs>/**`) → exit
non-zero with "code has unmerged changes — do not just take the records, re-merge and re-run the gates". Versions
that fit neither side (conflict resolution, a never-merged branch, a late change colliding with someone else's) are
named too rather than dropped. No branch → rc 2: "not checked" never looks like "nothing wrong".

1. verify the branch exists (a mistyped `--branch` is not misreported as a "conflict");
2. `merge --squash` → `commit` → **`push` (when `--push`/`--pr`)** all succeed;
3. only then `board set <ID> done`.

If any step fails (conflict / failed commit / failed push): **the BOARD keeps its pre-merge state** (usually
`review`), and `done` is not set — code that never reached the protected branch is not finished.

### The tool checks step 3 instead of trusting it

Since v1.20.0 `team board set <ID> done` (and `team close <ID>`, whose default status is `done`) **refuses** unless
it can point at something checkable at that moment:

- `<docs>/reviews/<ID>.md` exists and its verdict is not `FAIL`/`TIMEOUT` (`PASS`; `UNKNOWN`/`SKIPPED` are accepted
  as an explicit manual review and named as such in the output), **or**
- the task branch tip is already contained in the protected branch (`git merge-base --is-ancestor`, i.e. a real
  merge / fast-forward). A **squash** merge does not satisfy this — the branch commits are not ancestors — so in the
  usual squash workflow the review record is what unlocks `done`.

Every accepted transition appends what it checked to `<docs>/reviews/<ID>-done.md` (append-only; the forced case is
marked `FORCED`), so "what did it verify at that moment" survives the terminal:

```
- <timestamp> · `team board set M5.2 done` · OK：the evidence line for that moment
                                      (e.g. "review record <docs>/reviews/M5.2.md, verdict PASS")
```

If neither condition holds and the PM is sure anyway, the override has to carry a reason:

```bash
TEAM_BOARD_DONE_FORCE=1 TEAM_BOARD_DONE_REASON="hotfix pushed by hand; PR #12 is the record" \
  team board set <ID> done                 # or: team close <ID> --status done --force --reason "…"
```

A missing reason is refused, and the transition is recorded as `FORCED` together with the failed checks — an
override is allowed, a silent one is not. Nothing is written when the gate refuses.

### What the tool cannot check (the PM's own checklist)

The "pushed" half is the PM's:

- `git -C <root> merge-base --is-ancestor <branch> <protected>` exits 0, and `git -C <root> status -sb` shows the
  protected branch is not ahead of its upstream;
- `team review <ID> --dir <independent checkout>` recorded `PASS` on the revision you are about to merge
  (`--strong` for a milestone);
- only then `team board set <ID> done` and `team close <ID>`.

Conflict handling (the PM runs all of this itself):

```bash
git -C <root> merge --abort                                  # want to give up and start over
git -C <root> status --porcelain | grep '^U'                 # list the conflicted files (UU/AA/…)
# lockfile-style conflicts (pnpm-lock.yaml and friends): take the branch side, then reinstall
git -C <root> checkout --theirs -- pnpm-lock.yaml && (cd <root> && pnpm install --lockfile-only)
git -C <root> add -A && git -C <root> commit -m "<ID>: <title>"
```

**Conflict markers do not ride along**: `smoke.sh` §0d greps every tracked file for a line starting with
`<<<<<<<` / `=======` / `>>>>>>>` and fails with `file:line`, so a `git add -A && git commit` on top of an
unresolved `merge --squash` is caught by the next gate instead of reaching `main` (it did once).

A trap: an add/add conflict is often git's rename detection pairing two different paths (e.g. `reviews/<ID>.md` and
`reports/<ID>-<agent>.md`). Retry with `git -c merge.renames=false merge --squash <branch>` and it goes away.

## 8f. Template rendering and dispatch traps (measured on an external project)

- **Rendering must not go through sed**: `&` inside the replacement string means "the matched text", so
  `TEAM_GATES="pnpm test && pnpm lint"` was written out as `pnpm test {{GATES}}{{GATES}} pnpm lint`. `team_render` now
  uses bash parameter expansion and explicitly disables bash 5.2+'s `patsub_replacement` (which likewise treats `&`
  in the replacement as the matched text).
  `init` also escapes gate values containing `$ \` \ "` before writing them into the config, so sourcing it back
  yields the same value as the input (and is never executed).
- **Dispatch writes absolute paths**: the window shell's PATH/rc may not be ready yet, so exec'ing `pi` directly ends
  in `command not found` (reproduced twice on an external project). Dispatch now resolves pi's absolute path first, writes it into the
  window command, and checks that it exists before dispatching (a missing binary fails immediately instead of being
  discovered inside the window).
- **GitLab API headers and bodies must match**: if `--data-urlencode` is the form body, then
  `Content-Type: application/x-www-form-urlencoded` must go with it; setting `application/json` gets rejected by
  GitLab (`{"error":"Invalid JSON format"}`, and the PR/MR never opens). A JSON body (`--data/--data-binary`) still
  uses `application/json`.

## 8g. Across projects: you may talk, you may not command

- **Normal conversation between projects is allowed**: how an interface connects, advice with its basis, problem
  reports with reproductions, scheduling a joint test window.
  **Not allowed**: telling another PM/agent what to do, deciding on the other side's behalf, impersonating a human to
  issue instructions, changing the other side's repository or state.
- The mechanism makes "wanting to command but being unable to" real: the `intent` whitelist of `team meeting` has
  **no command/order**; `--as-user` only works from a human terminal (+ `TEAM_MEETING_ALLOW_USER_ID=1`), so an agent
  process writing it is refused; consensus requires **both sides to agree separately**; the meeting only writes the
  shared area and has zero write permission in the peer's repository.
- Division of labour: **workers do not attend** (cross-project communication goes through the PM); a worker that needs
  outside cooperation writes `BLOCKED:` in its report.
- The shared area lives outside both projects (`~/.pi/team/meetings/<slug>/`) and the transcript is the only truth;
  knocking (nudging the peer PM's window) is off by default and only happens with `--knock` + `TEAM_MEETING_KNOCK=1`
  + a registered peer session.
- Boundary guard: cross-session typing is refused **by default without exception**; the single exception is knocking
  for a registered meeting — which is what stops "casually reaching into another project".

## 8h. git and forges belong to the PM: the skill neither performs nor wraps them

- The skill **does not perform** git writes (creating/switching branches, squash, push) or forge writes (open/merge a
  PR, comment, close a PR); **and it does not print "recipes"** either — that would be over-packaging tools that
  already exist. The PM uses the **real tools** directly: `git`, plus whatever forge access it has (`curl` against an
  API, `gh`/`glab`, the web UI).
- On git the skill does exactly three things (all read-only or bookkeeping):
  1. **Checks**: before `dispatch` it confirms the worktree is clean and not on the protected branch (otherwise it
     refuses and explains why);
  2. **Read-only observation**: `roster` / `digest` show the branch, dirty file count, commits ahead and a
     "waiting to be wrapped up" list;
  3. **Verification evidence**: `review <ID> --dir <the checkout the PM prepared>` runs the gates and writes
     `reviews/<ID>.md` (git is prepared by the PM).
- The conventions (written in SKILL.md and the project's PROTOCOL, for the PM to follow): one long-lived worktree per
  agent; task branches cut off the protected branch; verification on a detached independent checkout; merging =
  squashing into the protected branch (with a PR, merge the PR first and then `fetch + merge --ff-only`);
  **the BOARD is only marked done after the code has really reached the protected branch**.
- Forge-agnostic: GitHub/GitLab are not assumed; a token is read from the project's configured token file and injected
  only for the duration of a command, never echoed.

## 8i. The roster is a contract value with one authorized entry

The roster (`TEAM_AGENTS`) is part of the project contract, and like every other key it has exactly one writer:
the contract's hardened writer, reached through `team add-agent <a> --register` (grow) or `team teardown --agent
<a> --register` (shrink). Both do a read-modify-write with the roster's value rule (each seat name matches
`[A-Za-z0-9][A-Za-z0-9._-]*`, appears once, is never `pm`), the sha256 fingerprint CAS and exactly one
`result=ok actor=cli` line in `<state>/config.log`; the flag is not optional, and a bare `team add-agent <a>` for
a seat the roster does not carry exits 5 while naming both routes that work (hand-edit `.pi/team/config.sh`, or
add `--register`). `TEAM_AGENTS` stays class `refuse`: the console may not write it — a human at the panel could
otherwise widen the project's authority — and the schema note it renders names the register entries. The same
value rule is what `team config list --json` reports as the key's `warning` when a hand-edited file already
carries an illegal token, so the read side and the write side can never disagree about what a seat name is.

## 9. Capacity: the floor is that neither RAM nor disk swap bottoms out (zram does not count)

- There is only one condition for refusing a dispatch: free swap below `TEAM_MIN_FREE_SWAP_MB` (1024MB by default) or
  RAM+swap below `TEAM_MIN_TOTAL_MB`.
- Tight RAM (below `TEAM_WARN_AVAIL_MB`) only warns: slowness is acceptable, because slow is not broken; an OOM is the
  real accident.
- `team ps` / `team doctor` report from the same data source and add an estimate of "how many more agents fit"
  (`TEAM_AGENT_MEM_MB`).

## 9b. Tests and gates must have a timeout (non-negotiable)

- The fact: a deliberately broken implementation made a PG integration test wait for a connection forever (no pool
  timeout + a missing `--test-timeout` + bash `timeout` set to 1800000s) → the script hung for **85 minutes** with zero
  output.
- Therefore: `team review` **always wraps the gates in a hard timeout** (`TEAM_REVIEW_TIMEOUT`, 1800s by default; a
  timeout is recorded as `TIMEOUT`, treated as FAIL and written into the verification record). The verdict is taken from
  the `timeout` **wrapper's exit code together with the elapsed time** (124 = TERM fired, 137 = the child ignored TERM
  and was KILLed), never from a substring of the gate's own log — otherwise a hung gate reads as a plain failure and a
  gate that merely *prints* “using timeout 5” reads as a timeout. Requiring **both** signals is what stops the opposite
  error: a gate that exits 124 by itself (or a suite whose inner test propagates 124) used to be recorded as "killed at
  the deadline"; it is now `FAIL` (a signal-terminated gate is recorded as `FAIL` with the signal named). Gate commands
  should carry their own `timeout` too (e.g. `timeout 900 pnpm test:unit`).
- Acceptance commands in a brief should carry their own timeout as well; destructive experiment scripts must restore
  the scene with `trap 'git checkout -- …' EXIT`.

### 9b-2. The gate is the machine's one shared resource (queue, and the measurement premise)

The full gate — `openspec validate … && bash skills/teamsmith/tests/smoke.sh` — spends the machine's tmux servers,
node/bun processes and login shells, so it is serialised on **one gate lock**
(`${TEAM_SMOKE_LOCK:-${TMPDIR:-/tmp}/teamsmith-smoke.lock}`, wait capped by `TEAM_SMOKE_LOCK_WAIT`, default
1800 s). Use `TEAM_SMOKE_FAST=1` for in-batch self-tests (pure-logic sections only; measured in the low minutes
on a loaded 32-core box, not the seconds it once was - P100's F6) and the **full** suite for delivery and review.

**The gate lock is a pair: a published path (compatibility) and a companion (the mutex).** The published path is
what a writer of an earlier release — which knows only that name — takes, so a run takes it **first** and then the
companion `<same dir>/.<basename>.machine`, where the mutex actually lives and where the run keeps its records
(`<companion>.holder`, `<companion>.runs/<pid>.run`). The companion is dot-prefixed on purpose: a shared temp root
collects other projects' cleanups, and a `rm -rf ${TMPDIR}/teamsmith-smoke.*` from one of them must not be able to
reach this project's lock (that incident deleted a running gate's lock and work root; a shared `teamsmith-` prefix
is not ownership). **Neither file is ever unlinked** — removing a lock file that waiters are blocked on is exactly
the silent split the pair exists to remove — and only the holder's own record and run entry are removed at the end
of the run. The run takes both locks **in its own process** (fds 8/9) and hands the suite to a child with those
descriptors closed (`8>&- 9>&-`), so a fixture's background process cannot inherit the lock and hold it past the
run. The holder record carries the locking process's own descriptor identity (`lock_identity=fd|path`,
`lock_dev_ino=<dev:ino>`) — the identity of the file the holder really holds, readable only through that process's
`/proc`; when that cannot be read the record says `path` and the path's identity instead, rather than guessing.

**Losing the lock while running is loud and stops the run.** The 0.2 s tripwire watches the companion as well:
when the lock is no longer the file the holder holds — *gone* (unlinked, not rebuilt) or *replaced* (the path now
names a different inode; `rm`+recreate and `mv`-over both land here) — it prints one line saying which of the two
happened, with the inode the holder holds and the one now on the path, appends the evidence (identity, the
published path's state, the last section reached) next to the run's `$TMP` — outside it, so deleting the scene
does not take it — and stops at the next safe point with exit 2 and **no result line**: a verdict computed after
the mutex broke is not honest, and a plain red would bury *when* the lock went. Losing the **published** path alone
is printed once and does not stop the run (the mutex is on the companion; what is lost is serialisation against
older writers). A run that takes a lock whose live `top` record attests **another** identity refuses in the same
way *before* running the suite — that record is the observable form of a mutex that is already broken.

**A lock that cannot be taken is a visible degradation, not a hard failure.** If the lock file cannot be
created (or `flock` is absent) the suite prints `注意：锁文件 … 建不了 → 不做排队` and runs anyway; only a
*queue overrun* is loud (a one-line `排队超限` naming the holder in `<companion>.holder`, exit 2), and a genuine
red is exit 1 with no queue wording (P66/P100's F1).

**A nested run does not queue on the gate lock, and it must prove its ancestor.** The lock serialises
**top-level** runs only. A run started inside a run that holds the lock — *proved* by walking the process
ancestry to a pid recorded in `<companion>.holder` / `<companion>.runs/<pid>.run` and matching that process's
start time, never by the inherited `SMOKE_LOCK_WRAPPED` marker (a fixture can clear it, and a nested run carrying
it used to overwrite and delete the real holder's record) — does not queue: queueing there hands the machine's one
shared resource to a fixture and deadlocks the run against its own ancestor. It registers itself in
`<companion>.runs/<pid>.run` instead, so a waiter can attribute what it is waiting for. A run that cannot prove a
live ancestor holding the lock (including on a host without `/proc`) is treated as top-level and queues exactly
as before, so skipping the queue is never unconditional. The claim that a run is "the one that holds the lock"
must be backed by the locking process's own `/proc` — its descriptor pointing at the companion, with the identity
the run was handed — or by a live record naming it: environment variables alone name nobody. The claimant must
also be a **strict** ancestor of the claiming suite (a section-selected run adds one more process layer, so the
locker is the grandparent there and the parent in the direct case, and both are covered). The holder record names its kind (`top`/`nested`), its
seat and its worktree (derived from the runtime directory by the same rule as `team notify`'s sender, never from
a tmux window name; unresolvable fields are written `?`, not guessed), its start time, its expected duration and
its bound (the section table's Σband / Σbudget over the sections that run will execute), and the waiter prints
that record when it starts queueing and again when it hits its cap. Only the process that really holds the lock
writes and removes the holder record — and it writes only when that record is absent, stale (its process is gone)
or already names that same pid: the inherited `SMOKE_LOCK_MARKER` file is a hint, not a proof, so a descendant
that rewrites the marker with its own pid still cannot overwrite or delete a record naming another **live**
process. A record whose process is gone is printed as stale and ignored, and no run removes a record it did not
write. A nested run must finish inside the section that spawned it: its section
guard is armed with a deadline taken from the ancestor's currently armed section (the record publishes the
section guard's heartbeat path; the nested run reads `armed_epoch + budget` and takes the smaller of that and
its own table budget), so it trips at the suite's own safe point, names the section and the bound, stops and
leaves its scene; a fixture that leaves a registered nested run alive after its spawning section closed gets one
visible line naming it, and only the pids the fixture itself registered are signalled.

**The residual window is the explicit opt-out.** `TEAM_SMOKE_NO_LOCK=1`, a host without `flock` or the fast mode
(which runs no real-process section) leave the suite unserialised, so two seats' nested runs can overlap there.
The suite prints `no_lock=1` with the reason **and registers that run** in `<companion>.runs/<pid>.run` with
`no_lock=1` when that happens: the window is *observable* in the record, not just on stdout, and it is not
asserted away. The gate can prove that the record carries these fields, that a waiter prints them, and that a nested run
past its bound is named and stopped — it **cannot** prove that a live seat read the line and acted on it, nor
that two seats' nested runs never overlap. Those two are outside what the gate can show.

**A waiter re-resolves the path it is waiting on.** The wait is a bounded poll (one short non-blocking
`flock` per second, cap recomputed each round), not a single blocking `flock`: when the lock the waiter is
blocked on is unlinked and rebuilt while it waits, it prints one line with both identities (what it was waiting
on, what is on the path now) and goes on waiting on the path — never silently on a dead inode, which is how a
waiter ends up "holding" a lock nobody else can see.

**The queue cap follows the holder's published expected duration.** When the holder record publishes `started`
and `expected`, the waiter's cap is `clamp(remaining + 60 s, TEAM_SMOKE_LOCK_WAIT, 4 × TEAM_SMOKE_LOCK_WAIT)`
with `remaining = expected − (now − started)`, and the waiter prints that arithmetic together with the three
numbers; when the record publishes no `expected` (an older writer, or an unreadable record) the cap stays
`TEAM_SMOKE_LOCK_WAIT` exactly as before. The verdict token set (`PASS|FAIL|TIMEOUT|SKIPPED|UNKNOWN`), the
`queued Ns / ran Ns / limit Ns` accounting and the rule that a queue overrun is `FAIL` (never `TIMEOUT`) with
the holder named are all unchanged. `team review` takes the same pair, in the same order, in its own process,
and runs the project's gate with the lock descriptors closed: its gate record still separates `queued` from
`ran`, and a broken mutex makes the verdict `FAIL` with its own marker (internal `mutexbroken`; rendered as
`gate mutex broken（互斥被破坏：判定不可信）` in the record's flags, alongside `queuecap`) — either before the
gate runs (a live `top` record attesting another identity: the gate did **not** run, and the record says so,
exactly as the `queuecap` shape does) or after it (the gate ran, the record says the verdict is not trustworthy,
and it does not claim the gate never ran).

**The gate fingerprints the tree it judges (P158).** Once the run's own root exists it records `HEAD` and the
sha256 of `git status --porcelain --untracked-files=all` (untracked files included) into the run's scene file
(`<run tmp>/run-fingerprint.txt`, next to `sections.tsv`), and compares that fingerprint again after the last
section. If the tree moved while the run was in flight — a merge into the same checkout, an edit to any path,
even one no section read — the run prints one conspicuous line
`本次运行无效：树在跑动中被改动（HEAD A→B / 脏文件 N→M）` right next to the result line, leaves every ✓/✗ it
already printed standing (the reds are not swallowed) and exits **4**: the verdict is *invalid*, not a judgement
on the code, because the tree that ran is not the tree the result line names. 1 stays “there are failures”, 2
“the gate stopped itself” (precondition or queue), 3 “no temp root”, so the four codes do not overlap; rerun
once the tree is stable. The criterion is deliberately the whole worktree rather than “the files the suite
read”: cheaper and unambiguous, and a record under `docs/team/**` that no section consumed still counts as the
tree having moved (conservative). A tree that is not a git checkout prints one visible note and does not judge
what it cannot judge (P148's rule); an unchanged tree adds no stdout at all. The red side and its teeth are
`tests/flip-p158.sh` (four cases plus two `--break=` shadows), and suite section `0i` runs it.

**How often the full suite runs (2026-09-23, user directive).** The full suite takes 13-20 minutes and the
machine runs one at a time, so it is a *milestone* gate, not a per-change habit: a task runs it **once at
delivery** (not per batch), a review runs it **once**, and the PM runs it on the protected branch **only at
archive/release milestones** - per merge it reads the delivered run plus CI. A change whose diff touches no
product path (for example records under `docs/**` only) needs **no** suite run: say so in the report and let
the guarded FAST set (or nothing) stand as the evidence. When in doubt prefer FAST plus the affected
sections and name what you did not run; **never** claim a full run you did not do. Which sections a changed path affects is
mechanical: `bash skills/teamsmith/tests/section-select.sh --paths <path…>` answers `RUN`, `NONE` (records under `docs/**`) or `FULL`
(a path no row claims — run the suite), `bash skills/teamsmith/tests/smoke.sh --paths <path…>` runs that answer, and a selected run
lists the sections it did not run instead of claiming the full suite. **A selected run's green covers only the sections it
ran, not the suite: a report citing one names the sections it did not run.** `team review` takes that same lock **before** starting its
hard timeout: the queue is its own bounded phase, the record accounts it separately (`limit=Ns queued=Ns ran=Ns`),
a queue that exceeds the cap is `FAIL` with the holder named (never `TIMEOUT`), a run that really overruns is still
`TIMEOUT` with `ran=Ns`, and `SMOKE_LOCK_WRAPPED=1` tells a nested run that an ancestor already holds the lock (so
it does not queue again and cannot deadlock against its own suite). Without `flock` the degradation is printed.
The gate judges **correctness only**: it contains no wall-clock or CPU-share red line, and a pure-logic guard in the
gate's own sources (`tests/gate-guard.sh`) fails when a frame-budget/CPU-share marker reappears in it or when the
performance suite loses its markers. The three panel numbers — the interactive first frame, the uncached frame
assembly and the steady-state pane CPU — belong to the **performance suite**
(`bash skills/teamsmith/tests/perf.sh`, front door `team perf`): the thresholds (`2000 ms` / `2000 ms` / `< 1 %` of
one core), the premise factors (`0.75` for the five-sample assembly median, `0.25` for the cold-start first frame
and the sampled pane CPU, `TEAM_PANEL_CPU_PREMISE_FACTOR`) and the median-of-three/five rules are unchanged, every
sample is printed, and above the premise the judgement **skips visibly** (exit `4` for a skip, `2` for a red, `0`
only when every judgement ran green) rather than reporting a red the machine owes. The suite takes its **own** lock
(`TEAM_PERF_LOCK`, `TEAM_PERF_LOCK_WAIT`, holder in `<companion>.holder`, where `<companion>` is
`<same dir>/.<basename>.machine` — the same pair the gate uses, so a perf lock survives the same incident) and **never** takes `TEAM_SMOKE_LOCK`: it does
one read-only non-blocking probe of the gate lock and prints the holder when the correctness gate is running, but
it never queues behind it. The gate's lock accounting (`limit=Ns queued=Ns ran=Ns`), the fixture-knob rule (those
`TEAM_*` knobs only work under `TEAM_SMOKE_FIXTURE=1`, and in the real path they are ignored and printed) and the
gate's time-independent half of that check (`tests/panel-knobs.sh`) are unchanged; a red performance run changes no
review verdict. The contracts live in the specs (`verification#The correctness gate judges correctness only`,
`verification#The performance suite is separate, self-describing and non-blocking`,
`verification#The hard timeout covers the gate run, not the queue`,
`panel#Frame assembly is asynchronous, cached and never blocks input`); this section is the operational rule.

The gate also reports **its own temp-root usage**: one line at the start and one with the result summary (also on
failure) name the run's root, its size, its file count and **how the root was resolved** (`source=tmpdir|config|
default`, plus the `TEAM_TMP_ROOT` value that lost when `TMPDIR` overrode it); the run also asserts that every root
it created is gone unless it declared one kept. Fixtures get their root from the
resolved temp root (`TMPDIR` → `TEAM_TMP_ROOT` → `/var/tmp/teamsmith-<ns>`, one resolver, never re-implemented):
`<that root>/teamsmith-<kind>-<ns>.XXXXXX`
(`ns = <slug>-<hash8>`, derived from the main checkout, so every worktree of one project shares it and two projects
cannot collide; the helper records it in the root's marker and the run ledger, `tmp-hygiene.sh --status` shows it,
and roots that predate the namespace are listed as `legacy`) through the
single owner helper `tests/lib/tmp-root.sh` (owner marker inside, run ledger outside, reclaim on `EXIT`/`INT`/`TERM`,
`TEAM_TMP_KEEP=1` to keep); residual roots are reclaimed by an **operator** action,
`bash skills/teamsmith/tests/tmp-hygiene.sh --status` then `--sweep` (it proves a root is unoccupied before
deleting, and refuses with nothing deleted when it cannot), or by deleting only this project's own
`teamsmith-<kind>-<ns>.*` entries. A pattern against the shared prefix — `rm -rf "$(team_tmp_root)/teamsmith-*"` — is
forbidden: that shape is what deleted another project's running gate's lock on 2026-10-15, and a shared prefix is
not ownership. Everything a run creates directly under the temp root is either dot-prefixed (ledger, pid, evidence)
or carries the ns (root, queue marker, filter copy). The gate never sweeps: a fixture another agent started
by hand is not in the gate lock, so pattern-deleting in the shared temp filesystem is the D37 family. `team doctor`
prints the temp root's headroom (free/total bytes and inodes, warning only) so a full filesystem is visible before it
turns into a red a fixture cannot explain.

### 9b-3. The ledger is public, so the ban is a gate check (D94)

Since 2026-10-04 the readable ledger — board, decisions, roadmap, ownership, briefs, the text of reports and
verification records, threads — is committed to the repository (D94). A brief or a report is therefore public the
moment it is written, and the one mechanical redaction pass that produced the first batch has no net behind it.
So the ban is a check in the gate: suite section `60` runs `tests/ledger-ban.sh`, which scans every file under
`docs/team` that would be committed (`git ls-files -c -o --exclude-standard`; P231 widened this from `**/*.md`,
because the `.tsv` ledgers — precisely where branch and project names get written — were a blind spot: a planted
leak there came back green)
and fails, naming file and line, on

- a home directory (`/home/<name>…` or `/Users/<name>…`; the ellipsis placeholders `/home/.../` and `/home/…/` pass);
- a private IPv4 address (`192.168.<x>`, `10.<x>.<x>`, `172.16–31.<x>` — three octets minimum, and the bare
  `192.168.` prefix, the way this very rule writes it, never matches);
- the local username, resolved at run time (`id -un` → `$USER`/`$LOGNAME` → the tail of `$HOME`) and never written
  into the repository; a name that cannot be resolved is a visible skip, not a silent pass;
- a name from the other-project list `.pi/team/forbidden-names.txt` — one name per line, `#` comments and blank
  lines ignored, matched as a whole word, case-insensitive. The list is gitignored, and it has to be: the names it
  spells out are exactly what the ban protects. A missing or empty list is a **visible skip**
  (`SKIP names —— 禁令名单未配置… 这一项不是通过`), never a pass.

The `<home>` / `<user>` / `<peer>` / `<internal>` placeholders are stripped before matching, so the redacted ledger
passes; text outside `docs/team/**` is not in scope (the skill's own docs talk about *the user's* projects), and the
gitignored evidence packages (`docs/team/reports/<ID>-<agent>/…`) are not scanned — they never enter the public
surface. A hit line is masked: it names the file and line and replaces the offending token with a placeholder, so a
gate log can be pasted anywhere without leaking the thing it reported. Since P216 that promise covers **every shape on
the line and every occurrence of it**, not just the first shape the scanner recognized: the hit spans are collected
first and the line is assembled once from the original text, so a replacement is never scanned again (what a hit
looks like in the log is `<home>` / `<internal>` / `<names#N>` / `<user>`, with the first shape found naming the
line), and a line longer than `BAN_LINE_MAX` (4096 bytes) is masked first and truncated after, with an explicit
`…(截断)` marker. Exit codes: `0` every shape was checked and
clean, `3` clean but a sub-check was skipped (missing/empty list, unresolved username), `1` a hit, `4` the scanner
itself failed. Falsifiability lives in the fixture self-test (`bash tests/ledger-ban.sh --self-test`): a planted home
path and a planted forbidden name must come back red with file:line, a missing list must show the visible skip, a
shadow scanner whose hit counter is broken must make the two red cases fail, and a second shadow whose masking is
turned off must make the “no raw text in the output” cases fail — a mixed line, twenty-five occurrences on one line,
a placeholder standing before the hit (the offset must survive the blanking), and an over-long line. P231 extends
both ends: the face itself is pinned by the ownership fixture (`tests/ledger-claim.sh`) — a planted name in a
`docs/team/ledger/*.tsv` must come back red naming that file and line, while a copy of the scanner whose face is
narrowed back to `*.md` must return `hits=0` on the same tree (the false green, reproduced) — and the header rules
(`# ledger-scan:` must be a single line and must point at the section that carries this check) are refused at load
time instead of at check time.

## 9c. Strong verification (adversarial package + finding flips, for milestones)

`team review <ID> --strong` checks two extra things and writes the conclusion into the verification record. The check is
**structural**, not a keyword grep (a report that merely *mentions* “flip evidence” or “independent package” does not
pass; an English-first report with real evidence does), the wording is the one this repo's own
`templates/report.md.tmpl` / protocol use, and the record shows what was looked for, what was found (file + line) and
which half is missing:

1. **Flip evidence**: a section whose heading names the flip (`flip`, `flip evidence`, `red before … green after`,
   “break the implementation”, `翻转`, `破坏`) **and** which contains both a failing (red) and a passing (green) result —
   “red before the fix → green after”, or “break the implementation → the guard test must fail”. A command line is
   recorded as an extra hint;
2. **Independent verification package**: a **path** in the report that points at the package/script — `…/pkg/run.sh`,
   `docs/team/reports/<ID>-<agent>/pkg/…`, a script (`.sh`/`.mjs`/`.js`/`.ts`/`.py`) or any *really existing* directory
   in the checkout. Bare words (“an independent package exists”) do not count. A statement of independence is recorded
   as a hint, and the record also says whether that path is actually present in the reviewed checkout.

The report is looked up in the reviewed checkout first and then in the PM's/agent's worktrees (a report that is not
committed in the verified revision is still read, and the record states that it was taken from outside the checkout).

A missing half is recorded as `不满足（不阻塞合并，但里程碑收口前应补齐）` and warns; it does not block the merge. The
sharper kind of flip — **deliberately break the implementation → the guard test must fail** — is exactly what a heading
like `## Flip evidence` with both outcomes is expected to contain.

It costs more, so it fits milestones and closure rounds; everyday tasks run the ordinary gates.

## 10. Decision log and research rules

- For a stack/technology choice (language, framework, library, storage, protocol) a **research agent gathers evidence
  first** (① measured on this machine > ② official documentation > ③ secondary sources; anything unmeasured must be
  marked as such), and the PM only writes a "decision" after verifying that evidence chain.
- Every decision must record its **reason** and its **impact**: a decision log with conclusions only is worth nothing
  six months later.
- An agent may overturn the PM's provisional judgement with its own evidence — that is by design, not overreach.

## 11. Talking to the user: a code never stands alone

The ledger is keyed by codes (`M12`, `P14`, `V15`, `T1.1`): the BOARD, the roadmap, briefs and review records all
use them, and after a few days the team reads them fluently. The user has no such index in their head — a report
that says "P14 needs a decision" sends them to the files to find out what P14 *is*, and the message that should
have informed them turns into homework.

So: **in user-facing output, a code never stands alone.** Every occurrence that names a task carries its short
human-readable name in the same place — `M12 (fix the smoke flake)` — and milestones and change ids work the same
way. A list is read row by row, so a code repeated on another line is named again there ("each row is a reading
unit"). The one exception is a copy-pasteable command (`team close M12`): there the code is a key, and rewriting
it would break the tool it calls.

The mechanism backs this up wherever the ledger already knows the name: `team digest` (pending review, the
board-skipped line, wrap-up, suggestions), `team status` and `team roster` print the name next to the code, resolving
it from the BOARD's task column first, then the task brief's H1, then the task's report H1. When no name is known the
code is printed alone — never a made-up name, and never a hidden code: the code is the key to the ledger.

### 11b. A caution earns its place by what it cites; a finding earns its place by what it changes

The rule in this section is about **support**, not tone. A sentence that hedges, qualifies, warns or sets a boundary
stays only when it names something the reader can check — a measurement, a counterexample, a gap in the reasoning,
or a boundary that was not observed; a sentence that names none is deleted, not kept as evidence of care. When the
evidence no longer supports a claim, say what the evidence does support instead of narrowing the claim, and never
delete a boundary statement that carries its evidence: it names exactly what this rule asks for. A finding,
likewise, states what it changed — the implementation, the next step, the conclusion, or nothing; an observation
that changes nothing does not stand between the reader and the answer. This is not an instruction to sound more
confident, more assertive or shorter (the measurements are in *Defensive Writing in GPT Models*, arXiv:2610.11355);
the vocabulary rule of §11 stands unchanged.

The reason is who rewards which text. In that study's comparison an AI reviewer scores a reviewer-oriented rewrite
higher (+1.22 on a ten-point scale) while the human readers find it harder to read (−0.70 on a seven-point scale)
with comprehension unchanged: a caution written for an imagined reviewer is paid for by the reader, so it has to
carry its evidence to earn its place.

**No tool verifies that a live author follows this rule.** The gate asserts nothing about wording — it can stay
green over a document that hedges everywhere — so whether a sentence earns its place is decided by the review that
reads the diff, and a surface that later loses the rule is caught there, by the reviewer naming the surface.
