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:
parent
1a29ff50b2
commit
7fa52772b3
4 changed files with 510 additions and 8 deletions
26
README.md
26
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-<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
118
docs/building.md
Normal 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
146
docs/jj-commitd.md
Normal 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
228
docs/planctl.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue