229 lines
11 KiB
Markdown
229 lines
11 KiB
Markdown
|
|
# 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.
|