# Guardrails (the teeth)

Everything dev-flow enforces while a feature is in flight, and what it deliberately does not do. Short version: [README → What is enforced](../README.md#what-is-enforced).

While a feature is active, the extension intercepts every tool call:

- **Nothing on main, ever** (until release): `git commit` on the default branch is blocked at every stage except `release` — where release chores commit in the main checkout (prefixed `DEV_FLOW_ALLOW_MAIN=1` for the hook) — and except one chore that needs no flow: a commit whose **only** change in the checkout is `.dev-flow/project.md`, because tracking the operator's playbook is setup, not feature work (any other dirty path closes that lane). pushes are judged by the ref they write — only pushing *to* the default branch (or a bare push while on it) requires stage `release`, so tag pushes and feature-branch pushes pass from any stage; `git merge` onto the default branch also requires stage `release`. Plus the standalone pre-commit hook for commits made outside Pi.
- **No code before an approved plan**: at stage `plan`, edits are confined to `.dev-flow/<slug>/` — the session's **own** feature directory, so one session cannot write another feature's plan and have its gate rubber-stamp what it never wrote.
- **Work stays in the worktree**: at stages `implement` and `pre-release`, edits are confined to that feature's worktree — judged per-path by the owning feature, so one session can never write into another session's worktree or into the main checkout. A throwaway **outside** the checkout is not feature work and stays free at every stage (the OS temp dir, e.g. a one-off rewrite script): both sides are realpath'd, so a symlink out of the temp dir is still judged as the repo path it writes, and a repository that merely lives under the temp dir (a test repo) keeps the stage rules.
- **Flow by default**: in a repo with `.dev-flow/`, an unbound session cannot touch source files at all — every request goes through `flow start`/`flow use`; there is no bypass. `flow start` refuses when the main checkout isn't on the default branch, worktrees are always created from the default branch, and the agent cannot check feature branches out in the main checkout.
- **Gates can't be skipped**: flow state is untouchable by the agent (edit tool and bash) — writing `state.json` or `claim.json`, moving either away, or deleting a feature directory or the whole `.dev-flow/` folder all block, at a relative **or an absolute** path (an absolute path names the same folder); the `project.md` playbook is operator-owned — the main-checkout copy is blocked for the agent outright, and only a **docs or patch flow's** worktree copy may be edited (normal features route playbook changes to their own flow); every sanctioned playbook overwrite keeps the previous file as `project.md.bak`; every recorded check carries a fingerprint of the worktree it ran against (paths, uncommitted diff, HEAD) — an edit-tool change clears the records at once, and any other write (`sed -i`, a formatter, codegen through bash) is caught at the gate itself, which reruns what went stale and says so; edit/write paths are judged by realpath, so a symlink out of the worktree cannot launder a confined write.
- **Your project's own rules**: every `deny`/`confirm`/`protect`/`forbid` rule from the `flow-guard` block in project.md `## global` runs on every bash command and every edit/write path — in every stage and binding, ahead of dev-flow's own verdicts. A block always carries the reason you wrote.
- **The rules reach the agent, they are not left in a file it may never read**: pi advertises a skill and loads it only if the model chooses to, so the ground rules of the dev-flow skill are injected into every session once (and again after a compaction that summarized them away), and each stage's turn names the one playbook file that stage needs. The guards enforce the same discipline either way — this is what makes the agent follow it without being stuck.
- **A forced push asks the operator**: `git push` carrying `--force`, `--force-with-lease…` or `-f` is confirmed before it runs — a rewritten branch is history CI and other sessions already have. The one spelling that answers for it is the prefix the refusal prints (`DEV_FLOW_ALLOW_REWRITE=1 git push -f …`), the same "the operator said so" contract as `DEV_FLOW_ALLOW_MAIN`. Local history rewrites (`git filter-branch --force`) are not pushes and stay free, so a project never needs a blunt flag-only deny of its own.
- **Shell calls can't hang forever**: pi kills a shell command only when the model passed a `timeout` — it omits one in ~75% of calls, so a `gh` waiting on a prompt or a dead `curl` froze sessions for hours. In an initiated repo (`.dev-flow/project.md` exists) the call is therefore by default **refused** until the model states one (`bashTimeout = require`): the refusal names the exact value the class table would have armed, so the retry is a one-token fix and the call line in the transcript shows the timeout that is actually running. The injected class default (30s quick inspection · 120s local git and unknown · 180s network · 300s containers/services · 900s builds/tests/installs · 1,200s watchers like `gh run watch`) stays armed underneath either way, and absurd explicit values clamp to 1,800s. A project that would rather not nag sets `bashTimeout = inject` in `## global` — then the default is armed silently and the call passes. Uninitiated folders hear nothing at all: without the playbook dev-flow enforces no rules.
- **Compact-aware**: dev-flow paces compaction the way pi counts it: the self-compaction trigger uses the *effective* `reserveTokens` (settings and per-model overrides, not the built-in default) and is checked at every turn end, not only when a run settles. When pi force-compacts on its threshold anyway — the chat-only states (open tier-2 dialog, plan discussion not yet in `plan/`) refuse to self-compact by design — dev-flow takes the compaction over and redoes it with flow-aware summary instructions (overflow recovery keeps pi's compact-and-retry and is left alone). Auto-compaction turned off in settings is honored; a note about any compaction dev-flow could not shape still lands in `reflections.md`.
- The active stage is injected into the model's context every turn; the bundled `dev-flow` skill is the agent's playbook, including the recommended sequences: pre-release — review (`/flow-review`) → fixes → docs → changelog/version bump; release — PR → merge → tag → `release-done`. The package also bundles the review skills themselves — `/flow-review` (a change, read against its `plan/plan.md`), `/flow-audit` (the whole repo) and `/flow-simplify` (the operative cut: deletes dead and duplicated code, one verified commit at a time) — and injects a built-in senior-discipline guidance at implement stage unless the playbook sets its own `prose.implement`.
- **Status bar**: an initiated repo (`.dev-flow/project.md` exists) always carries the dev-flow footer; while the session **is bound** to a feature it shows `<stage glyph> <slug>` plus mode markers — stages 📝 plan, 🔨 implement, 🔍 pre-release, 🚀 release; markers ⚡ patch, 📚 docs, 🎱 yolo, and `⚠️` while a check of the bound feature fails (emoji render in their own color, so the footer carries no theme coloring). An **unbound** session never sees a slug — it shows `⧖ N flow(s) open` (unfinished-feature count only, no warning marker) or `⧖ no flows` when nothing is in flight; a session binds only via `flow start`/`flow use`, never by opening. The footer repaints at session start, at the start of every agent run (the earliest a late-attaching web client is listening) and after every gate; an uninitiated folder gets no footer at all. `flow status` is the spelled-out surface: `stage=implement`, the per-feature mode tags (`[ui]`, `[yolo]`, `[no-version]`) and the `flow=<ver>` stamp of the build that started the feature.

## Limitations (deliberate, v1)

- Production deployment stays manual by design (phase 2 automation); `flow finish` only closes the flow.
- Bash git-guard parses shell segments to find real `git` invocations (honoring `cd … &&`, `git -C`, full paths) and judges them by the branch of the directory the command runs in — the feature worktree when you work there. A `~` / `$HOME` in that directory is expanded the way the shell would (any other `$VAR` stays literal); when the directory cannot be answered for, no branch is invented — the guard stays quiet and git itself fails. A commit refusal names the directory it judged, so a wrong verdict is readable as one. Splitting respects quotes, so text inside `"…"` / `'…'` is an argument's prose, never a command: `gh pr create --body "…git commit…"` is not a commit. A wrapper that execs its argument (`sh`, `bash`, `node`, `python`, `env`, `xargs`, …) is judged on its payload — `sh -c "git push origin main"` is a push. An exotic wrapper (subshell, variable) could still slip past; the `.git` hook covers plain commits outside Pi too, but neither is a security sandbox.
- `flow-guard` and the git guard are guardrails for what an agent types, **not a parser and not a sandbox**. Doing-verbs an agent types in the clear are caught; quoted prose is deliberately read as data, so a wrapper deciding to run something at runtime is out of reach. Write rules for the common mistakes, keep validation you actually depend on in scripts and CI.
- `protect`/`forbid` judge edit/write tools against the repo root the session runs in — a copy of a protected file inside a feature worktree is a different path, and a bash redirect (`echo k > docker/secrets/k`) is a `deny` rule's job. A glob in the **last** segment matches the siblings of that directory (`protect docker/.env*` covers `.env` and `.env.bak-20261021`); a glob anywhere else names one literal path, so `flow init` warns that the rule guards nothing.
- The read-only heads that get skipped (`find`, `awk`, `jq`, …) can write in their own dialects — `find . -delete`, `awk … > file`. A deny rule on `rm` does not cover them; write a rule for the effect you actually need to prevent.
- Gate commands (`test`, `check`, `verify` build, `health`) run inside the feature worktree once it exists — the code under test lives there; before the worktree exists they run in the main checkout.
- A bash segment whose first word is `flow` is always refused, with no per-project opt-out: dev-flow assumes the name is free because there is no `flow` CLI. A project that really ships a binary called `flow` calls it by path (`/usr/bin/flow`, `./node_modules/.bin/flow`) or through `npx` — the rule matches the head token only, so those pass.
