docs: add reference docs for both binaries + consolidated build guide

* docs/building.md — how to build jj-commitd and planctl from source,
  requirements, `go install` recipes, reproducible-build flags
  (`-trimpath -ldflags="-buildid="` per planctl PRD R7.3), test +
  benchmark commands, cross-compile, CI matrix link.
* docs/planctl.md — full command reference beyond --help: plan-dir
  discovery cases A-D, all 9 classification codes with severity and
  meaning, tag-grammar spec (including paren-aware annotations + the
  tag-unclosed recovery semantics), the five EARS regexes and the
  bold-prefix exemption, JSON schema, worked examples for pre-commit
  / CI / multi-plan / JSON consumers.
* docs/jj-commitd.md — architecture diagram, socket event protocol
  (session-start, post-edit, session-end, shutdown, status) with
  field tables, all env vars (REPO_ROOT, JJ_HOOK_DEBOUNCE_SEC,
  JJ_HOOK_STALE_MIN, JJ_HOOK_AUTO_SQUASH), baked-in timing constants,
  socket / log paths, fallback mode, troubleshooting.
* README.md — expanded the jj-commitd bullets and linked to each
  reference from a new "Documentation" section; kept top-level
  summaries terse.
This commit is contained in:
Sid 2026-04-21 21:37:17 -06:00
parent 1a29ff50b2
commit 7fa52772b3
4 changed files with 510 additions and 8 deletions

View file

@ -21,23 +21,33 @@ Reusable [jj](https://martinvonz.github.io/jj/) + [Claude Code](https://docs.ant
A Go daemon that batches file edits into debounced commits via a Unix socket.
- **How it works**: `session-start` launches the daemon; `post-edit` sends file paths over a socket; a debounce timer (default 3s) batches edits into a single `jj commit`; `session-end` flushes pending commits and shuts down
- **Build**: `go build -o bin/jj-commitd ./cmd/jj-commitd/` or `go install ./cmd/jj-commitd/`
- **Config**: `JJ_HOOK_DEBOUNCE_SEC` (default `3`), `JJ_HOOK_STALE_MIN` (default `30`)
- **Session inventory**: on `session-start`, returns list of other active sessions and their files
- **Logging**: daemon log at `/tmp/jj-commitd-{repo_id}.log`
- **Fallback**: if the binary is not found, the hook falls back to file-tracking-only mode (commits at session end)
- **How it works**: `session-start` launches the daemon; `post-edit` sends file paths over a socket; a debounce timer (default 3s) batches edits into a single `jj commit`; `session-end` flushes pending commits and shuts down.
- **Install**: `go install forgejo.zerova.net/sid/template-jj/cmd/jj-commitd@latest`.
- **Config**: `JJ_HOOK_DEBOUNCE_SEC` (default `3`, max `30`), `JJ_HOOK_STALE_MIN` (default `30`), `JJ_HOOK_AUTO_SQUASH=1` to squash at session end.
- **Session inventory**: on `session-start`, returns other active sessions and their tracked files.
- **Logging**: daemon log at `/tmp/jj-commitd-<repo_id>.log`.
- **Fallback**: without the binary on `PATH`, the hook falls back to file-tracking-only mode that commits at session end.
- **Full reference**: [`docs/jj-commitd.md`](docs/jj-commitd.md).
## planctl
Lint tool for spec-driven plan directories (`dev/plans/<YYWWD>-<feature-slug>/`). Validates traceability tags, cross-references between `prd.md` / `design.md` / `tasks.md`, file-presence at close-out, and EARS-keyword conformance on PRD acceptance criteria. Read-only by design — v1 ships `planctl lint` and nothing else.
- **Install**: `go install forgejo.zerova.net/sid/template-jj/cmd/planctl@latest`
- **Install**: `go install forgejo.zerova.net/sid/template-jj/cmd/planctl@latest`.
- **Usage**: `planctl lint [plan-dir]` — pass a path, or invoke from anywhere inside a plan dir or a repo containing `dev/plans/`.
- **Output**: one line per diagnostic in the form `<path>:<line>: [<severity>] <code>: <message>`; a clean plan prints one summary line. `--format=json` emits JSONL for tooling.
- **Flags**: `--strict` (warnings → exit 1), `--no-ears` (skip EARS check), `--format={text,json}`, `--color={auto,always,never}` (no-op in v1).
- **Classification codes**: `tag-syntax`, `tag-unclosed`, `orphan-requirement`, `orphan-design`, `uncovered-requirement`, `uncovered-design`, `missing-prd`, `missing-closeout-file`, `ears-violation`. These are grep-stable public API.
- **Spec**: see [`dev/plans/26172-planctl/`](dev/plans/26172-planctl/) for the PRD, design, and task list that defined the tool.
- **Full reference**: [`docs/planctl.md`](docs/planctl.md).
- **Spec**: [`dev/plans/26172-planctl/`](dev/plans/26172-planctl/) — PRD, design, task list, and codex-review log.
## Documentation
- [`docs/building.md`](docs/building.md) — build both binaries from source, reproducible builds, cross-compile, CI matrix.
- [`docs/planctl.md`](docs/planctl.md) — full `planctl` command reference.
- [`docs/jj-commitd.md`](docs/jj-commitd.md) — full `jj-commitd` daemon reference.
- [`INTEGRATION.md`](INTEGRATION.md) — integrate this template into an existing or new project.
- [`AGENTS.md`](AGENTS.md) — cross-agent working conventions.
## Prerequisites

118
docs/building.md Normal file
View file

@ -0,0 +1,118 @@
# Building from source
This repo ships two Go binaries under `cmd/`:
| Binary | Purpose | Path |
|--------------|-----------------------------------------------------------------|-------------------------|
| `jj-commitd` | Debounced commit daemon for Claude Code sessions (optional). | `cmd/jj-commitd/` |
| `planctl` | Lint tool for spec-driven plan directories. | `cmd/planctl/` |
Both are pure-Go, standard-module-layout, and require no system libraries at runtime. `jj-commitd` shells out to the `jj` CLI; `planctl` has no runtime dependencies beyond the Go standard library and the one compiled-in `github.com/yuin/goldmark` module.
## Requirements
- **Go 1.22 or newer** on `PATH`. The repo's `go.mod` pins a higher toolchain (`go 1.26.1`) for development, but both binaries compile on 1.22 per PRD R7.1 and the CI matrix in `.forgejo/workflows/planctl.yml`.
- **`jj`** — only needed at *runtime* for `jj-commitd`. Not a build dependency.
- No other tools needed for a vanilla build.
## Installing with `go install` (recommended)
Places the binary at `$GOBIN` (falls back to `$GOPATH/bin`, defaulting to `~/go/bin/`). Make sure that directory is on your `PATH`.
```bash
# From anywhere:
go install forgejo.zerova.net/sid/template-jj/cmd/planctl@latest
go install forgejo.zerova.net/sid/template-jj/cmd/jj-commitd@latest
# Pin a specific tag or commit:
go install forgejo.zerova.net/sid/template-jj/cmd/planctl@v1.0.0
go install forgejo.zerova.net/sid/template-jj/cmd/planctl@<commit-sha>
```
Both binaries are tagged in lockstep with the repo.
## Building from a checkout
```bash
git clone https://forgejo.zerova.net/sid/template-jj.git
cd template-jj
# Binaries to ./bin/
go build -o bin/planctl ./cmd/planctl/
go build -o bin/jj-commitd ./cmd/jj-commitd/
```
`go build` without `-o` emits the binary into the current directory using the package directory name — useful for one-off checks:
```bash
cd cmd/planctl && go build && ./planctl --version
```
## Reproducible builds
Per PRD R7.3 for `planctl``go build -trimpath -ldflags="-buildid="` produces byte-stable binaries given the same source.
```bash
go build -trimpath -ldflags="-buildid=" -o bin/planctl ./cmd/planctl/
```
`-trimpath` strips the absolute checkout path from the binary; `-buildid=` zeroes the per-build Go toolchain fingerprint. The output is suitable for deterministic distribution and CI caching.
## Verifying the build
```bash
./bin/planctl --version
# planctl 0.1.0-dev
./bin/jj-commitd status # over an existing socket — see docs/jj-commitd.md
```
Run the test suite:
```bash
go test ./cmd/planctl/...
go test ./cmd/jj-commitd/...
```
`planctl` also has a benchmark:
```bash
go test -bench=. -benchtime=3x -run=^$ ./cmd/planctl/
```
Expected output on a 2023-era machine: ~1 ms per `Benchmark_Lint_BigPlan` invocation (the ~90 KB `testdata/perf/big-plan` fixture), well under the PRD success metric M3 ceiling of 100 ms.
## CI matrix
`planctl` is exercised in `.forgejo/workflows/planctl.yml` across:
- `os`: `ubuntu-latest`, `macos-latest`, `windows-latest`
- `go`: `1.22`, `stable`
A dedicated benchmark job runs once per push on `ubuntu-latest` + `stable`. `jj-commitd` has no CI cell yet — it's covered by its own package tests and integration in practice.
## Binary sizes
On `darwin/arm64` with Go 1.26.1, stripped with `-ldflags="-s -w"`:
- `planctl`: ~5 MB (includes goldmark).
- `jj-commitd`: ~3 MB (stdlib only).
Both well under any practical size budget.
## Cross-compiling
Standard Go cross-compile:
```bash
GOOS=linux GOARCH=amd64 go build -o bin/planctl-linux-amd64 ./cmd/planctl/
GOOS=windows GOARCH=amd64 go build -o bin/planctl.exe ./cmd/planctl/
```
No cgo dependencies; cross-builds work on any platform with the Go toolchain.
## See also
- [`docs/planctl.md`](planctl.md) — `planctl` command reference.
- [`docs/jj-commitd.md`](jj-commitd.md) — `jj-commitd` command reference.
- [`INTEGRATION.md`](../INTEGRATION.md) — integrating this template into an existing or new project.

146
docs/jj-commitd.md Normal file
View file

@ -0,0 +1,146 @@
# jj-commitd — command reference
Debounced commit daemon for Claude Code sessions. Per-session, file-scoped commits with ~3 s debouncing so rapid successive edits batch into one `jj commit` instead of racing.
Optional. If the binary isn't on `PATH`, the hook script falls back to file-tracking-only mode that commits at session end.
## Install
```bash
go install forgejo.zerova.net/sid/template-jj/cmd/jj-commitd@latest
```
See [`docs/building.md`](building.md) for building from source.
## Architecture
```
Claude Code hooks (bash) Unix socket Go daemon jj CLI
┌─────────────────────┐ ┌────────────────────┐ ┌──────────────┐ ┌────────┐
│ session-start │→ │ │→ │ │→ │ jj │
│ post-edit (edit N) │→ │ /tmp/jj-commitd- │ │ jj-commitd │→ │ commit │
│ post-edit (edit N+1)│→ │ <repo_id>.sock │ │ (this binary)│ │ │
│ session-end │→ │ │→ │ │→ │ jj ... │
└─────────────────────┘ └────────────────────┘ └──────────────┘ └────────┘
```
- The bash hook client ([`scripts/jj-hook.sh`](../scripts/jj-hook.sh)) is installed into Claude Code's `.claude/settings.json` as an event hook.
- On the first `session-start` the hook auto-spawns the daemon in the background.
- Events travel over the Unix socket as one JSON object per line.
- The daemon serialises all `jj` invocations through a single mutex so concurrent sessions never race.
- When the last session ends and an idle timeout passes (30 min), the daemon exits and cleans up its socket.
## Usage
The binary is normally *not* invoked directly — `scripts/jj-hook.sh` manages its lifecycle. For debugging / manual testing:
```bash
# Start manually from a repo root (daemon blocks in foreground):
REPO_ROOT=$(pwd) jj-commitd
# Ask an existing daemon to log its status (writes to the daemon's log file):
scripts/jj-hook.sh status
# Gracefully shut down a running daemon:
scripts/jj-hook.sh session-end # (or send a shutdown event directly — see below)
```
There is no `--help`, no `--version`, and no subcommand dispatch. The daemon reads all configuration from environment variables.
## Events
The daemon consumes one JSON object per line over its Unix socket. All events are one-shot request/response; the daemon closes the connection after replying.
| Event | Fields | Semantics |
|-----------------|------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------|
| `session-start` | `session_id`, `repo_root`, `pid` | Register a session. Response includes the inventory of other active sessions + their tracked files. |
| `post-edit` | `session_id`, `file` (absolute path), `repo_root` | Track a file edit. Resets the session's debounce timer; the next idle window triggers `jj commit`. |
| `session-end` | `session_id` | Flush any pending commit, record the session as ended. Daemon exits once all sessions end + idle out. |
| `shutdown` | — | Immediate graceful shutdown. Used by cleanup scripts. |
| `status` | — | Snapshot current session state to the log file. Returns `{"ok": true}`. |
Unknown events are rejected with `{"ok": false, "error": "unknown event"}`.
### Example — manual protocol poke
```bash
# Send a status request to an existing daemon:
echo '{"event":"status"}' \
| nc -U /tmp/jj-commitd-$(echo -n $(pwd) | shasum -a 256 | head -c 16).sock
```
(The `repo_id` is a SHA-256 prefix of the absolute repo root — see `repoID()` in `cmd/jj-commitd/main.go`.)
## Configuration (environment variables)
| Variable | Default | Max | Effect |
|--------------------------|---------|-------|--------------------------------------------------------------------------|
| `REPO_ROOT` | `$(pwd)` | — | Repo root the daemon manages. Auto-detected from `cwd` if unset. |
| `JJ_HOOK_DEBOUNCE_SEC` | `3` | `30` | Debounce window in seconds. Edits within this window batch into one commit. |
| `JJ_HOOK_STALE_MIN` | `30` | — | Minutes before a session with a dead PID is reaped. |
| `JJ_HOOK_AUTO_SQUASH` | `0` | — | When `1`, `session-end` triggers the `squash-wip` flow (squashes the session's WIP commits into one). |
Invalid or zero values for the numeric vars fall back to defaults.
## Paths
All daemon state is per-repo, keyed by a SHA-256 prefix of `$REPO_ROOT`:
- **Socket:** `/tmp/jj-commitd-<repo_id>.sock`
- **Log:** `/tmp/jj-commitd-<repo_id>.log` (rotated when it exceeds 512 KB)
Both clean up automatically when the daemon exits. Stale sockets from a crashed daemon are detected via a `DialTimeout` probe on startup and unlinked.
## Timing constants
All baked in at `cmd/jj-commitd/main.go:46-53`; not user-configurable beyond the env vars above.
| Constant | Value | Purpose |
|----------------------|--------|----------------------------------------------------------------------|
| `defaultDebounce` | 3 s | Default batching window for post-edit events. |
| `quiescenceWindow` | 500 ms | Must see no new edits for this long before a commit fires. |
| `maxDebounce` | 30 s | Cap on `JJ_HOOK_DEBOUNCE_SEC`. |
| `idleShutdown` | 30 m | Time after the last session ends before the daemon self-exits. |
| `defaultStaleSessionAge` | 30 m | Default stale-session reap age (overridable via `JJ_HOOK_STALE_MIN`). |
| `maxLogSize` | 512 KB | Log rotation threshold. |
## Session lifecycle
1. **`session-start`** registers a new session with an empty `Files` map and `StartedAt = now`. The response carries an inventory of *other* active sessions — a Claude Code agent can inspect this to avoid stepping on another session's tracked files.
2. **`post-edit`** appends the edited file to the session's dedup set and resets the debounce timer. When the timer fires and the session has been idle for `quiescenceWindow`, the daemon:
- Runs `jj diff --summary -- <tracked files>` to confirm there are real changes (avoids empty commits from Claude re-reading an unchanged file).
- Runs `jj commit -m "wip(claude:<session>): <file-list>" -- <tracked files>` under a global `jjLock` so concurrent sessions can't race.
3. **`session-end`** flushes any pending commit and marks the session ended. If `JJ_HOOK_AUTO_SQUASH=1`, the hook then invokes the squash-wip flow.
4. When all sessions have ended AND no new traffic arrives for `idleShutdown` (30 minutes), the daemon exits cleanly.
## Conflict detection
When two sessions touch the same file, the daemon logs a warning (grep `/tmp/jj-commitd-<repo_id>.log` for `conflict`) and attributes the edit to whichever session's timer fires first. Per-session commit messages keep the history readable even when multiple agents interleave.
## Legacy cleanup
On startup the daemon sweeps and removes any `/tmp/jj-claude-*-files` tracking files left over from pre-daemon hook versions. Safe to run alongside old installations — it won't disrupt an in-flight older hook since the files it cleans are ephemeral per-session markers.
## Fallback mode
If `jj-commitd` is not on `PATH` when `scripts/jj-hook.sh session-start` runs, the hook falls back to file-tracking-only mode:
- Every `post-edit` appends to `/tmp/jj-claude-<session>-files`.
- On `session-end`, one big `jj commit` flushes all tracked files at once.
- No debouncing, no per-session isolation beyond the file list itself.
Functional, but loses the debounce and live-status benefits of the daemon.
## Troubleshooting
- **"connection refused" from the hook** — stale socket. The daemon detects these on startup, but if you see it mid-session, kill any leftover `jj-commitd` processes (`pkill jj-commitd`) and retry; the next `session-start` will relaunch.
- **Edits not committing** — check `/tmp/jj-commitd-<repo_id>.log`. Look for `commit error` or `jj exec` lines; the jj CLI's stderr is captured there.
- **Disappearing edits across sessions** — grep the log for the file path; you're likely hitting a cross-session conflict.
- **Daemon won't shut down**`scripts/jj-hook.sh status` to inspect active sessions; send the `shutdown` event directly (see "Manual protocol poke" above) to force exit.
## See also
- [`INTEGRATION.md`](../INTEGRATION.md) — installing hooks in a new or existing project.
- [`scripts/jj-hook.sh`](../scripts/jj-hook.sh) — the bash client that talks to the daemon.
- [`docs/planctl.md`](planctl.md) — the other binary in this repo.
- [`docs/building.md`](building.md) — build from source.

228
docs/planctl.md Normal file
View file

@ -0,0 +1,228 @@
# planctl — command reference
Lint tool for spec-driven plan directories. Read-only by design. v1 ships the `lint` subcommand only; task-state commands (`list`, `next`, `complete`, `new-plan`) are reserved for v2 and currently exit 2.
**Spec:** [`dev/plans/26172-planctl/`](../dev/plans/26172-planctl/) — PRD, design, task list, and codex-review session log.
## Install
```bash
go install forgejo.zerova.net/sid/template-jj/cmd/planctl@latest
```
See [`docs/building.md`](building.md) for building from source.
## Synopsis
```
planctl lint [flags] [plan-dir]
planctl --help
planctl --version
```
## Flags
| Flag | Default | Effect |
|-----------------------------|----------|------------------------------------------------------------------------------------------|
| `--format={text,json}` | `text` | Output format. `json` emits one JSONL object per diagnostic plus a final summary. |
| `--strict` | off | Treat warnings as errors for the exit code (exit 1 on any diagnostic). |
| `--no-ears` | off | Suppress `ears-violation` warnings entirely. |
| `--color={auto,always,never}` | `auto` | Accepted for forward-compatibility; no-op in v1 (TTY detection deferred). |
| `--help`, `-h` | — | Show usage and exit. |
| `--version` | — | Print version and exit. |
Both `--flag=value` and `--flag value` forms are accepted for the two value-taking flags. Unknown flags, unknown subcommands, and invalid flag values exit 2 without attempting any lint work.
## Exit codes
| Code | Meaning |
|------|------------------------------------------------------------------------------------------|
| `0` | No errors (warnings alone are informational without `--strict`). |
| `1` | Any error-severity diagnostic, or any warning under `--strict`. |
| `2` | Tool error, bad invocation, unknown flag/subcommand, or missing explicit path. |
## Plan-directory discovery
When invoked without a positional argument, `planctl` walks upward from the current directory until one of four cases matches (see the `resolvePlans` implementation in `cmd/planctl/main.go`):
- **Case A — explicit path.** `planctl lint <path>` lints exactly that directory. Non-existent path or non-directory → exit 2.
- **Case B — CWD inside a plan dir.** If `cwd` is at or below a directory whose basename matches `^\d{5}-[a-z][a-z0-9-]+$` AND whose parent is `plans/` AND whose grandparent is `dev/`, lint that plan dir.
- **Case C — CWD at or above a repo root.** If an ancestor of `cwd` has `dev/plans/` as an immediate subpath, enumerate every direct child of `dev/plans/` matching `<YYWWD>-<slug>` (excluding `archive/` and anything under it), sort lexicographically, and lint all of them. Multi-plan output per R5.9.
- **Case D — nothing found.** Walk reached filesystem root without a match → exit 2 with the 3-option disambiguation message.
Case B wins over Case C at the same ancestor: deep within a plan-dir's subtree, `planctl lint` targets that plan, not the whole repo's `dev/plans/`.
## Output format
### Text (default, single plan)
Compact — designed to be quoted into an LLM agent's context (PRD G3).
```
tasks.md:42: [error] orphan-requirement: cites R3.7, not declared in prd.md
prd.md:128: [warning] ears-violation: R4.3: body "..." does not match an EARS keyword; consider starting with "WHEN <trigger>, THE SYSTEM SHALL ..."
```
Clean plan: one summary line.
```
26172-planctl: clean (79 tasks, 45 requirements, 21 design sections)
```
No header, no aggregate summary, no inter-plan blank line — PRD R5.10.
### Text (multi-plan — Case C)
```
=== 26167-group-backend ===
tasks.md:90: [warning] ears-violation: ...
=== 26172-planctl ===
26172-planctl: clean (79 tasks, 45 requirements, 21 design sections)
=== 26175-other ===
tasks.md:42: [error] orphan-requirement: ...
3 plans linted, 1 errors, 1 warnings
```
Labels in the aggregate summary are always plural per PRD R5.9 (`1 errors` even when the count is 1 — literal wording).
### JSON (`--format=json`)
JSONL — one object per diagnostic, terminating summary object:
```json
{"plan_dir":"26167-group-backend","path":"tasks.md","line":90,"severity":"warning","code":"ears-violation","message":"..."}
{"plan_dir":"26175-other","path":"tasks.md","line":42,"severity":"error","code":"orphan-requirement","message":"cites R3.7, not declared in prd.md"}
{"summary":{"plans":3,"errors":1,"warnings":1}}
```
Field schema per diagnostic:
| Field | Type | Description |
|------------|--------|-------------------------------------------------------------------|
| `plan_dir` | string | Plan-directory basename (e.g. `26172-planctl`). |
| `path` | string | File relative to the plan dir (e.g. `tasks.md`); `""` for directory-level diagnostics. |
| `line` | int | 1-indexed source line. `0` for directory-level diagnostics. |
| `severity` | string | `"error"` or `"warning"`. |
| `code` | string | Classification code (see below). |
| `message` | string | Human-readable. No trailing period, no newlines. |
Summary object:
```json
{"summary":{"plans":N,"errors":E,"warnings":W}}
```
The same format is used for single- and multi-plan runs — consumers can disambiguate via `plan_dir`.
## Classification codes
Codes are part of the **public grep-stable contract** (design §4). Renaming a code is a breaking change; adding a new code is additive.
| Code | Severity | Meaning |
|---------------------------|----------|------------------------------------------------------------------------------------------|
| `tag-syntax` | error | A tag body failed the `R<n.m>[a]` / `D§<n>[.<m>…]` grammar from PRD R1.1. |
| `tag-unclosed` | error | `_Requirements:` or `_Design:` opener has no matching closing `_` on the same line. |
| `orphan-requirement` | error | `tasks.md` cites an R-id not declared in `prd.md`. |
| `orphan-design` | error | `tasks.md` cites a D§-id not declared in `design.md`. |
| `uncovered-requirement` | error | A PRD R-id is never cited by any `tasks.md` tag. |
| `uncovered-design` | error | A design D§-id is never cited by any `tasks.md` tag. |
| `missing-prd` | error | Plan directory has no `prd.md`. Fatal — R2/R4 checks skip for this plan. |
| `missing-closeout-file` | error | Fully-closed-out `tasks.md` (all `[x]`, at least one task) lacks `codex-sessions.md` or `handoff*.md`. |
| `ears-violation` | warning | PRD acceptance criterion doesn't begin with an EARS keyword. Suppress with `--no-ears`. |
## Tag grammar (PRD R1.1)
A traceability tag is the token `_<Tag>: <ref-list>_` where:
- `<Tag>` is one of `Requirements`, `Design`.
- `<ref-list>` is one or more `<ref>` values separated by `, ` (comma-space).
- `<ref>` is `<id>` optionally followed by `( <free-text> )` annotation.
- For `Requirements`: `<id>` matches `R\d+(\.\d+)+[a-z]?` — e.g. `R1.2`, `R2.3a`, `R10.15`. The literal `infra` is also accepted as a sentinel (R1.7) and is exempt from `orphan-requirement` (R2.9).
- For `Design`: `<id>` matches `D§\d+(\.\d+)*` — e.g. `D§3`, `D§3.4`.
- Both underscores must balance; paren nesting is respected so annotation bodies may contain `_` or `,`.
### Inline-code and fence exclusion (R1.5)
Tag-like tokens inside fenced code blocks, indented code blocks, HTML blocks, and inline `` `` `` spans are ignored. This is implemented via a goldmark AST walk that populates per-line `InCode[]` (block-level) and per-column `LineMask[]` (inline-code span) arrays in the scanner; the indexer consults both before running the tag state machine.
### Multi-tags per line
`_Requirements: R1.2_ _Design: D§3.4_` on one line is fine. See also the tag-unclosed recovery below.
### Tag-unclosed recovery
Under `_Requirements: R3 _Design: D§2_` the state machine emits one `tag-unclosed` diagnostic for the unclosed `_Requirements:` token AND a well-formed `D§2` extraction. The recovery rule: when the forward scan hits another opener (`_Requirements:` / `_Design:`) before finding a closer, the current tag is "unclosed" and scanning resumes at the inner opener. See design §3.3.
## EARS patterns
`planctl` checks PRD acceptance-criterion bodies (text after `R<n.m> ` on a bullet line) against five regexes — all require an explicit `SHALL` or `THEN` continuation:
```
^THE SYSTEM SHALL\b
^WHEN .+?, THE SYSTEM SHALL\b
^WHILE .+?, THE SYSTEM SHALL\b
^WHERE .+?, THE SYSTEM SHALL\b
^IF .+?, THEN (THE SYSTEM SHALL\b|.+\b)
```
### Bold-prefix exemption
A body whose opening `**` is matched by a closing `**` on the same line is exempted from the EARS check. Use this narrow escape hatch for definitional bullets:
```markdown
- R3.3 **Definition — "task".** For R3 file-presence checks, ...
```
An unmatched opening `**` does NOT trigger the exemption — a malformed body that happens to start with `**foo` is still checked.
### Suppressing EARS
`planctl lint --no-ears <plan-dir>` skips the check entirely (PRD R4.4). `--strict` still promotes any remaining warnings to errors.
## Examples
### Pre-commit / pre-codex sanity check
```bash
# From inside a plan-dir:
planctl lint && codex exec ...
# Pointed explicitly:
planctl lint dev/plans/26172-planctl/ || exit 1
```
### CI gate
```bash
# Exit 1 on any warning or error.
planctl lint --strict dev/plans/26172-planctl/
```
### JSON for programmatic consumers
```bash
planctl lint --format=json dev/plans/26172-planctl/ \
| jq 'select(.severity=="error")'
```
### Multi-plan sweep from repo root
```bash
cd ~/myrepo
planctl lint
# ... lints every dev/plans/<YYWWD>-<slug>/ child in lex order, skipping archive/
```
## Performance
PRD success metric M3 targets sub-100ms lint time on a 5-file, ~100 KB plan. The in-repo perf fixture (`cmd/planctl/testdata/perf/big-plan/`) has 36 R-ids, 18 D§ sections, 72 tasks in ~90 KB and runs at ~1 ms on a 2023-era M-series machine. `Benchmark_Lint_BigPlan` measures this in CI; `TestLint_BigPlan_SoftCeiling` emits a `WARNING` to stderr if the 100 ms ceiling is breached (no test failure — CI runner variance is expected).
## See also
- [PRD](../dev/plans/26172-planctl/prd.md) — normative requirements.
- [Design](../dev/plans/26172-planctl/design.md) — architectural decisions, rule internals.
- [`docs/building.md`](building.md) — build from source.
- [`docs/jj-commitd.md`](jj-commitd.md) — the other binary in this repo.