# 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.