From 7fa52772b3683251b2ea6ff83cf8ad13110ff630 Mon Sep 17 00:00:00 2001 From: Sid Date: Tue, 21 Apr 2026 21:37:17 -0600 Subject: [PATCH] docs: add reference docs for both binaries + consolidated build guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- README.md | 26 ++++-- docs/building.md | 118 +++++++++++++++++++++++ docs/jj-commitd.md | 146 +++++++++++++++++++++++++++++ docs/planctl.md | 228 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 510 insertions(+), 8 deletions(-) create mode 100644 docs/building.md create mode 100644 docs/jj-commitd.md create mode 100644 docs/planctl.md diff --git a/README.md b/README.md index 2817cc0..121662a 100644 --- a/README.md +++ b/README.md @@ -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-.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/-/`). 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 `:: [] : `; 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 diff --git a/docs/building.md b/docs/building.md new file mode 100644 index 0000000..ae97aa3 --- /dev/null +++ b/docs/building.md @@ -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@ +``` + +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. diff --git a/docs/jj-commitd.md b/docs/jj-commitd.md new file mode 100644 index 0000000..0fc6f04 --- /dev/null +++ b/docs/jj-commitd.md @@ -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)│→ │ .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-.sock` +- **Log:** `/tmp/jj-commitd-.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 -- ` to confirm there are real changes (avoids empty commits from Claude re-reading an unchanged file). + - Runs `jj commit -m "wip(claude:): " -- ` 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-.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--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-.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. diff --git a/docs/planctl.md b/docs/planctl.md new file mode 100644 index 0000000..1ca619d --- /dev/null +++ b/docs/planctl.md @@ -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 ` 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 `-` (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 , 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[a]` / `D§[.…]` 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 `_: _` where: + +- `` is one of `Requirements`, `Design`. +- `` is one or more `` values separated by `, ` (comma-space). +- `` is `` optionally followed by `( )` annotation. +- For `Requirements`: `` 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`: `` 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 ` 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 ` 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/-/ 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.