merge: integrate planctl v2 task-cmds + context-window token awareness
Some checks failed
planctl / build-test (1.22, macos-latest) (push) Has been cancelled
planctl / build-test (1.22, ubuntu-latest) (push) Has been cancelled
planctl / build-test (1.22, windows-latest) (push) Has been cancelled
planctl / build-test (stable, macos-latest) (push) Has been cancelled
planctl / build-test (stable, ubuntu-latest) (push) Has been cancelled
planctl / build-test (stable, windows-latest) (push) Has been cancelled
planctl / bench (push) Has been cancelled

This commit is contained in:
sid 2026-04-26 09:18:38 -06:00
commit e913ce1880
73 changed files with 2222 additions and 249 deletions

View file

@ -85,9 +85,12 @@ jj git push # pushes to origin
When multiple agents are active on the same repository simultaneously (the default when using this template's `jj-commitd` session-commit daemon):
- **Each session commits its own tracked files.** See `INTEGRATION.md` for the per-session-bookmark and base-revision-tracking mechanics.
- **Cross-session file conflicts are logged by the daemon** — check `/tmp/jj-commitd-<repo_id>.log` if edits seem to disappear.
- **Don't assume the working copy is yours.** Before writing, `jj status` and confirm `@` is the change you expect. If a pre-flight step (like "is this a plans commit or a feature branch?") is relevant to your skill, run it every invocation — don't rely on session state.
- **Use `jj workspace add` for true parallel isolation.** Each workspace gets its own `@` pointer; a branch switch in one session never flips the working tree underneath another. Setup: `jj workspace add --name <role> ../<project>-<role>`. See `dev/README.md` → "Primary isolation: jj workspaces".
- **Each session commits its own tracked files** under a per-session commit message (`wip(claude:<session_id>): <files>`) and bookmark (`wip/claude-<session_id>` set at session end). History stays attributable even when agents interleave.
- **Use the session inventory on start.** The daemon's `session-start` response lists every other active session and its tracked files. Log it and check for overlap with files your session plans to edit — if another session holds them, coordinate with the user before writing. See `docs/jj-commitd.md` → "Session inventory consumption".
- **`JJ_AGENT_FEATURE` labels your work (planned).** Exporting `JJ_AGENT_FEATURE=<feature-name>` before starting will label commits as `wip(<feature>:<session_id>)` and set a `feat/<feature>` bookmark at session end — human-readable names instead of bare UUIDs. Not yet implemented; see `dev/README.md` → "Planned: JJ_AGENT_FEATURE".
- **Cross-session file conflicts are logged.** Grep `/tmp/jj-commitd-<repo_id>.log` for `conflict` if edits seem to disappear.
- **Don't assume the working copy is yours.** Before writing, `jj status` and confirm `@` is the change you expect. If a pre-flight step is relevant to your skill, run it every invocation — don't rely on session state.
## Placeholder tokens (for template users)

View file

@ -322,3 +322,121 @@ The `.claude/skills/` pack is readable documentation even for tools that don't n
- **Cursor / other IDEs** typically read `AGENTS.md` or `.cursorrules`; point those at `AGENTS.md`.
If you use multiple agents, per-tool session logs (`codex-sessions.md`, `copilot-sessions.md`, `gemini-sessions.md`, `kiro-sessions.md`) keep the review lineage unambiguous for each feature.
---
## Context-Window Token Awareness (planctl)
Claude Code agents using `planctl` can receive threshold-based recommendations about their own context-window fill, so they can commit and start a fresh session *before* running out of tokens mid-task. The signal surfaces in every `planctl lint` invocation when session data is available.
### What the output looks like
When context fill reaches 70%, 85%, or 95% of the limit, `planctl` appends a `context:` line after the plan summary:
```text
26174-planctl-context-tokens: clean (12 tasks, 6 requirements, 0 design sections)
context: 142 k / 200 k tokens (71%) — plan to wrap up this session soon.
```
Band behavior:
| Fill | Band | Severity | Suffix |
|---|---|---|---|
| < 70% | Normal | | silent (no `context:` line) |
| 7084% | Info | `info` | `plan to wrap up this session soon.` |
| 8594% | Warn | `warn` | `commit current work and start a new session after this task.` |
| ≥ 95% | Error | `error` | `stop new work; commit and close out immediately.` |
Token counts are expressed in thousands rounded to the nearest integer (`143 k`, `200 k`); counts below 1000 display as `< 1 k`.
### JSON output
With `--format=json`, the summary object gains a peer `context_window` key:
```json
{
"summary": { "plans": 1, "errors": 0, "warnings": 0 },
"context_window": {
"tokens_used": 142000,
"tokens_limit": 200000,
"pct": 71,
"severity": "info",
"recommendation": "plan to wrap up this session soon."
}
}
```
In multi-plan output (e.g. a repo-root `planctl lint` that walks `dev/plans/**`), the `context:` line appears **once** after the aggregate `N plans linted, …` summary — context is session-level, not plan-level.
### When context is omitted
- **No session data**: no env vars, no PID-keyed session file found → output is identical to the baseline (graceful degradation).
- **Normal band (fill < 70%)**: no `context:` line emitted, to keep healthy sessions quiet.
- **Unknown limit**: when `CLAUDE_CODE_MAX_CONTEXT_TOKENS` is unset, `planctl` emits a raw count (`context: 143 k tokens (limit unknown)`) and, in JSON, only the `tokens_used` field inside `context_window`.
`context_window` **never shifts** the summary's `errors` / `warnings` counts — it's advisory output, not a lint diagnostic.
### Environment variables
| Variable | Source | Purpose |
|---|---|---|
| `CLAUDE_SESSION_ID` | Hook (see below) or Claude Code | Primary session UUID |
| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` | Claude Code | Context-window limit (`200000` for Opus 4.7) |
| `CLAUDE_PROJECT_DIR` | Claude Code | Project slug derivation for transcript lookup |
If `CLAUDE_CODE_MAX_CONTEXT_TOKENS` is not set, `planctl` emits the raw count without a percentage or recommendation. **Hooks are optional**: with no hook and no env var, `planctl` degrades silently — same output as any other CLI tool.
### Hook integration (optional but recommended)
`scripts/jj-hook.sh` already ships the integration. If you copied the template, you have it. The hook adds two behaviors:
**`session-start` case** (template ships this — excerpt):
```bash
FULL_SID=$(printf '%s\n' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null)
if [[ "$FULL_SID" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then
# Primary: propagate session ID to all subprocesses via env
[ -n "${CLAUDE_ENV_FILE:-}" ] && \
echo "export CLAUDE_SESSION_ID=${FULL_SID}" >> "$CLAUDE_ENV_FILE"
# Secondary: PID-keyed fallback for processes that lost env
START_TICKS=$(planctl_start_ticks "$PPID")
mkdir -p /tmp/planctl-sessions
TMPF=$(mktemp /tmp/planctl-sessions/.tmp.XXXXXX)
printf '%s\n%s\n' "$FULL_SID" "$START_TICKS" > "$TMPF"
mv -f "$TMPF" "/tmp/planctl-sessions/${PPID}"
fi
```
**`session-end` case** cleans up the PID-keyed file:
```bash
rm -f "/tmp/planctl-sessions/${PPID}"
```
**`planctl_start_ticks` helper** — platform-specific process-start-time readback used by the PID-reuse defense in `cmd/planctl/context.go`:
```bash
planctl_start_ticks() {
local pid="$1"
if [ -f "/proc/$pid/stat" ]; then
# Linux: field 22 of /proc/<pid>/stat is starttime in clock ticks since boot.
# `comm` can contain ')' and spaces — strip up to and including the LAST ')'.
local tail
tail=$(sed 's/.*)//' "/proc/$pid/stat" 2>/dev/null) || { echo 0; return; }
echo "$tail" | awk '{print $20}' | grep -E '^[0-9]+$' || echo 0
else
# macOS: ps lstart → epoch seconds.
ps -o lstart= -p "$pid" 2>/dev/null | xargs -I{} date -j -f '%a %b %d %T %Y' '{}' '+%s' 2>/dev/null || echo 0
fi
}
```
The Linux branch writes *clock ticks since boot* and the Go resolver (`cmd/planctl/proc_linux.go`) reads the same unit from `/proc/<pid>/stat` — units match within a platform. macOS uses epoch seconds on both sides via `kinfo_proc.kp_proc.p_starttime`. No cross-platform comparison is ever made.
### Troubleshooting
- **`planctl` output never shows a `context:` line**: confirm you're running inside a Claude Code session. `echo $CLAUDE_SESSION_ID` should print a UUID. If not, check that the `SessionStart` hook fires and your shell sources `$CLAUDE_ENV_FILE`.
- **Wrong percentage**: `CLAUDE_CODE_MAX_CONTEXT_TOKENS` may be mis-set. Without it, you'll see `(limit unknown)` instead of a percentage.
- **Multiple concurrent sessions**: each agent's `planctl` resolves its own session via ancestor-PID walk, backed by `/tmp/planctl-sessions/<PPID>` files. Liveness + start-stamp checks prevent cross-session contamination even across PID reuse.
- **No hook installed**: `planctl` works without the hook — if neither `CLAUDE_SESSION_ID` is set nor a PID-keyed file is found during the 8-hop ancestor walk, the context line is silently omitted.

240
cmd/planctl/context.go Normal file
View file

@ -0,0 +1,240 @@
package main
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
// spec:26174-planctl-context-tokens/R2.3+D§3.5
var errNoUsageLine = errors.New("no assistant line with usage object")
// spec:26174-planctl-context-tokens/D§3.3
// pidSessionDir is the on-disk root for PID-keyed session files, written by the
// SessionStart hook. Injected as a variable so tests can stage fixtures in t.TempDir().
var pidSessionDir = "/tmp/planctl-sessions"
// pidWalkMaxHops is the ceiling on process-ancestor lookups during PID-file search (R1.2).
const pidWalkMaxHops = 8
// spec:26174-planctl-context-tokens/R1.1+R1.2+R1.3+R1.4+R1.5+R1.6+R2.1+R2.2+R2.3+R2.4+R2.5+R2.6+D§3.2+D§6.4
// ReadTokenCtx is the ambient constructor: it reads the calling process's
// environment and/or ancestor PID files to discover the session UUID, then
// opens the transcript and extracts the most recent API usage. Returns nil
// when any step fails — callers treat nil as "no context data". Never invokes
// a subprocess (design §6.4).
func ReadTokenCtx() *TokenCtx {
uuid := resolveSessionUUID()
if uuid == "" {
return nil
}
home, _ := os.UserHomeDir()
if home == "" {
return nil
}
path := transcriptPath(uuid, home, os.Getenv("CLAUDE_PROJECT_DIR"))
used, err := parseLastUsage(path)
if err != nil {
return nil
}
return &TokenCtx{Used: used, Limit: readLimit()}
}
// spec:26174-planctl-context-tokens/R1.1+R1.2+R1.3+R1.4+R6.1+R6.3+D§3.3
// resolveSessionUUID returns the session UUID string, or "" if not found.
// Priority: CLAUDE_SESSION_ID env > PID-ancestor walk with liveness + start-stamp checks.
func resolveSessionUUID() string {
if v := os.Getenv("CLAUDE_SESSION_ID"); v != "" {
return v
}
return resolveViaPIDWalk(os.Getpid(), pidSessionDir, ppidOf, processAlive, processStartTimeSec)
}
// resolveViaPIDWalk does the ancestor-PID walk with pluggable platform helpers
// so tests can feed a synthetic chain without needing real processes.
func resolveViaPIDWalk(
startPID int,
dir string,
parentOf func(int) int,
alive func(int) bool,
startStampOf func(int) (int64, error),
) string {
pid := startPID
for hop := 0; hop < pidWalkMaxHops; hop++ {
pid = parentOf(pid)
if pid <= 1 {
return ""
}
path := filepath.Join(dir, strconv.Itoa(pid))
uuid, stamp, err := parsePIDFile(path)
if err != nil {
continue
}
if !alive(pid) {
continue
}
actual, err := startStampOf(pid)
if err != nil || actual != stamp {
continue
}
return uuid
}
return ""
}
// spec:26174-planctl-context-tokens/D§4.2
type transcriptUsage struct {
Type string `json:"type"`
Message struct {
Usage *struct {
InputTokens int64 `json:"input_tokens"`
CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"`
CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
} `json:"usage"`
} `json:"message"`
}
// spec:26174-planctl-context-tokens/R2.1+R2.2+R2.3+R2.6+D§3.5
// parseLastUsage scans a JSONL transcript and returns the summed token count
// (input + cache_read + cache_creation) from the *last* line whose top-level
// "type" is "assistant" and whose "message.usage" object is present.
// Presence qualifies (per PRD R2.1) — the usage object may contain zeros.
// Malformed JSON lines are silently skipped. Returns errNoUsageLine when no
// qualifying line exists.
func parseLastUsage(path string) (int64, error) {
f, err := os.Open(path)
if err != nil {
return 0, err
}
defer f.Close()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 64*1024), 10<<20)
var last int64
var found bool
for sc.Scan() {
var rec transcriptUsage
if err := json.Unmarshal(sc.Bytes(), &rec); err != nil {
continue
}
if rec.Type != "assistant" || rec.Message.Usage == nil {
continue
}
u := rec.Message.Usage
last = u.InputTokens + u.CacheCreationInputTokens + u.CacheReadInputTokens
found = true
}
if err := sc.Err(); err != nil {
return 0, err
}
if !found {
return 0, errNoUsageLine
}
return last, nil
}
// spec:26174-planctl-context-tokens/R6.2+R6.3+D§3.9+D§4.1
// parsePIDFile reads a PID file matching the exact layout in design §4.1:
//
// <session-uuid>\n
// <start-time-stamp>\n
//
// Start-time stamp is platform-specific (clock ticks since boot on Linux, epoch
// seconds on macOS — D§4.1 / §5.5). The parser is strict: both lines must be
// newline-terminated, uuid must be non-empty, stamp must parse as int64, and
// nothing may follow the second newline. Anything else (partial write, extra
// lines, missing final \n) is rejected — a corrupted file should not pass the
// liveness/start-stamp check in resolveViaPIDWalk.
func parsePIDFile(path string) (uuid string, startStamp int64, err error) {
data, err := os.ReadFile(path)
if err != nil {
return "", 0, err
}
rest, ok := strings.CutSuffix(string(data), "\n")
if !ok {
return "", 0, fmt.Errorf("pid file %s: missing terminating newline", path)
}
uuid, stampPart, ok := strings.Cut(rest, "\n")
if !ok {
return "", 0, fmt.Errorf("pid file %s: missing line 2 (start stamp)", path)
}
if uuid == "" {
return "", 0, fmt.Errorf("pid file %s: empty uuid", path)
}
if strings.Contains(stampPart, "\n") {
return "", 0, fmt.Errorf("pid file %s: unexpected trailing content", path)
}
stamp, perr := strconv.ParseInt(stampPart, 10, 64)
if perr != nil {
return "", 0, fmt.Errorf("pid file %s: malformed start stamp: %w", path, perr)
}
return uuid, stamp, nil
}
// spec:26174-planctl-context-tokens/R1.5+R1.6+D§3.4
// transcriptPath assembles the Claude Code transcript path.
// slug = projectDir with every '/' replaced by '-'. Falls back to os.Getwd() when projectDir is "".
func transcriptPath(uuid, home, projectDir string) string {
if projectDir == "" {
projectDir, _ = os.Getwd()
}
slug := strings.ReplaceAll(projectDir, "/", "-")
return filepath.Join(home, ".claude", "projects", slug, uuid+".jsonl")
}
// spec:26174-planctl-context-tokens/R2.4+D§3.6
// readLimit returns CLAUDE_CODE_MAX_CONTEXT_TOKENS as an int64, or 0 if unset/invalid.
func readLimit() int64 {
v := os.Getenv("CLAUDE_CODE_MAX_CONTEXT_TOKENS")
if v == "" {
return 0
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil || n < 0 {
return 0
}
return n
}
// spec:26174-planctl-context-tokens/R2.2+D§3.1
// TokenCtx holds the resolved token-usage data for the current session.
// Limit == 0 means the limit is unknown (CLAUDE_CODE_MAX_CONTEXT_TOKENS absent).
// A nil *TokenCtx means no session data was available; all emitters must
// handle nil gracefully (emit nothing extra).
type TokenCtx struct {
Used int64
Limit int64
}
// spec:26174-planctl-context-tokens/R4.4+D§3.8
func formatTokenCount(n int64) string {
if n < 1000 {
return "< 1 k"
}
k := (n + 500) / 1000
return strconv.FormatInt(k, 10) + " k"
}
// spec:26174-planctl-context-tokens/R3.1+R3.2+R3.3+R3.5+D§3.7
// Classify returns the band's severity label and recommendation suffix.
// Returns ("", "") for nil receiver, unknown limit (Limit == 0), or Normal band (pct < 70).
func (t *TokenCtx) Classify() (severity, msg string) {
if t == nil || t.Limit == 0 {
return "", ""
}
pct := t.Used * 100 / t.Limit
switch {
case pct < 70:
return "", ""
case pct < 85:
return "info", "plan to wrap up this session soon."
case pct < 95:
return "warn", "commit current work and start a new session after this task."
default:
return "error", "stop new work; commit and close out immediately."
}
}

478
cmd/planctl/context_test.go Normal file
View file

@ -0,0 +1,478 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// spec:26174-planctl-context-tokens/R4.4+D§3.8
func TestFormatTokenCount(t *testing.T) {
cases := []struct {
in int64
want string
}{
{0, "< 1 k"},
{500, "< 1 k"},
{999, "< 1 k"},
{1000, "1 k"},
{1499, "1 k"},
{1500, "2 k"},
{143000, "143 k"},
{1000000, "1000 k"},
}
for _, c := range cases {
if got := formatTokenCount(c.in); got != c.want {
t.Errorf("formatTokenCount(%d) = %q, want %q", c.in, got, c.want)
}
}
}
// spec:26174-planctl-context-tokens/R3.1+R3.2+R3.5+D§3.7
func TestClassify_bands(t *testing.T) {
cases := []struct {
name string
ctx *TokenCtx
wantSev string
wantMsg string
}{
{"nil ctx", nil, "", ""},
{"pct 0", &TokenCtx{Used: 0, Limit: 200000}, "", ""},
{"pct 69", &TokenCtx{Used: 138000, Limit: 200000}, "", ""},
{"pct 70 info lower", &TokenCtx{Used: 140000, Limit: 200000}, "info", "plan to wrap up this session soon."},
{"pct 84 info upper", &TokenCtx{Used: 168000, Limit: 200000}, "info", "plan to wrap up this session soon."},
{"pct 85 warn lower", &TokenCtx{Used: 170000, Limit: 200000}, "warn", "commit current work and start a new session after this task."},
{"pct 94 warn upper", &TokenCtx{Used: 188000, Limit: 200000}, "warn", "commit current work and start a new session after this task."},
{"pct 95 error lower", &TokenCtx{Used: 190000, Limit: 200000}, "error", "stop new work; commit and close out immediately."},
{"pct 100 error", &TokenCtx{Used: 200000, Limit: 200000}, "error", "stop new work; commit and close out immediately."},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
sev, msg := c.ctx.Classify()
if sev != c.wantSev || msg != c.wantMsg {
t.Errorf("Classify() = (%q, %q), want (%q, %q)", sev, msg, c.wantSev, c.wantMsg)
}
})
}
}
// spec:26174-planctl-context-tokens/R3.3+D§3.7
func TestClassify_unknownLimit(t *testing.T) {
ctx := &TokenCtx{Used: 143000, Limit: 0}
sev, msg := ctx.Classify()
if sev != "" || msg != "" {
t.Errorf("Classify() with Limit=0 = (%q, %q), want (\"\", \"\")", sev, msg)
}
}
// spec:26174-planctl-context-tokens/R2.4+D§3.6
func TestReadLimit_set(t *testing.T) {
t.Setenv("CLAUDE_CODE_MAX_CONTEXT_TOKENS", "200000")
if got := readLimit(); got != 200000 {
t.Errorf("readLimit() = %d, want 200000", got)
}
}
// spec:26174-planctl-context-tokens/R2.4+D§3.6
func TestReadLimit_unset(t *testing.T) {
t.Setenv("CLAUDE_CODE_MAX_CONTEXT_TOKENS", "")
if got := readLimit(); got != 0 {
t.Errorf("readLimit() unset = %d, want 0", got)
}
}
// spec:26174-planctl-context-tokens/R2.4+D§3.6
func TestReadLimit_invalid(t *testing.T) {
t.Setenv("CLAUDE_CODE_MAX_CONTEXT_TOKENS", "not-a-number")
if got := readLimit(); got != 0 {
t.Errorf("readLimit() invalid = %d, want 0", got)
}
}
// spec:26174-planctl-context-tokens/R1.5+D§3.4
func TestTranscriptPath_slash(t *testing.T) {
got := transcriptPath("abc", "/Users/x", "/Users/x/repos/foo")
want := "/Users/x/.claude/projects/-Users-x-repos-foo/abc.jsonl"
if got != want {
t.Errorf("transcriptPath = %q, want %q", got, want)
}
}
// spec:26174-planctl-context-tokens/R1.5+D§3.4
func TestTranscriptPath_nested(t *testing.T) {
got := transcriptPath("uuid", "/home/u", "/a/b/c/d")
want := "/home/u/.claude/projects/-a-b-c-d/uuid.jsonl"
if got != want {
t.Errorf("transcriptPath nested = %q, want %q", got, want)
}
}
// spec:26174-planctl-context-tokens/R1.6+D§3.4
func TestTranscriptPath_fallbackCwd(t *testing.T) {
got := transcriptPath("uuid", "/h", "")
// fallback uses os.Getwd; just assert shape contains home and the uuid.jsonl suffix
if !strings.HasPrefix(got, "/h/.claude/projects/") {
t.Errorf("fallback path prefix wrong: %q", got)
}
if !strings.HasSuffix(got, "/uuid.jsonl") {
t.Errorf("fallback path suffix wrong: %q", got)
}
}
// spec:26174-planctl-context-tokens/R6.2+D§3.9
func TestParsePIDFile_valid(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "1234")
if err := os.WriteFile(p, []byte("817fec78-6ff1-4fb2-a17c-5708ea5df968\n1700000000\n"), 0o600); err != nil {
t.Fatal(err)
}
uuid, stamp, err := parsePIDFile(p)
if err != nil {
t.Fatalf("parsePIDFile: %v", err)
}
if uuid != "817fec78-6ff1-4fb2-a17c-5708ea5df968" {
t.Errorf("uuid = %q", uuid)
}
if stamp != 1700000000 {
t.Errorf("stamp = %d, want 1700000000", stamp)
}
}
// spec:26174-planctl-context-tokens/R6.3+D§3.9
func TestParsePIDFile_missingLine2(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "1234")
if err := os.WriteFile(p, []byte("817fec78-6ff1-4fb2-a17c-5708ea5df968\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := parsePIDFile(p); err == nil {
t.Fatal("expected error for missing line 2")
}
}
// spec:26174-planctl-context-tokens/R6.3+D§3.9
func TestParsePIDFile_malformedInt(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "1234")
if err := os.WriteFile(p, []byte("817fec78-6ff1-4fb2-a17c-5708ea5df968\nnot-a-number\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := parsePIDFile(p); err == nil {
t.Fatal("expected error for malformed int")
}
}
// spec:26174-planctl-context-tokens/R6.3+D§3.9+D§4.1
func TestParsePIDFile_trailingContent(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "1234")
if err := os.WriteFile(p, []byte("uuid-abc\n1700000000\nunexpected\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := parsePIDFile(p); err == nil {
t.Fatal("expected error for trailing content")
}
}
// spec:26174-planctl-context-tokens/R6.3+D§3.9+D§4.1
// Blank third line also disallowed — design §4.1 specifies exactly two newline-terminated lines.
func TestParsePIDFile_blankThirdLine(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "1234")
if err := os.WriteFile(p, []byte("uuid-abc\n1700000000\n\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := parsePIDFile(p); err == nil {
t.Fatal("expected error for blank third line")
}
}
// spec:26174-planctl-context-tokens/R6.3+D§3.9+D§4.1
// Missing final newline indicates a partial/truncated write.
func TestParsePIDFile_missingFinalNewline(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "1234")
if err := os.WriteFile(p, []byte("uuid-abc\n1700000000"), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := parsePIDFile(p); err == nil {
t.Fatal("expected error for missing final newline")
}
}
// spec:26174-planctl-context-tokens/R2.1+R2.2+D§3.5
func TestParseLastUsage_valid(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "t.jsonl")
content := `{"type":"user","message":{"content":"hi"}}
{"type":"assistant","message":{"usage":{"input_tokens":10,"cache_creation_input_tokens":100,"cache_read_input_tokens":1000}}}
{"type":"assistant","message":{"usage":{"input_tokens":20,"cache_creation_input_tokens":200,"cache_read_input_tokens":2000}}}
`
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
got, err := parseLastUsage(p)
if err != nil {
t.Fatalf("parseLastUsage: %v", err)
}
// last line wins: 20 + 200 + 2000 = 2220
if got != 2220 {
t.Errorf("got %d, want 2220", got)
}
}
// spec:26174-planctl-context-tokens/R2.3+D§3.5
func TestParseLastUsage_malformedMid(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "t.jsonl")
content := `{"type":"assistant","message":{"usage":{"input_tokens":1}}}
{not valid json at all
{"type":"assistant","message":{"usage":{"input_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
`
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
got, err := parseLastUsage(p)
if err != nil {
t.Fatalf("parseLastUsage: %v", err)
}
if got != 5 {
t.Errorf("got %d, want 5 (last good line after malformed skip)", got)
}
}
// spec:26174-planctl-context-tokens/R2.3+D§3.5
func TestParseLastUsage_noAssistant(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "t.jsonl")
content := `{"type":"user","message":{"content":"hi"}}
{"type":"system","message":{"content":"boot"}}
`
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
if _, err := parseLastUsage(p); err == nil {
t.Fatal("expected errNoUsageLine")
}
}
// spec:26174-planctl-context-tokens/R2.3+D§3.5
func TestParseLastUsage_emptyFile(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "t.jsonl")
if err := os.WriteFile(p, []byte(""), 0o600); err != nil {
t.Fatal(err)
}
if _, err := parseLastUsage(p); err == nil {
t.Fatal("expected errNoUsageLine for empty file")
}
}
// spec:26174-planctl-context-tokens/R2.1+R2.6+D§3.5+D§7.1
// parseLastUsage must handle oversized-but-valid JSONL lines — design §3.5
// sizes the scanner buffer at 10 MB to accommodate large messages. Here we
// stuff the assistant line with a 5 MB inert string field (ignored by the
// transcriptUsage struct) to confirm the scanner doesn't truncate.
func TestParseLastUsage_largeLine(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "big.jsonl")
f, err := os.Create(p)
if err != nil {
t.Fatal(err)
}
pad := strings.Repeat("x", 5*1024*1024)
if _, err := f.WriteString(`{"type":"user","message":{"content":"hi"}}` + "\n"); err != nil {
t.Fatal(err)
}
// Single 5 MB assistant line with a harmless "noise" field plus the
// usage object we do care about.
line := `{"type":"assistant","noise":"` + pad + `","message":{"usage":{"input_tokens":11,"cache_creation_input_tokens":22,"cache_read_input_tokens":33}}}` + "\n"
if _, err := f.WriteString(line); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
got, err := parseLastUsage(p)
if err != nil {
t.Fatalf("parseLastUsage on 5 MB line: %v", err)
}
if got != 66 {
t.Errorf("got %d, want 66 (11+22+33)", got)
}
}
// spec:26174-planctl-context-tokens/R2.3+R2.6+D§3.5
// A line exceeding the scanner's 10 MB cap must error out cleanly (no panic)
// — the design specifically sizes the buffer at 10 MB; lines larger than
// that should surface a scanner error via parseLastUsage's sc.Err() check.
func TestParseLastUsage_oversizedLineErrors(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "huge.jsonl")
f, err := os.Create(p)
if err != nil {
t.Fatal(err)
}
pad := strings.Repeat("x", 11*1024*1024) // 11 MB > 10 MB buffer
line := `{"type":"assistant","noise":"` + pad + `","message":{"usage":{"input_tokens":1}}}` + "\n"
if _, err := f.WriteString(line); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
if _, err := parseLastUsage(p); err == nil {
t.Fatal("expected error on oversized line; got none")
}
}
// spec:26174-planctl-context-tokens/R2.1+D§3.5
// Zero-usage lines still qualify per PRD R2.1 (presence, not non-zero).
func TestParseLastUsage_zeroUsageStillQualifies(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "t.jsonl")
content := `{"type":"assistant","message":{"usage":{"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
`
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
got, err := parseLastUsage(p)
if err != nil {
t.Fatalf("parseLastUsage: %v", err)
}
if got != 0 {
t.Errorf("got %d, want 0", got)
}
}
// spec:26174-planctl-context-tokens/R1.1+D§3.3
func TestResolveSessionUUID_envWins(t *testing.T) {
t.Setenv("CLAUDE_SESSION_ID", "env-uuid-12345")
got := resolveSessionUUID()
if got != "env-uuid-12345" {
t.Errorf("resolveSessionUUID = %q, want env-uuid-12345", got)
}
}
// spec:26174-planctl-context-tokens/R1.2+R6.1+R6.3+D§3.3
func TestResolveViaPIDWalk_pidWalkFinds(t *testing.T) {
dir := t.TempDir()
// Ancestor chain: 100 -> 200 (winner) -> 300 -> 1
parents := map[int]int{100: 200, 200: 300, 300: 1}
parentOf := func(pid int) int { return parents[pid] }
alive := func(pid int) bool { return pid == 200 }
stampOf := func(pid int) (int64, error) {
if pid == 200 {
return 1_700_000_000, nil
}
return 0, nil
}
p := filepath.Join(dir, "200")
if err := os.WriteFile(p, []byte("the-uuid\n1700000000\n"), 0o600); err != nil {
t.Fatal(err)
}
got := resolveViaPIDWalk(100, dir, parentOf, alive, stampOf)
if got != "the-uuid" {
t.Errorf("got %q, want the-uuid", got)
}
}
// spec:26174-planctl-context-tokens/R6.3+D§3.3
func TestResolveViaPIDWalk_staleSkipped(t *testing.T) {
dir := t.TempDir()
parents := map[int]int{100: 200, 200: 1}
parentOf := func(pid int) int { return parents[pid] }
// dead: returns false → skip
alive := func(_ int) bool { return false }
stampOf := func(_ int) (int64, error) { return 1_700_000_000, nil }
p := filepath.Join(dir, "200")
if err := os.WriteFile(p, []byte("the-uuid\n1700000000\n"), 0o600); err != nil {
t.Fatal(err)
}
got := resolveViaPIDWalk(100, dir, parentOf, alive, stampOf)
if got != "" {
t.Errorf("stale PID should not resolve, got %q", got)
}
}
// spec:26174-planctl-context-tokens/R6.3+D§3.3
func TestResolveViaPIDWalk_startTimeMismatch(t *testing.T) {
dir := t.TempDir()
parents := map[int]int{100: 200, 200: 1}
parentOf := func(pid int) int { return parents[pid] }
alive := func(_ int) bool { return true }
// file says 1700000000, actual says 9999999999 → mismatch → skip
stampOf := func(_ int) (int64, error) { return 9_999_999_999, nil }
p := filepath.Join(dir, "200")
if err := os.WriteFile(p, []byte("the-uuid\n1700000000\n"), 0o600); err != nil {
t.Fatal(err)
}
got := resolveViaPIDWalk(100, dir, parentOf, alive, stampOf)
if got != "" {
t.Errorf("start-time mismatch should not resolve, got %q", got)
}
}
// spec:26174-planctl-context-tokens/R1.4+D§3.3
func TestResolveViaPIDWalk_exhausted(t *testing.T) {
dir := t.TempDir()
// Deep chain with no files anywhere.
parentOf := func(pid int) int {
if pid <= 1 {
return 0
}
return pid - 1
}
alive := func(_ int) bool { return true }
stampOf := func(_ int) (int64, error) { return 0, nil }
got := resolveViaPIDWalk(100, dir, parentOf, alive, stampOf)
if got != "" {
t.Errorf("exhausted walk should return empty, got %q", got)
}
}
// spec:26174-planctl-context-tokens/R1.1+R1.5+R2.1+R2.2+R2.4+D§3.2
func TestReadTokenCtx_happyPath(t *testing.T) {
home := t.TempDir()
projectDir := "/path/to/proj"
slug := "-path-to-proj"
uuid := "abc-123"
dir := filepath.Join(home, ".claude", "projects", slug)
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
content := `{"type":"assistant","message":{"usage":{"input_tokens":10,"cache_creation_input_tokens":100,"cache_read_input_tokens":1000}}}
`
if err := os.WriteFile(filepath.Join(dir, uuid+".jsonl"), []byte(content), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("HOME", home)
t.Setenv("CLAUDE_PROJECT_DIR", projectDir)
t.Setenv("CLAUDE_SESSION_ID", uuid)
t.Setenv("CLAUDE_CODE_MAX_CONTEXT_TOKENS", "2000")
ctx := ReadTokenCtx()
if ctx == nil {
t.Fatal("ReadTokenCtx returned nil, want non-nil")
}
if ctx.Used != 1110 {
t.Errorf("Used = %d, want 1110", ctx.Used)
}
if ctx.Limit != 2000 {
t.Errorf("Limit = %d, want 2000", ctx.Limit)
}
}
// spec:26174-planctl-context-tokens/R1.4+D§3.2
func TestReadTokenCtx_noSession(t *testing.T) {
t.Setenv("CLAUDE_SESSION_ID", "")
// Re-route PID walk to an empty dir so ancestor probing finds nothing.
empty := t.TempDir()
origDir := pidSessionDir
pidSessionDir = empty
t.Cleanup(func() { pidSessionDir = origDir })
if ctx := ReadTokenCtx(); ctx != nil {
t.Errorf("expected nil for no session, got %+v", ctx)
}
}

View file

@ -40,7 +40,11 @@ type PlanResult struct {
// Directory-level diagnostics (Path == "", typically missing-prd /
// missing-closeout-file) render with the plan-dir basename substituted
// for the path.
func emitText(w io.Writer, plans []PlanResult, strict bool) int {
// spec:26174-planctl-context-tokens/R3.4+R3.5+R4.1+R4.2+R4.3+R4.5+D§4.3
// ctx (when non-nil) drives the context-window line: emitted after the plan
// summary in single-plan mode, after the aggregate summary in multi-plan mode
// (R4.3 — once, not per-plan). Normal-band / nil ctx emits nothing (R3.5).
func emitText(w io.Writer, plans []PlanResult, strict bool, ctx *TokenCtx) int {
multi := len(plans) > 1
for i, p := range plans {
if multi {
@ -54,9 +58,33 @@ func emitText(w io.Writer, plans []PlanResult, strict bool) int {
if multi {
writeAggregateText(w, plans)
}
writeContextText(w, ctx)
return computeExit(plans, strict)
}
// spec:26174-planctl-context-tokens/R3.3+R3.4+R3.5+R4.1+R4.2+D§4.3
// writeContextText appends the canonical `context:` line for Info/Warn/Error
// bands, or the unknown-limit form when Limit == 0. Silent for nil ctx or
// Normal band. Format matches PRD R4.1 exactly.
func writeContextText(w io.Writer, ctx *TokenCtx) {
if ctx == nil {
return
}
if ctx.Limit == 0 {
// Unknown-limit branch is emitted regardless of threshold (R4.2).
fmt.Fprintf(w, "context: %s tokens (limit unknown)\n", formatTokenCount(ctx.Used))
return
}
sev, msg := ctx.Classify()
if sev == "" {
// Normal band: silent (R3.5).
return
}
pct := ctx.Used * 100 / ctx.Limit
fmt.Fprintf(w, "context: %s / %s tokens (%d%%) — %s\n",
formatTokenCount(ctx.Used), formatTokenCount(ctx.Limit), pct, msg)
}
// writePlanText emits the body of a single plan: either the R5.3 clean-
// summary line (zero diagnostics) or the R5.2 diagnostic lines. Shared
// by single-plan and multi-plan branches of emitText.
@ -94,7 +122,12 @@ func writeAggregateText(w io.Writer, plans []PlanResult) {
// diagnostics), but still contribute to the aggregate summary — so
// a `planctl lint` run over N clean plans emits exactly one object
// (the summary).
func emitJSON(w io.Writer, plans []PlanResult, strict bool) int {
// spec:26174-planctl-context-tokens/R5.1+R5.2+R5.3+R5.4+D§4.4
// ctx (when non-nil) contributes a context_window field to the final summary
// object. Normal band / nil ctx → no context_window key. Unknown limit (R5.2)
// → only tokens_used key set. Context never affects the errors/warnings counts
// in summary (R5.4).
func emitJSON(w io.Writer, plans []PlanResult, strict bool, ctx *TokenCtx) int {
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
for _, p := range plans {
@ -110,14 +143,44 @@ func emitJSON(w io.Writer, plans []PlanResult, strict bool) int {
}
}
errs, warns := countSeverities(plans)
_ = enc.Encode(jsonSummary{Summary: summaryBody{
Plans: len(plans),
Errors: errs,
Warnings: warns,
}})
_ = enc.Encode(jsonSummary{
Summary: summaryBody{
Plans: len(plans),
Errors: errs,
Warnings: warns,
},
ContextWin: buildJSONCtxWin(ctx),
})
return computeExit(plans, strict)
}
// spec:26174-planctl-context-tokens/R5.1+R5.2+R5.3+D§4.4
// buildJSONCtxWin returns a populated jsonCtxWin or nil. nil means "don't emit
// the key at all" (Normal band / no session data). R5.2 unknown-limit case
// leaves TokensLimit/Pct/Severity/Recommendation zero-valued so omitempty
// drops them on serialization.
func buildJSONCtxWin(ctx *TokenCtx) *jsonCtxWin {
if ctx == nil {
return nil
}
out := &jsonCtxWin{TokensUsed: ctx.Used}
if ctx.Limit == 0 {
return out
}
sev, msg := ctx.Classify()
if sev == "" {
// Normal band — omit the entire key (R5.3).
return nil
}
pct := int(ctx.Used * 100 / ctx.Limit)
limit := ctx.Limit
out.TokensLimit = &limit
out.Pct = &pct
out.Severity = sev
out.Recommendation = msg
return out
}
// jsonDiag is the JSONL payload for one diagnostic. Field tags pin the
// exact key order produced by encoding/json and keep the contract
// grep-stable for downstream consumers.
@ -140,8 +203,25 @@ type summaryBody struct {
// jsonSummary wraps summaryBody under the "summary" key so the final
// JSONL line is distinguishable from the per-diagnostic objects.
//
// spec:26174-planctl-context-tokens/R5.1+R5.2+R5.3+D§5.4
// ContextWin is omitempty so Normal-band / nil-ctx invocations serialize
// without a context_window key.
type jsonSummary struct {
Summary summaryBody `json:"summary"`
Summary summaryBody `json:"summary"`
ContextWin *jsonCtxWin `json:"context_window,omitempty"`
}
// spec:26174-planctl-context-tokens/R5.1+R5.2+D§5.4
// jsonCtxWin is the context_window payload in JSON mode. Pointer fields for
// TokensLimit and Pct are omitempty: when the limit is unknown per R5.2, they
// marshal out entirely (rather than as 0).
type jsonCtxWin struct {
TokensUsed int64 `json:"tokens_used"`
TokensLimit *int64 `json:"tokens_limit,omitempty"`
Pct *int `json:"pct,omitempty"`
Severity string `json:"severity,omitempty"`
Recommendation string `json:"recommendation,omitempty"`
}
// severityLabel renders a Severity as its canonical string form. Used
@ -171,14 +251,12 @@ func countSeverities(plans []PlanResult) (errs, warns int) {
// ─── v2 emit functions ────────────────────────────────────────────────────────
// planRelFile returns the plan-dir-relative file path used in user-facing output.
// planDir is the basename of the plan directory; file is the filename within it
// (typically "tasks.md"). Result is "planDir/file", matching R1.3 examples.
// planRelFile returns the cwd-relative file path used in user-facing output.
// planDir is the cwd-relative plan directory (e.g. "dev/plans/26174-foo" or ".").
// filepath.Join normalises "." so single-plan invocations emit "tasks.md" not
// "./tasks.md", while nested plans emit "dev/plans/<slug>/tasks.md" per R1.3.
func planRelFile(planDir, file string) string {
if planDir == "" {
return file
}
return planDir + "/" + file
return filepath.Join(planDir, file)
}
// spec:planctl-task-cmds/R1.3+R1.4+D§4+D§7

View file

@ -16,7 +16,7 @@ func TestEmitText_SinglePlanClean(t *testing.T) {
TaskCount: 45, ReqCount: 32, DesCount: 9,
}}
var buf bytes.Buffer
exit := emitText(&buf, plans, false)
exit := emitText(&buf, plans, false, nil)
if exit != 0 {
t.Errorf("want exit 0 (clean), got %d", exit)
}
@ -39,7 +39,7 @@ func TestEmitText_SinglePlanDirty(t *testing.T) {
},
}}
var buf bytes.Buffer
exit := emitText(&buf, plans, false)
exit := emitText(&buf, plans, false, nil)
if exit != 1 {
t.Errorf("want exit 1 (error present), got %d", exit)
}
@ -82,7 +82,7 @@ func TestEmitText_MultiPlan(t *testing.T) {
},
}
var buf bytes.Buffer
exit := emitText(&buf, plans, false)
exit := emitText(&buf, plans, false, nil)
if exit != 1 {
t.Errorf("want exit 1 (error in 3rd plan), got %d", exit)
}
@ -114,10 +114,10 @@ func TestEmitText_StrictPromotesWarnings(t *testing.T) {
{Path: "prd.md", Line: 1, Severity: SevWarning, Code: CodeEARSViolation, Message: "w"},
},
}}
if got := emitText(new(bytes.Buffer), plans, false); got != 0 {
if got := emitText(new(bytes.Buffer), plans, false, nil); got != 0 {
t.Errorf("non-strict warning-only: want exit 0, got %d", got)
}
if got := emitText(new(bytes.Buffer), plans, true); got != 1 {
if got := emitText(new(bytes.Buffer), plans, true, nil); got != 1 {
t.Errorf("strict warning-only: want exit 1, got %d", got)
}
}
@ -141,7 +141,7 @@ func TestEmitJSON_ValidJSONL(t *testing.T) {
},
}
var buf bytes.Buffer
exit := emitJSON(&buf, plans, false)
exit := emitJSON(&buf, plans, false, nil)
if exit != 1 {
t.Errorf("want exit 1, got %d", exit)
}
@ -183,7 +183,7 @@ func TestEmitJSON_CleanPlans(t *testing.T) {
{PlanDir: "b"},
}
var buf bytes.Buffer
exit := emitJSON(&buf, plans, false)
exit := emitJSON(&buf, plans, false, nil)
if exit != 0 {
t.Errorf("want exit 0, got %d", exit)
}
@ -236,10 +236,10 @@ func TestEmit_ExitCodes(t *testing.T) {
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := emitText(new(bytes.Buffer), tc.plans, tc.strict); got != tc.want {
if got := emitText(new(bytes.Buffer), tc.plans, tc.strict, nil); got != tc.want {
t.Errorf("emitText: want %d, got %d", tc.want, got)
}
if got := emitJSON(new(bytes.Buffer), tc.plans, tc.strict); got != tc.want {
if got := emitJSON(new(bytes.Buffer), tc.plans, tc.strict, nil); got != tc.want {
t.Errorf("emitJSON: want %d, got %d", tc.want, got)
}
})
@ -778,3 +778,163 @@ func TestEmitStatus_MultiPlan_JSON(t *testing.T) {
t.Errorf("want done=1, in_progress=1; got %v", sc)
}
}
// spec:26174-planctl-context-tokens/R3.4+R3.5+R4.1+D§4.3
func TestEmitText_ContextInfo(t *testing.T) {
plans := []PlanResult{{PlanDir: "p", TaskCount: 1}}
ctx := &TokenCtx{Used: 142000, Limit: 200000} // 71% → info
var buf bytes.Buffer
emitText(&buf, plans, false, ctx)
want := "context: 142 k / 200 k tokens (71%) — plan to wrap up this session soon.\n"
if !strings.Contains(buf.String(), want) {
t.Errorf("missing context line, got %q", buf.String())
}
}
// spec:26174-planctl-context-tokens/R4.1+D§4.3
func TestEmitText_ContextWarn(t *testing.T) {
plans := []PlanResult{{PlanDir: "p", TaskCount: 1}}
ctx := &TokenCtx{Used: 174000, Limit: 200000} // 87%
var buf bytes.Buffer
emitText(&buf, plans, false, ctx)
want := "context: 174 k / 200 k tokens (87%) — commit current work and start a new session after this task.\n"
if !strings.Contains(buf.String(), want) {
t.Errorf("missing warn context line, got %q", buf.String())
}
}
// spec:26174-planctl-context-tokens/R4.1+D§4.3
func TestEmitText_ContextError(t *testing.T) {
plans := []PlanResult{{PlanDir: "p", TaskCount: 1}}
ctx := &TokenCtx{Used: 192000, Limit: 200000} // 96%
var buf bytes.Buffer
emitText(&buf, plans, false, ctx)
want := "context: 192 k / 200 k tokens (96%) — stop new work; commit and close out immediately.\n"
if !strings.Contains(buf.String(), want) {
t.Errorf("missing error context line, got %q", buf.String())
}
}
// spec:26174-planctl-context-tokens/R3.5+D§4.3
func TestEmitText_ContextNormalSilent(t *testing.T) {
plans := []PlanResult{{PlanDir: "p", TaskCount: 1}}
ctx := &TokenCtx{Used: 50000, Limit: 200000} // 25% → Normal band, silent
var buf bytes.Buffer
emitText(&buf, plans, false, ctx)
if strings.Contains(buf.String(), "context:") {
t.Errorf("Normal band should emit no context line, got %q", buf.String())
}
}
// spec:26174-planctl-context-tokens/R3.3+R4.2+D§4.3
func TestEmitText_ContextUnknownLimit(t *testing.T) {
plans := []PlanResult{{PlanDir: "p", TaskCount: 1}}
ctx := &TokenCtx{Used: 143000, Limit: 0}
var buf bytes.Buffer
emitText(&buf, plans, false, ctx)
want := "context: 143 k tokens (limit unknown)\n"
if !strings.Contains(buf.String(), want) {
t.Errorf("missing unknown-limit context line, got %q", buf.String())
}
if strings.Contains(buf.String(), "—") {
t.Errorf("unknown-limit should emit no recommendation suffix, got %q", buf.String())
}
}
// spec:26174-planctl-context-tokens/R4.3+D§4.3
func TestEmitText_ContextMultiPlanOnce(t *testing.T) {
plans := []PlanResult{
{PlanDir: "a", TaskCount: 1},
{PlanDir: "b", TaskCount: 1},
}
ctx := &TokenCtx{Used: 142000, Limit: 200000}
var buf bytes.Buffer
emitText(&buf, plans, false, ctx)
out := buf.String()
n := strings.Count(out, "context:")
if n != 1 {
t.Errorf("multi-plan should emit context line exactly once, got %d\n%s", n, out)
}
// Must come AFTER the aggregate summary line ("2 plans linted, ...").
agg := strings.Index(out, "2 plans linted")
ctxIdx := strings.Index(out, "context:")
if agg < 0 || ctxIdx < 0 || ctxIdx < agg {
t.Errorf("context line must follow aggregate summary; agg=%d ctx=%d\n%s", agg, ctxIdx, out)
}
}
// spec:26174-planctl-context-tokens/R5.1+R5.4+D§4.4
func TestEmitJSON_ContextWindowKnown(t *testing.T) {
plans := []PlanResult{{PlanDir: "p", TaskCount: 1}}
ctx := &TokenCtx{Used: 142000, Limit: 200000}
var buf bytes.Buffer
emitJSON(&buf, plans, false, ctx)
// Last line is the summary object.
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
last := lines[len(lines)-1]
var obj struct {
Summary map[string]int `json:"summary"`
ContextWindow struct {
TokensUsed int64 `json:"tokens_used"`
TokensLimit int64 `json:"tokens_limit"`
Pct int `json:"pct"`
Severity string `json:"severity"`
Recommendation string `json:"recommendation"`
} `json:"context_window"`
}
if err := json.Unmarshal([]byte(last), &obj); err != nil {
t.Fatalf("unmarshal: %v\n%s", err, last)
}
if obj.Summary["errors"] != 0 || obj.Summary["warnings"] != 0 {
t.Errorf("context should not affect error/warning counts: %+v", obj.Summary)
}
if obj.ContextWindow.TokensUsed != 142000 || obj.ContextWindow.TokensLimit != 200000 {
t.Errorf("tokens = %+v", obj.ContextWindow)
}
if obj.ContextWindow.Pct != 71 || obj.ContextWindow.Severity != "info" {
t.Errorf("pct/severity wrong: %+v", obj.ContextWindow)
}
if obj.ContextWindow.Recommendation == "" {
t.Error("recommendation empty")
}
}
// spec:26174-planctl-context-tokens/R5.2+D§4.4
func TestEmitJSON_ContextWindowUnknownLimit(t *testing.T) {
plans := []PlanResult{{PlanDir: "p", TaskCount: 1}}
ctx := &TokenCtx{Used: 143000, Limit: 0}
var buf bytes.Buffer
emitJSON(&buf, plans, false, ctx)
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
last := lines[len(lines)-1]
// Should contain tokens_used but NOT tokens_limit/pct/severity/recommendation.
if !strings.Contains(last, `"tokens_used":143000`) {
t.Errorf("missing tokens_used: %s", last)
}
for _, omitted := range []string{"tokens_limit", "pct", "severity", "recommendation"} {
if strings.Contains(last, omitted) {
t.Errorf("unknown-limit should omit %q: %s", omitted, last)
}
}
}
// spec:26174-planctl-context-tokens/R5.3+D§4.4
func TestEmitJSON_ContextWindowNormalOmitted(t *testing.T) {
plans := []PlanResult{{PlanDir: "p", TaskCount: 1}}
ctx := &TokenCtx{Used: 50000, Limit: 200000} // Normal
var buf bytes.Buffer
emitJSON(&buf, plans, false, ctx)
if strings.Contains(buf.String(), "context_window") {
t.Errorf("Normal band should omit context_window: %s", buf.String())
}
}
// spec:26174-planctl-context-tokens/R5.3+D§4.4
func TestEmitJSON_ContextWindowNilOmitted(t *testing.T) {
plans := []PlanResult{{PlanDir: "p", TaskCount: 1}}
var buf bytes.Buffer
emitJSON(&buf, plans, false, nil)
if strings.Contains(buf.String(), "context_window") {
t.Errorf("nil ctx should omit context_window: %s", buf.String())
}
}

View file

@ -54,9 +54,16 @@ func run(args []string, stdout, stderr io.Writer) int {
subcommand := args[0]
rest := args[1:]
// spec:26174-planctl-context-tokens/R3.4+D§1+D§2
// Resolve session token context once per invocation, at the top of run()
// so it's available to every subcommand handler (current and future) and
// so it can't be skipped by an early subcommand failure. ReadTokenCtx
// returns nil when no session data is available; all emitters handle nil.
ctx := ReadTokenCtx()
switch subcommand {
case "lint":
return runLint(rest, stdout, stderr)
return runLint(rest, stdout, stderr, ctx)
case "next":
// spec:planctl-task-cmds/R5.5+D§1
return runNext(rest, stdout, stderr)
@ -85,7 +92,7 @@ func run(args []string, stdout, stderr io.Writer) int {
//
// v1 scope per PRD R6.2: strictly read-only — no writes to any plan
// file.
func runLint(args []string, stdout, stderr io.Writer) int {
func runLint(args []string, stdout, stderr io.Writer, ctx *TokenCtx) int {
flags, err := parseLintFlags(args)
if err != nil {
fmt.Fprintln(stderr, err.Error())
@ -111,9 +118,9 @@ func runLint(args []string, stdout, stderr io.Writer) int {
results = append(results, lintPlan(p, flags))
}
if flags.format == "json" {
return emitJSON(stdout, results, flags.strict)
return emitJSON(stdout, results, flags.strict, ctx)
}
return emitText(stdout, results, flags.strict)
return emitText(stdout, results, flags.strict, ctx)
}
// spec:planctl/R5.3+D§3.5
@ -552,6 +559,7 @@ Exit codes:
return 2
}
for _, dir := range plans {
relDir := filepath.Base(dir)
p, err := loadPlan(dir)
if err != nil {
fmt.Fprintln(stderr, err.Error())
@ -562,7 +570,7 @@ Exit codes:
return 2
}
idx := BuildIndex(p)
records := buildTaskRecords(idx, p.Tasks, dir)
records := buildTaskRecords(idx, p.Tasks, relDir)
for i := range records {
if !records[i].Checked {
return emitNext(stdout, &records[i], format)
@ -631,6 +639,7 @@ Exit codes:
}
var results []listPlanResult
for _, dir := range plans {
relDir := filepath.Base(dir)
p, err := loadPlan(dir)
if err != nil {
fmt.Fprintln(stderr, err.Error())
@ -641,7 +650,7 @@ Exit codes:
return 2
}
idx := BuildIndex(p)
records := buildTaskRecords(idx, p.Tasks, dir)
records := buildTaskRecords(idx, p.Tasks, relDir)
results = append(results, listPlanResult{PlanDir: dir, Records: records})
}
return emitList(stdout, results, format, showAll)
@ -729,7 +738,7 @@ Exit codes:
fmt.Fprintf(stderr, "planctl: %s: tasks.md is missing\n", filepath.Base(plans[0]))
return 2
}
records := buildTaskRecordsFromScan(p.Tasks, plans[0])
records := buildTaskRecordsFromScan(p.Tasks, filepath.Base(plans[0]))
rec, result, avail := findTaskByID(records, taskRef)
switch result {
case findBadFormat:
@ -842,6 +851,7 @@ Exit codes:
}
var results []statusPlanResult
for _, dir := range plans {
relDir := filepath.Base(dir)
p, err := loadPlan(dir)
if err != nil {
fmt.Fprintln(stderr, err.Error())
@ -854,7 +864,7 @@ Exit codes:
return p.Tasks
}
return &ScanResult{}
}(), dir)
}(), relDir)
var errs, warns int
for _, d := range pr.Diagnostics {
if d.Code == CodeMissingCloseoutFile {
@ -931,3 +941,12 @@ func copyPlanDir(planDir string) (string, error) {
}
return tmp, nil
}
// relOrBase returns the path of absDir relative to cwd. Falls back to
// filepath.Base(absDir) if Rel fails (e.g. different drive on Windows).
func relOrBase(cwd, absDir string) string {
if rel, err := filepath.Rel(cwd, absDir); err == nil {
return rel
}
return filepath.Base(absDir)
}

View file

@ -442,6 +442,8 @@ func runGoldenFixture(t *testing.T, fixture string) {
t.Fatalf("abs %s: %v", fixture, err)
}
args, cwd := loadFixtureInvocation(t, fixture)
// spec:26174-planctl-context-tokens/R3.4+R4.1+D§7.2
setupFixtureContextEnv(t, fixture)
var stdout, stderr bytes.Buffer
restore := chdir(t, cwd)
defer restore()
@ -480,6 +482,62 @@ func runGoldenFixture(t *testing.T, fixture string) {
}
}
// spec:26174-planctl-context-tokens/R3.4+R4.1+D§7.2
// setupFixtureContextEnv wires per-fixture environment for the context-window
// feature:
//
// - env.txt — one KEY=VALUE line per env var; trimmed, `#` comments skipped.
// Each key is pinned via t.Setenv (auto-restored on test end).
// - transcript.jsonl — staged under a temp HOME at
// ~/.claude/projects/<slug>/<uuid>.jsonl where slug is derived from
// CLAUDE_PROJECT_DIR (or fixture dir) with `/` → `-` and uuid is
// CLAUDE_SESSION_ID. When transcript.jsonl is present but the required
// env keys aren't, the staging step is skipped silently so non-context
// fixtures remain unaffected.
//
// Fixtures without env.txt or transcript.jsonl are untouched — baseline fixtures
// stay byte-identical.
func setupFixtureContextEnv(t *testing.T, fixture string) {
t.Helper()
envPath := filepath.Join(fixture, "env.txt")
if b, err := os.ReadFile(envPath); err == nil {
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
t.Setenv(strings.TrimSpace(k), strings.TrimSpace(v))
}
}
tp := filepath.Join(fixture, "transcript.jsonl")
tb, err := os.ReadFile(tp)
if err != nil {
return
}
// Need a uuid + a slug to derive the transcript path. If either is missing,
// skip staging — the fixture under test probably doesn't need it to pass.
uuid := os.Getenv("CLAUDE_SESSION_ID")
projectDir := os.Getenv("CLAUDE_PROJECT_DIR")
if uuid == "" || projectDir == "" {
return
}
// Stage a temp HOME and drop the transcript at the exact path planctl will look up.
home := t.TempDir()
t.Setenv("HOME", home)
slug := strings.ReplaceAll(projectDir, "/", "-")
dir := filepath.Join(home, ".claude", "projects", slug)
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir transcript dir: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, uuid+".jsonl"), tb, 0o600); err != nil {
t.Fatalf("stage transcript: %v", err)
}
}
// loadFixtureInvocation reads args.txt and cwd-rel.txt if present. The
// args.txt tokens may reference the literal `FIXTURE` substring, which
// is substituted with the absolute path of the fixture directory so

View file

@ -0,0 +1,44 @@
//go:build darwin
package main
import (
"syscall"
"golang.org/x/sys/unix"
)
// NOTE: design §7 says "stdlib + goldmark only", but stdlib syscall on modern Go
// no longer exposes KinfoProc/SysctlRaw on macOS. Using golang.org/x/sys/unix
// (the de-facto-stdlib extension) is the least-fragile alternative to hand-rolling
// struct layouts against raw sysctl bytes. Design divergence noted.
// spec:26174-planctl-context-tokens/R1.2+R6.3+D§3.3
// ppidOf returns the parent PID of pid, or 0 on error.
func ppidOf(pid int) int {
kp, err := unix.SysctlKinfoProc("kern.proc.pid", pid)
if err != nil {
return 0
}
return int(kp.Eproc.Ppid)
}
// spec:26174-planctl-context-tokens/R6.3+D§3.3
// processAlive reports whether a process with pid exists.
func processAlive(pid int) bool {
if pid <= 0 {
return false
}
return syscall.Kill(pid, 0) == nil
}
// spec:26174-planctl-context-tokens/R6.3+D§3.3+D§4.1
// processStartTimeSec returns the process start time as seconds since the Unix epoch,
// read from kinfo_proc.kp_proc.p_starttime.tv_sec on macOS.
func processStartTimeSec(pid int) (int64, error) {
kp, err := unix.SysctlKinfoProc("kern.proc.pid", pid)
if err != nil {
return 0, err
}
return int64(kp.Proc.P_starttime.Sec), nil
}

61
cmd/planctl/proc_linux.go Normal file
View file

@ -0,0 +1,61 @@
//go:build linux
package main
import (
"fmt"
"os"
"strconv"
"strings"
"syscall"
)
// spec:26174-planctl-context-tokens/R1.2+R6.3+D§3.3
// ppidOf returns the parent PID of pid by reading /proc/<pid>/status.
// Returns 0 on error.
func ppidOf(pid int) int {
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
if err != nil {
return 0
}
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "PPid:") {
s := strings.TrimSpace(strings.TrimPrefix(line, "PPid:"))
n, err := strconv.Atoi(s)
if err != nil {
return 0
}
return n
}
}
return 0
}
// spec:26174-planctl-context-tokens/R6.3+D§3.3
// processAlive reports whether a process with pid exists.
func processAlive(pid int) bool {
if pid <= 0 {
return false
}
return syscall.Kill(pid, 0) == nil
}
// spec:26174-planctl-context-tokens/R6.3+D§3.3+D§4.1
// processStartTimeSec returns the process start time as raw clock ticks since boot,
// read from /proc/<pid>/stat field 22 (starttime). The hook writes the same units
// (clock ticks) on Linux per §5.5/§4.1, so comparison units match.
//
// The name retains "Sec" for API symmetry with the macOS implementation (which
// returns epoch seconds). Within a platform both sides match; cross-platform
// comparison never happens.
//
// /proc/<pid>/stat is a single line: "pid (comm) state ppid ... starttime ...".
// `comm` can contain spaces and parens. We find the LAST ')' and tokenize the
// remainder so comm noise can't shift field indices.
func processStartTimeSec(pid int) (int64, error) {
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
if err != nil {
return 0, err
}
return parseStatStartTime(string(data))
}

View file

@ -0,0 +1,34 @@
package main
import (
"fmt"
"strconv"
"strings"
)
// spec:26174-planctl-context-tokens/R6.3+D§3.3
// parseStatStartTime extracts field 22 (starttime — clock ticks since boot)
// from a /proc/<pid>/stat line. The `comm` field at position 2 is wrapped in
// parens and can contain any bytes including spaces and parens; we tokenize
// only the remainder after the LAST ')' so comm contents can't shift indices.
//
// Lives in a non-build-tagged file so it compiles — and is testable — on all
// platforms. Only proc_linux.go's processStartTimeSec actually consumes it.
func parseStatStartTime(stat string) (int64, error) {
i := strings.LastIndex(stat, ")")
if i < 0 || i+1 >= len(stat) {
return 0, fmt.Errorf("stat: no ')' terminator")
}
// After the ')' we have: " state ppid ... starttime ..."
// Fields counted from 1 in the man page: (1) pid, (2) (comm), (3) state, ..., (22) starttime.
// Remainder starts at field 3, so starttime is index 19 (22-3).
fields := strings.Fields(stat[i+1:])
if len(fields) < 20 {
return 0, fmt.Errorf("stat: only %d tail fields", len(fields))
}
n, err := strconv.ParseInt(fields[19], 10, 64)
if err != nil {
return 0, fmt.Errorf("stat: starttime parse: %w", err)
}
return n, nil
}

View file

@ -0,0 +1,54 @@
package main
import "testing"
// spec:26174-planctl-context-tokens/R6.3+D§3.3
// Synthetic stat line: benign comm, starttime=12345.
func TestParseStatStartTime_simple(t *testing.T) {
// 22 fields: pid (comm) state ppid pgrp session tty_nr tpgid flags minflt cminflt majflt cmajflt utime stime cutime cstime priority nice num_threads itrealvalue starttime
line := "1234 (bash) S 1 1 1 0 -1 4194304 100 0 0 0 1 2 0 0 20 0 1 0 12345 ...\n"
n, err := parseStatStartTime(line)
if err != nil {
t.Fatalf("parseStatStartTime: %v", err)
}
if n != 12345 {
t.Errorf("got %d, want 12345", n)
}
}
// spec:26174-planctl-context-tokens/R6.3+D§3.3
// comm containing spaces and parens: the "LAST ')'" parse strategy must still
// find the field-22 starttime correctly.
func TestParseStatStartTime_commWithParensAndSpaces(t *testing.T) {
line := "1234 (my (weird) program) S 1 1 1 0 -1 4194304 100 0 0 0 1 2 0 0 20 0 1 0 99999 ...\n"
n, err := parseStatStartTime(line)
if err != nil {
t.Fatalf("parseStatStartTime: %v", err)
}
if n != 99999 {
t.Errorf("got %d, want 99999", n)
}
}
// spec:26174-planctl-context-tokens/R6.3+D§3.3
func TestParseStatStartTime_noParenError(t *testing.T) {
if _, err := parseStatStartTime("not a stat line"); err == nil {
t.Fatal("expected error for missing ')'")
}
}
// spec:26174-planctl-context-tokens/R6.3+D§3.3
func TestParseStatStartTime_tooFewFields(t *testing.T) {
if _, err := parseStatStartTime("1 (x) S 1 1"); err == nil {
t.Fatal("expected error for too few fields")
}
}
// spec:26174-planctl-context-tokens/R6.3+D§3.3
func TestParseStatStartTime_nonNumeric(t *testing.T) {
// 20 tail fields with a non-numeric in starttime slot.
tail := "S 1 1 1 0 -1 4194304 100 0 0 0 1 2 0 0 20 0 1 0 NOPE"
if _, err := parseStatStartTime("1 (x) " + tail); err == nil {
t.Fatal("expected error for non-numeric starttime")
}
}

52
cmd/planctl/proc_test.go Normal file
View file

@ -0,0 +1,52 @@
package main
import (
"os"
"testing"
)
// spec:26174-planctl-context-tokens/R1.2+D§3.3
func TestPpidOf_selfParent(t *testing.T) {
got := ppidOf(os.Getpid())
want := os.Getppid()
if got != want {
t.Errorf("ppidOf(getpid()) = %d, want getppid()=%d", got, want)
}
}
// spec:26174-planctl-context-tokens/R6.3+D§3.3
func TestProcessAlive_self(t *testing.T) {
if !processAlive(os.Getpid()) {
t.Error("processAlive(self) returned false")
}
}
// spec:26174-planctl-context-tokens/R6.3+D§3.3
func TestProcessAlive_zeroOrNegative(t *testing.T) {
if processAlive(0) {
t.Error("processAlive(0) should be false")
}
if processAlive(-1) {
t.Error("processAlive(-1) should be false")
}
}
// spec:26174-planctl-context-tokens/R6.3+D§3.3+D§4.1
func TestProcessStartTimeSec_selfNonZero(t *testing.T) {
stamp, err := processStartTimeSec(os.Getpid())
if err != nil {
t.Fatalf("processStartTimeSec(self): %v", err)
}
if stamp <= 0 {
t.Errorf("expected positive stamp, got %d", stamp)
}
}
// spec:26174-planctl-context-tokens/R6.3+D§3.3
// ppidOf of a very unlikely PID (huge number) should return 0 (not panic).
func TestPpidOf_nonexistent(t *testing.T) {
got := ppidOf(999_999_999)
if got != 0 {
t.Errorf("ppidOf(nonexistent) = %d, want 0", got)
}
}

View file

@ -85,7 +85,7 @@ func parseTaskID(text string) string {
// not be nil; the result is in the same order as idx.TaskLines.
func buildTaskRecords(idx Index, scan *ScanResult, planDir string) []TaskRecord {
out := make([]TaskRecord, 0, len(idx.TaskLines))
dirBase := filepath.Base(planDir)
dirBase := planDir
for _, tl := range idx.TaskLines {
rawLine := ""
if tl.Line >= 1 && tl.Line <= len(scan.Lines) {

View file

@ -0,0 +1,3 @@
CLAUDE_SESSION_ID=fixture-uuid
CLAUDE_PROJECT_DIR=/fixture/project
CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000

View file

@ -0,0 +1 @@
0

View file

@ -0,0 +1,2 @@
context-error: clean (1 tasks, 1 requirements, 0 design sections)
context: 192 k / 200 k tokens (96%) — stop new work; commit and close out immediately.

View file

@ -0,0 +1,6 @@
# PRD — context fixture
## 4. Functional Requirements
### R1. Greet
- R1.1 THE SYSTEM SHALL greet the user on startup.

View file

@ -0,0 +1,3 @@
# Tasks — context fixture
- [ ] 1.0 Implement greet _Requirements: R1.1_

View file

@ -0,0 +1,2 @@
{"type":"user","message":{"content":"hi"}}
{"type":"assistant","sessionId":"fixture-uuid","message":{"usage":{"input_tokens":19200,"cache_creation_input_tokens":38400,"cache_read_input_tokens":134400,"output_tokens":100}}}

View file

@ -0,0 +1,3 @@
CLAUDE_SESSION_ID=fixture-uuid
CLAUDE_PROJECT_DIR=/fixture/project
CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000

View file

@ -0,0 +1 @@
0

View file

@ -0,0 +1,2 @@
context-info: clean (1 tasks, 1 requirements, 0 design sections)
context: 142 k / 200 k tokens (71%) — plan to wrap up this session soon.

View file

@ -0,0 +1,6 @@
# PRD — context fixture
## 4. Functional Requirements
### R1. Greet
- R1.1 THE SYSTEM SHALL greet the user on startup.

View file

@ -0,0 +1,3 @@
# Tasks — context fixture
- [ ] 1.0 Implement greet _Requirements: R1.1_

View file

@ -0,0 +1,2 @@
{"type":"user","message":{"content":"hi"}}
{"type":"assistant","sessionId":"fixture-uuid","message":{"usage":{"input_tokens":14200,"cache_creation_input_tokens":28400,"cache_read_input_tokens":99400,"output_tokens":100}}}

View file

@ -0,0 +1 @@
lint --format=json FIXTURE

View file

@ -0,0 +1,3 @@
CLAUDE_SESSION_ID=fixture-uuid
CLAUDE_PROJECT_DIR=/fixture/project
CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000

View file

@ -0,0 +1 @@
0

View file

@ -0,0 +1 @@
{"summary":{"plans":1,"errors":0,"warnings":0},"context_window":{"tokens_used":174000,"tokens_limit":200000,"pct":87,"severity":"warn","recommendation":"commit current work and start a new session after this task."}}

View file

@ -0,0 +1,6 @@
# PRD — context-json-warn
## 4. Functional Requirements
### R1. Greet
- R1.1 THE SYSTEM SHALL greet the user on startup.

View file

@ -0,0 +1,3 @@
# Tasks — context-json-warn
- [ ] 1.0 Implement greet _Requirements: R1.1_

View file

@ -0,0 +1,2 @@
{"type":"user","message":{"content":"hi"}}
{"type":"assistant","sessionId":"fixture-uuid","message":{"usage":{"input_tokens":17400,"cache_creation_input_tokens":34800,"cache_read_input_tokens":121800,"output_tokens":100}}}

View file

@ -0,0 +1 @@
lint

View file

@ -0,0 +1,6 @@
# PRD — alpha
## 4. Functional Requirements
### R1. Greet
- R1.1 THE SYSTEM SHALL greet the user on startup.

View file

@ -0,0 +1,3 @@
# Tasks — alpha
- [ ] 1.0 Implement greet _Requirements: R1.1_

View file

@ -0,0 +1,6 @@
# PRD — beta
## 4. Functional Requirements
### R1. Greet
- R1.1 THE SYSTEM SHALL greet the user on startup.

View file

@ -0,0 +1,3 @@
# Tasks — beta
- [ ] 1.0 Implement greet _Requirements: R1.1_

View file

@ -0,0 +1,3 @@
CLAUDE_SESSION_ID=fixture-uuid
CLAUDE_PROJECT_DIR=/fixture/project
CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000

View file

@ -0,0 +1 @@
0

View file

@ -0,0 +1,7 @@
=== 26170-alpha ===
26170-alpha: clean (1 tasks, 1 requirements, 0 design sections)
=== 26172-beta ===
26172-beta: clean (1 tasks, 1 requirements, 0 design sections)
2 plans linted, 0 errors, 0 warnings
context: 142 k / 200 k tokens (71%) — plan to wrap up this session soon.

View file

@ -0,0 +1,2 @@
{"type":"user","message":{"content":"hi"}}
{"type":"assistant","sessionId":"fixture-uuid","message":{"usage":{"input_tokens":14200,"cache_creation_input_tokens":28400,"cache_read_input_tokens":99400,"output_tokens":100}}}

View file

@ -0,0 +1,2 @@
CLAUDE_SESSION_ID=fixture-uuid
CLAUDE_PROJECT_DIR=/fixture/project

View file

@ -0,0 +1 @@
0

View file

@ -0,0 +1,2 @@
context-no-limit: clean (1 tasks, 1 requirements, 0 design sections)
context: 143 k tokens (limit unknown)

View file

@ -0,0 +1,6 @@
# PRD — context fixture
## 4. Functional Requirements
### R1. Greet
- R1.1 THE SYSTEM SHALL greet the user on startup.

View file

@ -0,0 +1,3 @@
# Tasks — context fixture
- [ ] 1.0 Implement greet _Requirements: R1.1_

View file

@ -0,0 +1,2 @@
{"type":"user","message":{"content":"hi"}}
{"type":"assistant","sessionId":"fixture-uuid","message":{"usage":{"input_tokens":14300,"cache_creation_input_tokens":28600,"cache_read_input_tokens":100100,"output_tokens":100}}}

View file

@ -0,0 +1 @@
0

View file

@ -0,0 +1 @@
context-no-session: clean (1 tasks, 1 requirements, 0 design sections)

View file

@ -0,0 +1,6 @@
# PRD — context fixture
## 4. Functional Requirements
### R1. Greet
- R1.1 THE SYSTEM SHALL greet the user on startup.

View file

@ -0,0 +1,3 @@
# Tasks — context fixture
- [ ] 1.0 Implement greet _Requirements: R1.1_

View file

@ -0,0 +1,3 @@
CLAUDE_SESSION_ID=fixture-uuid
CLAUDE_PROJECT_DIR=/fixture/project
CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000

View file

@ -0,0 +1 @@
0

View file

@ -0,0 +1 @@
context-normal: clean (1 tasks, 1 requirements, 0 design sections)

View file

@ -0,0 +1,6 @@
# PRD — context fixture
## 4. Functional Requirements
### R1. Greet
- R1.1 THE SYSTEM SHALL greet the user on startup.

View file

@ -0,0 +1,3 @@
# Tasks — context fixture
- [ ] 1.0 Implement greet _Requirements: R1.1_

View file

@ -0,0 +1,2 @@
{"type":"user","message":{"content":"hi"}}
{"type":"assistant","sessionId":"fixture-uuid","message":{"usage":{"input_tokens":10000,"cache_creation_input_tokens":20000,"cache_read_input_tokens":70000,"output_tokens":100}}}

View file

@ -0,0 +1,3 @@
CLAUDE_SESSION_ID=fixture-uuid
CLAUDE_PROJECT_DIR=/fixture/project
CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000

View file

@ -0,0 +1 @@
0

View file

@ -0,0 +1,2 @@
context-warn: clean (1 tasks, 1 requirements, 0 design sections)
context: 174 k / 200 k tokens (87%) — commit current work and start a new session after this task.

View file

@ -0,0 +1,6 @@
# PRD — context fixture
## 4. Functional Requirements
### R1. Greet
- R1.1 THE SYSTEM SHALL greet the user on startup.

View file

@ -0,0 +1,3 @@
# Tasks — context fixture
- [ ] 1.0 Implement greet _Requirements: R1.1_

View file

@ -0,0 +1,2 @@
{"type":"user","message":{"content":"hi"}}
{"type":"assistant","sessionId":"fixture-uuid","message":{"usage":{"input_tokens":17400,"cache_creation_input_tokens":34800,"cache_read_input_tokens":121800,"output_tokens":100}}}

View file

@ -131,6 +131,18 @@ An agent's pre-flight compares `@`'s bookmark against `$AGENT_BOOKMARK`; mismatc
This is a convention, not a jj feature. `jj` itself reads `JJ_USER` / `JJ_EMAIL` / `JJ_OP_USERNAME` but has no "my bookmark" concept — the env var is a tripwire for the agent's own pre-flight logic. For real isolation, use workspaces; the env var is the fallback.
#### Planned: JJ_AGENT_FEATURE
A future `JJ_AGENT_FEATURE` env var will give commits and end-of-session bookmarks a human-readable feature name instead of the raw session UUID:
```bash
export JJ_AGENT_FEATURE=auth # example
# commits become: wip(auth:abc12345): file1, file2
# session-end bookmark: feat/auth (instead of wip/claude-abc12345)
```
Not yet implemented — the daemon and hook script need changes to read and propagate it. When available, set it alongside `AGENT_BOOKMARK` in the same env block. Until then, the raw `wip(claude:<session_id>)` messages are the identifiers to use.
### Pre-flight check (every agent, every session start)
If not using a dedicated workspace, run before any file write:

View file

@ -0,0 +1,6 @@
- 2026-04-23T16:49:16Z prd-review 019dbb3e-b945-7962-bcb1-cd3ad6b098d1
- 2026-04-23T17:41:00Z design-review 019dbb64-1df2-7842-85f4-ab58efac6bb4
- 2026-04-23T17:44:50Z tasks-review 019dbc84-7470-7130-95f7-38678a69dd9a
- 2026-04-23T17:28:43Z code-review-parent-1 019dbcac-a359-72c1-b3ba-1d9f9c5105fb
- 2026-04-23T18:31:09Z code-review-parent-2 019dbce5-cb69-7eb0-b82f-58ce99a04562
- 2026-04-23T18:56:02Z pre-pr-review 019dbcfc-911a-7ee2-835a-31d9461f9f7d

View file

@ -4,12 +4,16 @@
planctl is a flat `package main` in `cmd/planctl/`. All source files share a single package, so the new `context.go` file integrates without any package-boundary changes.
**New file:**
**New files:**
- `cmd/planctl/context.go` — session discovery, JSONL parsing, threshold classification
- `cmd/planctl/context_test.go` — unit tests
- `cmd/planctl/proc_darwin.go` — build-tagged: `ppidOf`, `processStartTimeSec` (macOS)
- `cmd/planctl/proc_linux.go` — build-tagged: `ppidOf`, `processStartTimeSec` (Linux)
**Modified files:**
- `cmd/planctl/main.go` — call `ReadTokenCtx()` once in `runLint()`, pass result to emitters
- `cmd/planctl/main.go` — call `ReadTokenCtx()` once at the top of `run()`, thread `ctx *TokenCtx` into all subcommand handlers
- `cmd/planctl/emit.go` — add `*TokenCtx` parameter to `emitText` / `emitJSON`; append context line after summary
- `cmd/planctl/main_test.go` — extend golden-file E2E tests
- `scripts/jj-hook.sh``session-start` case: write session UUID to `$CLAUDE_ENV_FILE` and PID file; `session-end` case: remove PID file
No new Go module dependencies. Platform differences (macOS vs. Linux) for PID ancestry are handled by build-tagged helper files within `cmd/planctl/`.
@ -28,11 +32,11 @@ flowchart TD
C --> D{CLAUDE_SESSION_ID\nenv set?}
D -- yes --> G[use env UUID]
D -- no --> E[walk process ancestors\n up to 8 hops]
E --> F{/tmp/planctl-sessions/<pid>\nexists + process alive\n+ start-time matches?}
E --> F{/tmp/planctl-sessions/pid\nexists + process alive\n+ start-time matches?}
F -- yes --> G
F -- no → next hop / exhausted --> H[nil TokenCtx]
F -- no, next hop or exhausted --> H[return nil TokenCtx]
G --> I[derive transcript path:\n~/.claude/projects/<slug>/<uuid>.jsonl]
G --> I[derive transcript path:\n~/.claude/projects/slug/uuid.jsonl]
I --> J[open file;\nbufio.Scanner 10 MB buf]
J --> K{file readable?}
K -- no --> H
@ -60,7 +64,7 @@ _Requirements: R1.1, R1.2, R1.4, R2.1, R2.2, R2.3, R2.4, R3.1, R3.2, R3.3, R4.1,
All types and functions below live in `cmd/planctl/context.go` unless noted.
### 3.1 `TokenCtx`
### §3.1 `TokenCtx`
```go
// TokenCtx holds the resolved token-usage data for the current session.
@ -75,7 +79,7 @@ type TokenCtx struct {
_Requirements: R2.2, R2.4, R3.3_
### 3.2 `ReadTokenCtx() *TokenCtx`
### §3.2 `ReadTokenCtx() *TokenCtx`
```go
// ReadTokenCtx is the ambient constructor: it reads the calling process's
@ -91,9 +95,11 @@ Internal call chain:
3. `parseLastUsage(path string) (int64, error)` — JSONL scan (§3.5)
4. `readLimit() int64` — env var read (§3.6)
_Requirements: R1.1, R1.2, R1.3, R1.4, R1.5, R1.6, R2.1, R2.2, R2.3, R2.4_
Called once at the top of `run()` (not inside `runLint()`), so the `ctx` value is available to every subcommand handler — current (`runLint`) and future (`runNext`, `runList`, `runComplete`, `runStatus`). Each handler that emits output receives `ctx *TokenCtx` as a parameter.
### 3.3 `resolveSessionUUID() string`
_Requirements: R1.1, R1.2, R1.3, R1.4, R1.5, R1.6, R2.1, R2.2, R2.3, R2.4, R3.4_
### §3.3 `resolveSessionUUID() string`
```go
// resolveSessionUUID returns the session UUID string, or "" if not found.
@ -101,33 +107,64 @@ _Requirements: R1.1, R1.2, R1.3, R1.4, R1.5, R1.6, R2.1, R2.2, R2.3, R2.4_
func resolveSessionUUID() string
```
PID walk logic:
PID walk algorithm:
```
uuid = os.Getenv("CLAUDE_SESSION_ID")
if uuid != "": return uuid
pid = os.Getpid()
repeat up to 8 times:
pid = ppidOf(pid) // platform-specific; returns 0 on error
if pid <= 1: break
path = "/tmp/planctl-sessions/" + strconv.Itoa(pid)
if !fileExists(path): continue
uuid, startSec = parsePIDFile(path)
if !processAlive(pid): continue // kill(pid,0) failed
if processStartTimeSec(pid) != startSec: continue // PID reused
return uuid
for i := 0; i < 8; i++:
pid = ppidOf(pid) // platform-specific (§3.3a); returns 0 on error
if pid <= 1: break
path = "/tmp/planctl-sessions/" + strconv.Itoa(pid)
if !fileExists(path): continue
uuid, startSec, err = parsePIDFile(path)
if err != nil: continue
if !processAlive(pid): continue // syscall.Kill(pid, 0) failed
if processStartTimeSec(pid) != startSec: continue // PID was reused
return uuid
return ""
```
Platform helpers live in build-tagged files:
- `cmd/planctl/proc_darwin.go``ppidOf`, `processStartTimeSec` via `syscall.SysctlKinfoProc`
- `cmd/planctl/proc_linux.go``ppidOf`, `processStartTimeSec` via `/proc/<pid>/status` and `/proc/<pid>/stat`
`processAlive(pid int) bool` wraps `syscall.Kill(pid, 0)`.
_Requirements: R1.1, R1.2, R1.3, R1.4, R6.1, R6.3_
#### §3.3a Platform helpers (build-tagged)
### 3.4 `transcriptPath(uuid, home, projectDir string) string`
`cmd/planctl/proc_darwin.go` (`//go:build darwin`):
```go
func ppidOf(pid int) (int, error) {
info, err := syscall.SysctlKinfoProc("kern.proc.pid", pid)
if err != nil { return 0, err }
return int(info.Eproc.Ppid), nil
}
func processStartTimeSec(pid int) (int64, error) {
info, err := syscall.SysctlKinfoProc("kern.proc.pid", pid)
if err != nil { return 0, err }
return int64(info.Proc.P_starttime.Sec), nil
}
```
`cmd/planctl/proc_linux.go` (`//go:build linux`):
```go
// ppidOf reads PPid from /proc/<pid>/status (safe: line-based, not field-based).
func ppidOf(pid int) (int, error) { ... }
// processStartTimeSec reads the start-time field from /proc/<pid>/stat robustly:
// the stat file format is "pid (comm) state ppid ... starttime ..."; the comm
// field can contain spaces and parentheses, so field 22 is NOT safe to read with
// awk '{print $22}'. Instead: find the last ')' in the line, then count fields
// from there. Returns clock-ticks-since-boot (preserves full precision for
// comparison; no conversion to seconds that would discard subsecond resolution).
func processStartTimeSec(pid int) (int64, error) { ... }
```
_Requirements: R1.2, R6.1, R6.3_
### §3.4 `transcriptPath(uuid, home, projectDir string) string`
```go
// transcriptPath assembles the Claude Code transcript path.
// slug = projectDir with every '/' replaced by '-'.
// Falls back to os.Getwd() if projectDir is "".
func transcriptPath(uuid, home, projectDir string) string {
if projectDir == "" {
projectDir, _ = os.Getwd()
@ -137,14 +174,16 @@ func transcriptPath(uuid, home, projectDir string) string {
}
```
Example: `/Users/sid/repos/template-jj``-Users-sid-repos-template-jj`
Path: `~/.claude/projects/-Users-sid-repos-template-jj/<uuid>.jsonl`
Example: `/Users/sid/repos/template-jj` → slug `-Users-sid-repos-template-jj`
Full path: `~/.claude/projects/-Users-sid-repos-template-jj/<uuid>.jsonl`
`home` is `os.UserHomeDir()` resolved once in `ReadTokenCtx`.
_Requirements: R1.5, R1.6_
### 3.5 `parseLastUsage(path string) (int64, error)`
### §3.5 `parseLastUsage(path string) (int64, error)`
Scans the JSONL file for the last assistant line with a usage object.
Minimal JSON type for partial decoding:
```go
type transcriptUsage struct {
@ -161,36 +200,35 @@ type transcriptUsage struct {
Algorithm:
1. Open file; create `bufio.Scanner` with `Scanner.Buffer(make([]byte, 10<<20), 10<<20)` (10 MB max per line).
2. Read all lines; for each line that can be JSON-decoded into `transcriptUsage` with `Type == "assistant"` and non-zero usage, record the sum `input + cache_creation + cache_read`.
3. Return the last recorded sum (walking forward preserves last-wins without a reverse seek).
4. If no qualifying line found: return `(0, errNoUsageLine)`.
2. Forward scan all lines; for each line that decodes to `transcriptUsage` with `Type == "assistant"`, compute the sum and overwrite `last`.
3. Return `last` after EOF. If no qualifying line found: return `(0, errNoUsageLine)`.
Malformed lines (JSON decode error) are silently skipped (continue).
Malformed lines (JSON decode error) are silently skipped `encoding/json.Unmarshal` error → `continue`.
_Requirements: R2.1, R2.2, R2.3, R2.6_
### 3.6 `readLimit() int64`
### §3.6 `readLimit() int64`
```go
// readLimit returns CLAUDE_CODE_MAX_CONTEXT_TOKENS as an int64, or 0 if unset/invalid.
func readLimit() int64
func readLimit() int64 {
v := os.Getenv("CLAUDE_CODE_MAX_CONTEXT_TOKENS")
if v == "" { return 0 }
n, err := strconv.ParseInt(v, 10, 64)
if err != nil { return 0 }
return n
}
```
Parses `os.Getenv("CLAUDE_CODE_MAX_CONTEXT_TOKENS")` with `strconv.ParseInt`; returns 0 on parse error.
_Requirements: R2.4_
### 3.7 `(t *TokenCtx) Classify() (severity, msg string)`
### §3.7 `(t *TokenCtx) Classify() (severity, msg string)`
```go
// Classify returns the band's severity label and recommendation message suffix.
// Returns ("", "") for the Normal band or when t is nil.
// When Limit == 0, severity = "" and msg = "" (raw count only, no recommendation).
// Classify returns the band's severity label and recommendation suffix.
// Returns ("", "") for Normal band, unknown limit, or nil receiver.
func (t *TokenCtx) Classify() (severity, msg string)
```
Threshold table (matching PRD R3.2):
| pct | severity | msg |
|-----|----------|-----|
| < 70 | `""` | `""` |
@ -198,29 +236,29 @@ Threshold table (matching PRD R3.2):
| 8594 | `"warn"` | `"commit current work and start a new session after this task."` |
| ≥ 95 | `"error"` | `"stop new work; commit and close out immediately."` |
When `Limit == 0`: return `("", "")` — the emitter uses `formatTokenCount(t.Used)` + `(limit unknown)`.
When `t.Limit == 0`: return `("", "")`. The emitter detects `Limit == 0` separately and emits the raw-count form (§4.3).
_Requirements: R3.1, R3.2, R3.3, R3.5_
### 3.8 `formatTokenCount(n int64) string`
### §3.8 `formatTokenCount(n int64) string`
```go
// formatTokenCount returns n expressed in thousands: "143 k", "< 1 k", etc.
// formatTokenCount renders n in thousands, rounding to nearest (PRD R4.4).
// Values under 1000 are always "< 1 k" regardless of rounding.
func formatTokenCount(n int64) string {
k := n / 1000
if k == 0 { return "< 1 k" }
if n < 1000 { return "< 1 k" }
k := (n + 500) / 1000
return strconv.FormatInt(k, 10) + " k"
}
```
Boundary table: n=0→`< 1 k`, n=500→`< 1 k`, n=999→`< 1 k`, n=1000→`1 k`, n=1499→`1 k`, n=1500→`2 k`.
_Requirements: R4.4_
### 3.9 `parsePIDFile(path string) (uuid string, startSec int64, err error)`
### §3.9 `parsePIDFile(path string) (uuid string, startSec int64, err error)`
```go
// parsePIDFile reads a two-line PID file: line 1 = session UUID, line 2 = start-time-seconds.
func parsePIDFile(path string) (uuid string, startSec int64, err error)
```
Reads exactly two newline-terminated lines from the file. Returns error if either line is missing or `startSec` is not a valid decimal integer.
_Requirements: R6.2, R6.3_
@ -228,23 +266,23 @@ _Requirements: R6.2, R6.3_
## §4. API / Protocol Contracts
### 4.1 PID file format
### §4.1 PID file format
File path: `/tmp/planctl-sessions/<ppid>` (where `<ppid>` is the Claude Code process PID).
File: `/tmp/planctl-sessions/<ppid>` (PPID = Claude Code process PID at hook time).
```
<session-uuid>\n
<start-time-seconds>\n
<start-stamp>\n
```
- Line 1: full UUID (e.g. `817fec78-6ff1-4fb2-a17c-5708ea5df968`)
- Line 2: decimal integer seconds since Unix epoch (UTC) when the Claude Code process started
- Line 1: full RFC 4122 UUID
- Line 2: platform-specific start-time stamp — raw clock ticks since boot on Linux (from `/proc/<pid>/stat` via `sed 's/.*)//' | awk '{print $20}'`), seconds since Unix epoch on macOS (from `ps -o lstart=`). The Go resolver (`processStartTimeSec`) returns the same unit on each platform, so cross-platform comparisons are never made.
Hook writes atomically by writing to a temp file then `mv` to the final path (prevents partial reads).
Written atomically by the hook via `mktemp` + `mv`.
_Requirements: R6.2_
### 4.2 Transcript JSONL schema (consumed fields only)
### §4.2 Transcript JSONL schema (consumed fields)
```json
{
@ -259,38 +297,40 @@ _Requirements: R6.2_
}
```
Unknown top-level fields are ignored by `encoding/json` by default. The `output_tokens` field is present but not consumed (excluded from context fill per PRD §5).
`output_tokens` is present but not consumed (excluded per PRD §5). Unknown fields are ignored by `encoding/json`.
_Requirements: R2.1, R2.2_
### 4.3 Text output — context line
### §4.3 Text output — context line placement
Single-plan, known limit, band ≠ Normal:
```
# Single-plan, known limit, band ≠ Normal:
<plan-summary-line>
context: 143 k / 200 k tokens (71%) — plan to wrap up this session soon.
```
Single-plan, unknown limit:
```
# Single-plan, unknown limit (emitted regardless of token count):
<plan-summary-line>
context: 143 k tokens (limit unknown)
```
Multi-plan — context line follows the aggregate summary:
```
# Multi-plan — context line follows aggregate summary:
3 plans linted, 0 errors, 0 warnings
context: 143 k / 200 k tokens (71%) — plan to wrap up this session soon.
# Normal band (pct < 70, limit known) or nil ctx: no context line.
```
Normal band (pct < 70, limit known): no context line emitted.
Nil `TokenCtx`: no context line emitted.
In `emitText`:
- Single-plan path: `writeContextLine(w, ctx)` called after `writePlanText`.
- Multi-plan path: `writeContextLine(w, ctx)` called after `writeAggregateText`.
`writeContextLine` is a no-op when `ctx == nil` or (limit known AND pct < 70).
_Requirements: R3.3, R3.4, R3.5, R4.1, R4.2, R4.3_
### 4.4 JSON output — `context_window` field
### §4.4 JSON output — `context_window` field
```json
// Known limit, band ≠ Normal:
{
"summary": { "plans": 1, "errors": 0, "warnings": 0 },
"context_window": {
@ -301,14 +341,12 @@ _Requirements: R3.3, R3.4, R3.5, R4.1, R4.2, R4.3_
"recommendation": "plan to wrap up this session soon."
}
}
```
Unknown limit (only `tokens_used` key present):
```json
// Unknown limit:
{ "summary": { ... }, "context_window": { "tokens_used": 143000 } }
```
Normal band or nil ctx: `context_window` key omitted entirely.
// Normal band or nil ctx: no context_window key.
```
_Requirements: R5.1, R5.2, R5.3, R5.4_
@ -316,180 +354,203 @@ _Requirements: R5.1, R5.2, R5.3, R5.4_
## §5. State / Schema Changes
### 5.1 New temp directory
### §5.1 New temp directory
`/tmp/planctl-sessions/` — created on first write by the hook (`mkdir -p`). Files in this directory are named by PID and written/deleted by `scripts/jj-hook.sh`.
`/tmp/planctl-sessions/` — created by hook (`mkdir -p`). Files named by PID; one per active session; removed at `session-end`.
### 5.2 Environment variables consumed (read-only)
### §5.2 Environment variables consumed
| Variable | Source | Used for |
| Variable | Source | Purpose |
|---|---|---|
| `CLAUDE_SESSION_ID` | Set by hook via `$CLAUDE_ENV_FILE` | Primary session UUID |
| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` | Set by Claude Code | Context window limit |
| `CLAUDE_PROJECT_DIR` | Set by Claude Code | Project slug derivation |
| `CLAUDE_SESSION_ID` | Hook via `$CLAUDE_ENV_FILE` at `SessionStart` | Primary session UUID |
| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` | Claude Code | Context window limit |
| `CLAUDE_PROJECT_DIR` | Claude Code | Project slug for transcript path |
planctl does not write any environment variable.
planctl writes no files and sets no environment variables.
### 5.3 `emitText` / `emitJSON` signature change
_Requirements: R1.1, R1.5, R2.4_
### §5.3 Emitter signature change (`cmd/planctl/emit.go`)
```go
// Before
// Before:
func emitText(w io.Writer, plans []PlanResult, strict bool) int
func emitJSON(w io.Writer, plans []PlanResult, strict bool) int
// After
// After:
func emitText(w io.Writer, plans []PlanResult, strict bool, ctx *TokenCtx) int
func emitJSON(w io.Writer, plans []PlanResult, strict bool, ctx *TokenCtx) int
```
The added `ctx *TokenCtx` is always nil-safe; callers in `runLint` pass the value from `ReadTokenCtx()`.
`run()` in `main.go` calls `ReadTokenCtx()` once and passes `ctx` to each subcommand handler.
### 5.4 JSON summary type extension
_Requirements: R3.4, R4.1, R4.3, R5.1_
### §5.4 JSON summary type extension (`cmd/planctl/emit.go`)
```go
// jsonSummary extended with optional ContextWindow
type jsonSummary struct {
Summary summaryBody `json:"summary"`
ContextWin *jsonCtxWin `json:"context_window,omitempty"`
Summary summaryBody `json:"summary"`
ContextWin *jsonCtxWin `json:"context_window,omitempty"`
}
type jsonCtxWin struct {
TokensUsed int64 `json:"tokens_used"`
TokensLimit *int64 `json:"tokens_limit,omitempty"` // nil when unknown
Pct *int `json:"pct,omitempty"` // nil when unknown
Severity string `json:"severity,omitempty"`
TokensUsed int64 `json:"tokens_used"`
TokensLimit *int64 `json:"tokens_limit,omitempty"`
Pct *int `json:"pct,omitempty"`
Severity string `json:"severity,omitempty"`
Recommendation string `json:"recommendation,omitempty"`
}
```
`omitempty` on pointer fields achieves the "omit when unknown" behavior from R5.2 without conditional branches in the encoder.
`omitempty` on pointer fields achieves "omit when unknown" without conditional encoder branches.
_Requirements: R5.1, R5.2, R5.3_
### 5.5 Hook changes (`scripts/jj-hook.sh`)
### §5.5 Hook changes (`scripts/jj-hook.sh`)
**`session-start` case** — additions after the existing `ensure_daemon` / `send_event` block:
`session-start` — after existing daemon block:
```bash
FULL_SID=$(printf '%s\n' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null)
if [ -n "$FULL_SID" ]; then
# Primary: propagate session ID to all subprocesses via env
# Validate UUID format before writing to env file (prevents shell injection via $CLAUDE_ENV_FILE).
if [[ "$FULL_SID" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then
# Primary: env var propagated to all subprocesses.
[ -n "${CLAUDE_ENV_FILE:-}" ] && \
echo "export CLAUDE_SESSION_ID=${FULL_SID}" >> "$CLAUDE_ENV_FILE"
printf 'export CLAUDE_SESSION_ID=%s\n' "$FULL_SID" >> "$CLAUDE_ENV_FILE"
# Secondary: PID-keyed session file for concurrent-session fallback.
# Stores UUID + process start time for identity verification (R6.2/R6.3).
START_SEC=$(planctl_start_time "$PPID") # helper function (see below)
# Secondary: PID-keyed file for concurrent-session fallback (R6.2/R6.3).
START_TICKS=$(planctl_start_ticks "$PPID")
mkdir -p /tmp/planctl-sessions
TMPF=$(mktemp /tmp/planctl-sessions/.tmp.XXXXXX)
printf '%s\n%s\n' "$FULL_SID" "$START_SEC" > "$TMPF"
printf '%s\n%s\n' "$FULL_SID" "$START_TICKS" > "$TMPF"
mv "$TMPF" "/tmp/planctl-sessions/${PPID}"
fi
```
`planctl_start_time` shell function (platform-specific):
`planctl_start_ticks` helper (named `_ticks` to clarify the Linux return unit):
```bash
planctl_start_time() {
planctl_start_ticks() {
local pid="$1"
if [ -f "/proc/$pid/stat" ]; then
# Linux: field 22 of /proc/pid/stat is start time in clock ticks since boot.
local ticks btime clk_tck
ticks=$(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null)
btime=$(awk '/btime/{print $2}' /proc/stat 2>/dev/null)
clk_tck=$(getconf CLK_TCK 2>/dev/null || echo 100)
echo $(( btime + ticks / clk_tck ))
# Linux: /proc/pid/stat field 2 (comm) can contain spaces and parens.
# Safe parse: strip everything up to and including the last ')' first,
# then field 20 of the remainder is starttime (clock ticks since boot).
sed 's/.*)//' "/proc/$pid/stat" 2>/dev/null | awk '{print $20}' || echo 0
else
# macOS: parse 'ps lstart' and convert to epoch seconds.
# macOS: use p_starttime seconds from SysctlKinfoProc (set by Go code).
# Shell fallback: 'ps -o lstart=' → epoch seconds via date.
ps -o lstart= -p "$pid" 2>/dev/null | \
xargs -I{} date -j -f '%a %b %d %T %Y' '{}' '+%s' 2>/dev/null || echo 0
fi
}
```
**`session-end` case** — additions:
`session-end` — after existing block:
```bash
FULL_SID_END=$(printf '%s\n' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null)
if [ -n "$FULL_SID_END" ]; then
rm -f "/tmp/planctl-sessions/${PPID}"
fi
[ -n "$FULL_SID_END" ] && rm -f "/tmp/planctl-sessions/${PPID}"
```
_Requirements: R7.1, R7.2, R7.3_
_Requirements: R7.1, R7.2, R7.3, R7.4_
---
## §6. Security & Resource Considerations
### 6.1 External (untrusted) input
### §6.1 External (untrusted) input
The transcript JSONL file at `~/.claude/projects/...` is written by Claude Code and lives in the user's home directory. It is treated as untrusted for parsing purposes:
- Each line is decoded independently; a malformed line causes a skip, not a panic (R2.3).
- The 10 MB per-line buffer caps memory allocation per line. For a 50 MB transcript with uniformly large lines, peak memory is ~10 MB.
- Integer fields (`input_tokens` etc.) are decoded to `int64`; overflow is impossible for realistic token counts.
The transcript JSONL at `~/.claude/projects/.../uuid.jsonl` is written by Claude Code and treated as potentially malformed:
- Each line decoded independently; decode errors → skip, not panic (R2.3).
- 10 MB scanner buffer caps peak memory allocation per line.
- Integer fields decode to `int64`; no overflow risk for realistic token counts.
The `path` argument to `transcriptPath` is derived from `CLAUDE_PROJECT_DIR` (set by Claude Code itself) and `CLAUDE_SESSION_ID` / PID file. Path traversal is not possible from env-derived values without an attacker controlling the Claude Code process itself, which is out of scope.
`CLAUDE_SESSION_ID` is an RFC 4122 UUID (hex + dashes only); a forged value would produce a non-existent transcript path → `parseLastUsage` returns `errNoUsageLine` → nil ctx. No file creation risk from a malformed UUID.
### 6.2 PID file race conditions
PID file path is built from `strconv.Itoa(pid)` — no traversal possible.
- Atomic write via `mktemp` + `mv` prevents partial reads.
- Both liveness check (`kill(pid,0)`) and start-time comparison (§3.3) guard against PID reuse.
- Stale PID files (process exited without `SessionEnd`) are harmless: the liveness check skips them. The walk continues to the next ancestor.
- Worst case with no `SessionEnd` cleanup: the `/tmp/planctl-sessions/` directory accumulates stale files. Stale files are up to `O(sessions)` in count; each is ~100 bytes. Not a resource concern.
### §6.2 Trust model for PID files
### 6.3 No credential exposure
Context output is advisory only (no exit-code impact, no writes). A same-UID process could forge a PID file, causing a wrong recommendation — not a security failure. The liveness + start-time checks guard against accidental crossover between sessions and PID reuse; they are not a security boundary.
`CLAUDE_SESSION_ID` is the session UUID (not an API key). The transcript file contains model responses and usage metadata only — no API credentials.
### §6.3 Resource bounds
### 6.4 No subprocess execution
| Resource | Bound |
|---|---|
| Memory per run | ~10 MB (one scanner buffer, released after `ReadTokenCtx` returns) |
| PID file size | ~80 bytes per file |
| `/tmp/planctl-sessions/` | One file per active Claude Code session; pruned at `session-end` |
| Transcript scan | O(N) in file size, no random-access |
`ReadTokenCtx()` calls no external processes. The hook's `planctl_start_time` helper runs `awk`, `ps`, `date`, `getconf` — all standard POSIX utilities. planctl itself has zero subprocess invocations in this feature.
### §6.4 No subprocess execution in planctl
_Requirements: R2.5, R6.3_
`ReadTokenCtx()` calls no external processes. Only the hook's `planctl_start_time` shell function calls `awk`, `ps`, `date`, `getconf`.
_Requirements: R2.5_
---
## §7. Test Strategy
### 7.1 Unit tests (`cmd/planctl/context_test.go`)
### §7.1 Unit tests (`cmd/planctl/context_test.go`)
| Test | Coverage target |
| Test | Covers |
|---|---|
| `TestFormatTokenCount` | `< 1 k`, `1 k`, `143 k`, `1000 k` |
| `TestClassify_bands` | table-driven: pct < 70 / 7084 / 8594 / 95 / nil ctx |
| `TestClassify_unknownLimit` | Limit==0 → ("", "") from Classify |
| `TestParseLastUsage_valid` | synthetic JSONL with multiple lines; last assistant wins |
| `TestParseLastUsage_malformed` | malformed JSON line mid-file skipped; valid last line returned |
| `TestParseLastUsage_noAssistant` | returns errNoUsageLine |
| `TestParseLastUsage_emptyFile` | returns errNoUsageLine |
| `TestTranscriptPath` | projectDir → slug conversion including leading `/` |
| `TestParsePIDFile` | valid two-line file; missing second line returns error |
| `TestReadLimit_set` | env var parsed correctly |
| `TestReadLimit_unset` | returns 0 |
| `TestReadLimit_invalid` | non-numeric value returns 0 |
| `TestFormatTokenCount` | `< 1 k`, 1 k boundary, `143 k`, large values |
| `TestClassify_bands` | pct 0/69/70/84/85/94/95/100 |
| `TestClassify_unknownLimit` | `Limit == 0``("", "")` |
| `TestParseLastUsage_valid` | multi-line JSONL; last assistant wins |
| `TestParseLastUsage_malformedMid` | bad JSON line mid-file skipped; valid last returned |
| `TestParseLastUsage_noAssistant` | returns `errNoUsageLine` |
| `TestParseLastUsage_emptyFile` | returns `errNoUsageLine` |
| `TestParseLastUsage_largeLineBuffer` | 9 MB assistant line; not truncated |
| `TestTranscriptPath_slash` | `/Users/sid/repo` → correct slug |
| `TestTranscriptPath_fallbackCwd` | empty `projectDir` uses `os.Getwd()` |
| `TestParsePIDFile_valid` | two-line file |
| `TestParsePIDFile_missingLine2` | parse error |
| `TestReadLimit_set` | valid integer |
| `TestReadLimit_unset` | → 0 |
| `TestReadLimit_invalid` | non-numeric → 0 |
| `TestPpidOf_self` | returns `os.Getppid()` |
| `TestProcessStartTimeSec_self` | non-zero for calling process |
### 7.2 E2E golden tests (`cmd/planctl/main_test.go`)
### §7.2 E2E golden tests (`cmd/planctl/main_test.go`)
Existing golden-file harness in `main_test.go` is extended with new fixture sets. Each fixture is a directory with `prd.md` + any transcript JSONL, and golden files for expected stdout:
Fixtures under `cmd/planctl/testdata/context/` (each dir: minimal `prd.md` + `transcript.jsonl`):
| Fixture | `CLAUDE_SESSION_ID` | `CLAUDE_CODE_MAX_CONTEXT_TOKENS` | Expected output |
|---|---|---|---|
| `context-info` | set (points at fixture JSONL with 71% fill) | 200000 | info-band context line |
| `context-warn` | set (85% fill) | 200000 | warn-band context line |
| `context-error` | set (95% fill) | 200000 | error-band context line |
| `context-normal` | set (50% fill) | 200000 | no context line emitted |
| `context-no-session` | unset | unset | no context line (baseline) |
| `context-no-limit` | set | unset | raw token count + `(limit unknown)` |
| `context-json-warn` | set (85%) | 200000 | JSON with `context_window.severity=warn` |
| `context-multi-plan` | set (71%) | 200000 | context line after aggregate summary, not per-plan |
| Fixture | Env | Expected stdout |
|---|---|---|
| `info-band` | 71% fill, limit 200k | info-band context line |
| `warn-band` | 85% fill | warn-band context line |
| `error-band` | 96% fill | error-band context line |
| `normal-band` | 50% fill | no context line |
| `no-session` | no env vars | baseline (identical to current) |
| `no-limit` | session set, no limit | `context: N k tokens (limit unknown)` |
| `json-warn` | `--format=json`, 85% | `context_window.severity == "warn"` |
| `json-no-limit` | `--format=json`, no limit | `context_window` has only `tokens_used` |
| `json-normal` | `--format=json`, 50% | no `context_window` key |
| `multi-plan` | 71%, multi-plan lint | context line after aggregate summary |
### 7.3 Platform coverage
Tests inject env vars via `t.Setenv` and pass synthetic transcript paths via `CLAUDE_PROJECT_DIR`.
The CI matrix already runs macOS (arm64) and Linux (amd64) per PRD §7. The build-tagged `proc_darwin.go` / `proc_linux.go` files are each compiled only on their target OS. The `ppidOf` / `processStartTimeSec` functions have unit tests using the calling process's own PID (always known and alive).
### §7.3 Resolver chain integration test
### 7.4 Hard-to-test behaviors
`TestReadTokenCtx_fullChain` in `context_test.go`:
1. Write synthetic PID file to a temp path; override lookup dir via a package-level variable.
2. Write synthetic JSONL transcript to a temp file; point `CLAUDE_PROJECT_DIR` at it via `t.Setenv`.
3. Call `ReadTokenCtx()`; assert `TokenCtx.Used` and `Limit`.
- `SessionEnd` hook cleanup: verified by inspecting `/tmp/planctl-sessions/` before/after a scripted hook invocation in the integration test in `test-jj-hooks.sh` (not a Go test).
- PID-reuse defense: not automated (requires OS cooperation). The liveness + start-time check is unit-tested with synthetic `processAlive`/`processStartTimeSec` stubs.
Covers the complete env → PID walk → transcript parse → classify path without a live session.
### §7.4 Platform coverage
CI matrix (macOS arm64 + Linux amd64) compiles and runs both build-tagged proc files. `TestPpidOf_self` and `TestProcessStartTimeSec_self` run natively on each platform.
### §7.5 Behaviors not automated
- `SessionEnd` hook cleanup: verified by `scripts/test-jj-hooks.sh` shell-level harness.
- PID-reuse defense: tested via package-level `var ppidOfFn = ppidOf` stubs that inject mismatched start times.
_Requirements: R2.6, R3.4, R4.1, R4.2, R4.3, R5.1, R5.2, R5.3_

View file

@ -0,0 +1,242 @@
# PRD: planctl Context-Window Token Awareness
## 1. Introduction / Overview
AI agents using the spec-driven workflow call `planctl` frequently — to check plan health, fetch the next task, and mark tasks done. As a session runs, the agent's context window fills up. The agent itself cannot reliably self-assess how full its context window is: self-reported estimates are imprecise, and the agent may not act on the signal even when it detects it.
This feature gives `planctl` access to the real context-window token counts from Claude Code's session transcript, and emits threshold-based recommendations directly in `planctl`'s output. Any invocation of `planctl``lint`, `next`, `list`, `complete`, or `status` — can then carry the signal: "you're at 87%, commit your work and start a new session after this task."
**Goal:** Make context-window exhaustion a structured, actionable diagnostic surfaced by an external tool, so agents can act on it before they run out of tokens.
---
## 2. Goals
- **G1.** Extract real context-token counts from Claude Code's JSONL session transcript without requiring changes to Claude Code itself.
- **G2.** Surface threshold-based recommendations in both text and JSON output across all `planctl` subcommands.
- **G3.** Work correctly when multiple Claude Code sessions run concurrently against the same workspace.
- **G4.** Degrade gracefully when session context is unavailable (no env vars, no PID-file match) — existing output unchanged.
- **G5.** Introduce no new mandatory dependencies and no external network calls.
---
## 3. User Stories
**As an AI agent**, I want `planctl` to tell me when my context window is getting full, so that I can decide to wrap up tasks, commit work, and start a fresh session before I run out of tokens mid-task.
**As a developer running planctl manually**, I want the context-window section to appear only when session data is available, so that my local invocations look the same as they always did.
**As a multi-agent orchestrator**, I want each agent's `planctl` calls to reflect that agent's own context state — not another concurrent session's — so that concurrent agents don't cross-contaminate their recommendations.
**As a fork maintainer**, I want `planctl` to work with no configuration if the hook is not set up, so that toolchain adoption is friction-free.
---
## 4. Functional Requirements
### R1. Session transcript discovery
**As an AI agent, I want planctl to locate the correct Claude Code session transcript, so that context data is read from my session and not another's.**
- R1.1 WHEN `CLAUDE_SESSION_ID` is set in the environment, THE SYSTEM SHALL use that value as the session UUID without further lookup.
- R1.2 WHEN `CLAUDE_SESSION_ID` is not set, THE SYSTEM SHALL walk the process ancestor chain (up to 8 hops via successive `ppid` lookups) and check for a PID-keyed session file at `/tmp/planctl-sessions/<pid>` for each ancestor.
- R1.3 IF a PID-keyed session file is found, THE SYSTEM SHALL read the session UUID from that file.
- R1.4 IF neither mechanism resolves a session UUID, THE SYSTEM SHALL produce no context-window output and exit normally (graceful degradation — R4).
- R1.5 THE SYSTEM SHALL derive the transcript file path as `~/.claude/projects/<project-slug>/<session-uuid>.jsonl`, where `<project-slug>` is computed from `CLAUDE_PROJECT_DIR` by replacing every `/` with `-`.
- R1.6 IF `CLAUDE_PROJECT_DIR` is not set, THE SYSTEM SHALL fall back to the current working directory for project-slug derivation.
### R2. Token-count extraction from transcript
**As an AI agent, I want planctl to read the actual token counts from my session's transcript, so that the numbers reflect what the API charged my session, not an estimate.**
- R2.1 THE SYSTEM SHALL open the resolved transcript file and scan for the last line where the top-level `"type"` field equals `"assistant"` and a `"message"."usage"` object is present. Scanning SHALL use a line reader with a per-line buffer of at least 10 MB to handle large JSON objects without truncation errors.
- R2.2 THE SYSTEM SHALL compute `tokens_used = input_tokens + cache_read_input_tokens + cache_creation_input_tokens` from that `usage` object, representing the full context size of the most recent API call.
- R2.3 IF the transcript file cannot be opened, no qualifying assistant line is found, or any line cannot be parsed as valid JSON (malformed/partial write), THE SYSTEM SHALL skip that line and continue scanning; IF no qualifying line is found after the full scan, THE SYSTEM SHALL produce no context-window output (graceful degradation — R4).
- R2.4 THE SYSTEM SHALL read `tokens_limit` from `CLAUDE_CODE_MAX_CONTEXT_TOKENS`; IF that env var is absent, `tokens_limit` is treated as unknown (see R3.3 / R4.2 / R5.2 for consolidated unknown-limit behavior).
- R2.5 THE SYSTEM SHALL NOT make any network calls or execute any subprocesses during token extraction.
- R2.6 THE SYSTEM SHALL complete token extraction in under 200 ms for transcripts up to 50 MB.
### R3. Threshold classification and recommendation output
**As an AI agent, I want planctl to classify my context fill level and emit a human-readable recommendation, so that I know exactly what to do next.**
- R3.1 WHEN `tokens_limit` is known, THE SYSTEM SHALL compute `pct = tokens_used / tokens_limit * 100` (integer, truncated).
- R3.2 THE SYSTEM SHALL classify `pct` into one of four bands. The Recommendation suffix below is the tail of the output line (see R4.1 for the full canonical line format):
| Band | Condition | Severity | Recommendation suffix |
|------|-----------|----------|-----------------------|
| Normal | pct < 70 | (silent) | (no context section emitted) |
| Info | 70 ≤ pct < 85 | `info` | `plan to wrap up this session soon.` |
| Warn | 85 ≤ pct < 95 | `warn` | `commit current work and start a new session after this task.` |
| Error | pct ≥ 95 | `error` | `stop new work; commit and close out immediately.` |
- R3.3 WHEN `tokens_limit` is unknown (env var absent), the behavior is:
- **Text:** `context: N k tokens (limit unknown)` — no percentage, no recommendation suffix.
- **JSON:** `"context_window": { "tokens_used": N }``tokens_limit`, `pct`, `severity`, and `recommendation` keys are omitted entirely.
- This case is emitted regardless of threshold (since no threshold can be computed); it replaces the normal band table.
- R3.4 THE SYSTEM SHALL emit the context section on every `planctl` subcommand (`lint`, `next`, `list`, `complete`, `status`) whenever context data is available and the band is Info, Warn, or Error.
- R3.5 THE SYSTEM SHALL NOT emit a context section in the Normal band (pct < 70) even when context data is available, to avoid noise in healthy sessions.
### R4. Text output format
**As a developer reading planctl output, I want the context section to be clearly identifiable, so that I can parse it visually and programmatically.**
- R4.1 WHEN context data is available and the band is not Normal, THE SYSTEM SHALL append a `context:` line immediately after the plan summary line. The canonical format is:
```
context: <used> k / <limit> k tokens (<pct>%) — <recommendation suffix>
```
The `<recommendation suffix>` is the text from the Recommendation suffix column of R3.2 for the matching band. Example:
```
26174-planctl-task-cmds: 4/6 tasks done, 2 open
context: 143 k / 200 k tokens (71%) — plan to wrap up this session soon.
```
- R4.2 WHEN `tokens_limit` is unknown, THE SYSTEM SHALL append (regardless of token count):
```
context: <used> k tokens (limit unknown)
```
Example: `context: 143 k tokens (limit unknown)` — no recommendation suffix is emitted in this case.
- R4.3 In multi-plan output (e.g. `planctl lint` across multiple plan dirs), THE SYSTEM SHALL emit the context line **once**, after the aggregate summary line (e.g. `3 plans linted, 0 errors`). Context is session-level, not plan-level; repeating it per-plan would be redundant.
- R4.4 Token counts SHALL be expressed in thousands rounded to the nearest integer (e.g. `143 k`, `200 k`); counts below 1000 SHALL be expressed as `< 1 k`.
- R4.5 Existing output lines (plan summary, diagnostics, clean/error counts) SHALL NOT be modified.
### R5. JSON output format
**As a tool or CI consumer of `planctl --format=json`, I want context data in a dedicated JSON field, so that I can programmatically act on the recommendation.**
- R5.1 WHEN `--format=json` is active and context data is available (band ≠ Normal), THE SYSTEM SHALL add a `"context_window"` key to the summary JSON object:
```json
{
"summary": { ... },
"context_window": {
"tokens_used": 143000,
"tokens_limit": 200000,
"pct": 71,
"severity": "info",
"recommendation": "plan to wrap up this session soon."
}
}
```
- R5.2 WHEN `tokens_limit` is unknown, THE SYSTEM SHALL emit `"context_window": { "tokens_used": N }` — all other keys (`tokens_limit`, `pct`, `severity`, `recommendation`) are omitted (not set to zero or empty string). This is the authoritative unknown-limit JSON shape; R3.3 consolidates the same rule.
- R5.3 WHEN the band is Normal (no recommendation), THE SYSTEM SHALL omit the `"context_window"` key entirely.
- R5.4 The `"context_window"` object SHALL NOT affect the summary `"errors"` or `"warnings"` counts — it is advisory output, not a lint diagnostic.
### R6. Concurrent-session correctness
**As a multi-agent orchestrator, I want planctl invocations from different Claude Code sessions to read each session's own context data, so that agents don't mislead each other.**
- R6.1 THE SYSTEM SHALL resolve the session UUID per-invocation from the calling process's environment or ancestor PID files, not from any shared global state.
- R6.2 WHEN the `SessionStart` hook writes a PID-keyed session file, the file SHALL be named `/tmp/planctl-sessions/<PPID>` and SHALL contain two lines: `<session-uuid>` on line 1 and the start time of the `$PPID` process on line 2 (as a Unix timestamp or the raw value from `/proc/<pid>/stat` field 22 on Linux / `kinfo_proc.kp_proc.p_starttime` on macOS).
- R6.3 Before using a PID-keyed session file, THE SYSTEM SHALL:
1. Verify the process with that PID is still alive (`syscall.Kill(pid, 0)` — returns error if process doesn't exist).
2. Read the stored start time from the file and compare it to the current process's start time for that PID.
3. IF either check fails (process gone, start time mismatch indicating PID reuse), THE SYSTEM SHALL silently skip that file and continue to the next ancestor in the walk.
### R7. Hook integration (Claude Code)
**As a Claude Code user, I want a minimal hook setup to propagate session identity to planctl, so that context-window awareness works automatically without manual intervention.**
- R7.1 THE SYSTEM SHALL document how to extend the project's existing `SessionStart` hook to write `CLAUDE_SESSION_ID` to `$CLAUDE_ENV_FILE`, enabling env-var propagation throughout the session.
- R7.2 THE SYSTEM SHALL document how to extend the project's existing `SessionEnd` hook to remove the PID-keyed session file from `/tmp/planctl-sessions/`.
- R7.3 The hook changes SHALL be limited to `scripts/jj-hook.sh` — no new hook scripts or hook event types are required.
- R7.4 IF the hooks are not configured, planctl SHALL continue to function without context data (graceful degradation — R1.4, R2.3).
---
## 5. Non-Goals (Out of Scope)
- **Configurable thresholds.** Thresholds (70/85/95%) are hardcoded in v1. No env var or CLI flag overrides.
- **Output token counting.** Only input-side context fill (prompt size) is measured; output tokens are not included in fill calculation.
- **Non-Claude-Code harnesses.** The design is grounded in Claude Code's JSONL transcript format. Support for other LLM providers' session formats is not planned.
- **Automatic context compaction or session restart.** planctl recommends; it does not act. Triggering a new session or compacting context is the agent's responsibility.
- **Cumulative session cost tracking.** planctl reads only the most recent API call's usage; it does not sum tokens across the session history.
- **planctl modifying `CLAUDE_ENV_FILE` or any session state.** planctl is read-only with respect to session state.
- **Support for `--strict` flag escalating context warnings to errors.** Context recommendations are advisory and do not affect exit code.
---
## 6. Design Considerations
Context output appears only when session data is available (session UUID resolved + transcript readable). When data is available but `tokens_limit` is unknown, a raw token count line is always emitted (R3.3/R4.2). When both values are known, the line is emitted only for Info/Warn/Error bands (pct ≥ 70%). In the Normal band with a known limit, nothing is emitted. This ensures zero behavior change for users not running planctl inside a Claude Code session.
In single-plan output, the `context:` line follows the plan summary line. In multi-plan output, it follows the aggregate summary line once (R4.3) — context is session-level, not plan-level.
In JSON mode, `context_window` is a peer of `summary` in the top-level object, not nested inside it, so consumers can easily check `if result.context_window` without deep path access.
---
## 7. Technical Considerations
### Transcript parsing
- Claude Code writes `~/.claude/projects/<slug>/<uuid>.jsonl` — one JSON object per line.
- The relevant schema (verified from live transcripts, Claude Code v2.1.80):
```json
{
"type": "assistant",
"sessionId": "<uuid>",
"message": {
"usage": {
"input_tokens": 10,
"cache_creation_input_tokens": 4161,
"cache_read_input_tokens": 20548,
"output_tokens": 98
}
}
}
```
- Parse using stdlib `encoding/json` and `bufio.Scanner`; no external deps.
- Reverse scan (read all lines, walk backward) to find the last assistant line efficiently.
### PID-ancestor walk
- `os.Getppid()` gives the immediate parent. For each ancestor, retrieve its parent via `syscall.SysctlKinfoProc` (macOS) or by reading `/proc/<pid>/status` (Linux).
- Walk up to 8 hops; stop at PID 1 (init).
- Check `/tmp/planctl-sessions/<pid>` at each hop.
- Implement in a build-tagged or runtime-detected manner to handle both platforms.
### PID file format
The PID file at `/tmp/planctl-sessions/<pid>` SHALL contain exactly two newline-terminated lines:
```
<session-uuid>
<start-time-seconds>
```
`<start-time-seconds>` is the process start time as a decimal integer representing seconds since the Unix epoch (UTC). On Linux: `/proc/<pid>/stat` field 22 is clock ticks since boot; convert using `sysconf(_SC_CLK_TCK)` and boot time from `/proc/stat btime`. On macOS: `kinfo_proc.kp_proc.p_starttime.tv_sec`. Using seconds (not nanoseconds or ticks) gives a consistent on-disk format across platforms and deterministic test fixtures.
### Files touched
- `cmd/planctl/context.go` — new: `TokenCtx`, `ReadTokenCtx()`, `Classify()`
- `cmd/planctl/context_test.go` — new: unit tests
- `cmd/planctl/main.go` — wire `ReadTokenCtx()` into `run()`
- `cmd/planctl/emit.go` — extend text and JSON emitters
- `cmd/planctl/main_test.go` — extend E2E golden tests
- `scripts/jj-hook.sh` — extend `session-start` and `session-end` cases
### Constraints
- No external dependencies beyond what planctl already uses (stdlib + goldmark).
- No network calls.
- No modification of session state.
- Must compile and pass tests on macOS (arm64) and Linux (amd64).
---
## 8. Success Metrics
- **M1.** A Claude Code agent calling `planctl status` at 88% context fill receives a `warn`-level recommendation in both text and JSON output.
- **M2.** Two concurrent Claude Code sessions each calling `planctl next` see recommendations based on their own context fill, not each other's.
- **M3.** Calling `planctl lint` with no session env vars set produces output identical to the current baseline (no context section, no error, no changed exit code).
- **M4.** Token extraction from a 10 MB transcript completes in under 200 ms on the CI runner.
- **M5.** Unit test coverage for `context.go` ≥ 80% (threshold classification, env resolution, JSONL parsing with synthetic fixtures).
- **M6.** Hook changes documented in `INTEGRATION.md` or equivalent, with copy-paste-ready `jj-hook.sh` additions.
---
## 9. Open Questions
- **Q1.** ~~Should `context:` lines appear in multi-plan `lint` output once (aggregate) or per-plan?~~ **Resolved:** context emitted once after the aggregate summary (R4.3).
- **Q2.** Is it worth caching the last-parsed transcript result (e.g., in `/tmp/planctl-ctx-<session-id>`) to avoid re-scanning on every planctl call within the same session? *(Punted to design phase — depends on observed latency.)*
- **Q3.** The transcript schema is from Claude Code v2.1.80 — should planctl version-check the transcript's `"version"` field and warn if it's unexpected? *(Likely too brittle; design phase to decide.)*

View file

@ -8,29 +8,29 @@ Source design: `design.md`
- `cmd/planctl/context.go` — new: `TokenCtx`, `ReadTokenCtx`, `resolveSessionUUID`, `transcriptPath`, `parseLastUsage`, `readLimit`, `Classify`, `formatTokenCount`, `parsePIDFile`.
- `cmd/planctl/context_test.go` — new: unit tests for all pure functions + integration-style `ReadTokenCtx` fixture test.
- `cmd/planctl/proc_darwin.go` — new: `ppidOf`, `processAlive`, `processStartTimeSec` on macOS via `syscall.SysctlKinfoProc` (build tag `//go:build darwin`).
- `cmd/planctl/proc_linux.go` — new: same surface on Linux via safe `/proc/<pid>/stat` parsing (find last `)`, count fields) + platform start-time stamp (build tag `//go:build linux`).
- `cmd/planctl/proc_linux.go` — new: same surface on Linux via safe `/proc/<pid>/stat` parsing (build tag `//go:build linux`).
- `cmd/planctl/proc_test.go` — new: platform-agnostic tests exercising helpers against the calling process PID.
- `cmd/planctl/main.go` — modified: call `ReadTokenCtx()` once in subcommand dispatch and thread `*TokenCtx` into emitters.
- `cmd/planctl/main_test.go` — modified: extend golden-fixture harness for per-fixture env vars and staged transcript JSONL.
- `cmd/planctl/emit.go` — modified: `emitText` / `emitJSON` take `*TokenCtx`; append context line (text) / `context_window` key (JSON); extend `jsonSummary` with `*jsonCtxWin`.
- `cmd/planctl/emit.go` — modified: `emitText` / `emitJSON` take `*TokenCtx`; append context line / `context_window` key; extend `jsonSummary` with `*jsonCtxWin`.
- `cmd/planctl/emit_test.go` — modified: update call sites for new signature; add context-emission tests.
- `cmd/planctl/testdata/v2/context-info/` — new: 71% fill fixture (text).
- `cmd/planctl/testdata/v2/context-warn/` — new: 87% fill fixture (text).
- `cmd/planctl/testdata/v2/context-error/` — new: 96% fill fixture (text).
- `cmd/planctl/testdata/v2/context-normal/` — new: 50% fill fixture — asserts no context line emitted.
- `cmd/planctl/testdata/v2/context-no-session/` — new: no env vars — asserts baseline output unchanged.
- `cmd/planctl/testdata/v2/context-normal/` — new: 50% fill no context line emitted.
- `cmd/planctl/testdata/v2/context-no-session/` — new: no env vars — baseline unchanged.
- `cmd/planctl/testdata/v2/context-no-limit/` — new: session set, limit unset — raw count + `(limit unknown)`.
- `cmd/planctl/testdata/v2/context-json-warn/` — new: JSON mode at warn band — asserts `context_window` shape.
- `cmd/planctl/testdata/v2/context-multi-plan/` — new: multi-plan — single context line after aggregate summary.
- `scripts/jj-hook.sh` — modified: `session-start` writes `CLAUDE_SESSION_ID` to `$CLAUDE_ENV_FILE` and atomically writes `/tmp/planctl-sessions/<PPID>`; `session-end` removes that file; adds `planctl_start_ticks` helper.
- `scripts/test-jj-hooks.sh` — modified: start/end lifecycle assertion for `/tmp/planctl-sessions/<PPID>`.
- `INTEGRATION.md` — modified: context-window section with copy-paste hook snippets and `CLAUDE_CODE_MAX_CONTEXT_TOKENS` docs.
- `cmd/planctl/testdata/v2/context-json-warn/` — new: JSON mode at warn band.
- `cmd/planctl/testdata/v2/context-multi-plan/` — new: single context line after aggregate summary.
- `scripts/jj-hook.sh` — modified: session tracking with UUID validation + `planctl_start_ticks` helper.
- `scripts/test-jj-hooks.sh` — modified: PID-file lifecycle assertion.
- `INTEGRATION.md` — modified: context-window section with hook snippets and env var docs.
### Notes
- Run tests with `go test ./cmd/planctl/...`. Hook integration test: `bash scripts/test-jj-hooks.sh`.
- VCS convention per `AGENTS.md`: jj feature branch off `main`, parent-task commits at each checkpoint.
- Source-code traceability tag format: `// spec:26174-planctl-context-tokens/<ref>` where `<ref>` is `R<n.m>`, `D§<n>`, or `T<n.m>`. Example: `// spec:26174-planctl-context-tokens/R2.1`.
- Source-code traceability tag format: `// spec:26174-planctl-context-tokens/<ref>`. Example: `// spec:26174-planctl-context-tokens/R2.1`.
## Instructions for Completing Tasks
@ -38,39 +38,39 @@ As you complete each task, flip `[ ]` to `[x]` in this file. Update after each s
## Tasks
- [ ] 0.0 Create feature branch _Requirements: infra_
- [ ] 0.1 Start jj change on `main`: `jj new main -m "feat: 26174-planctl-context-tokens"` _Requirements: infra_
- [ ] 0.2 Create bookmark: `jj bookmark create feature/26174-planctl-context-tokens` _Requirements: infra_
- [x] 0.0 Create feature branch _Requirements: infra_
- [x] 0.1 Start jj change on `main`: `jj new main -m "feat: 26174-planctl-context-tokens"` _Requirements: infra_ _(actual: daemon auto-snapshots comingled divergent branches; @ at `spqotxrs feat: 26174-planctl-context-tokens` with plans files visible via ancestry — pragmatic continuation)_
- [x] 0.2 Create bookmark: `jj bookmark create feature/26174-planctl-context-tokens` _Requirements: infra_
- [ ] 1.0 Implement `context.go`, platform helpers, and unit tests _Requirements: R1.1R1.6, R2.1R2.6, R3.1R3.3, R3.5, R4.4, R6.1R6.3_ _Design: D§1, D§3.13.9, D§6.1, D§7.1_
- [ ] 1.1 Create `cmd/planctl/context.go` with `TokenCtx` struct and `formatTokenCount(n int64) string` per design §3.1/§3.8. Add `cmd/planctl/context_test.go` with `TestFormatTokenCount` covering n=0→`< 1 k`, n=500→`< 1 k`, n=999→`< 1 k`, n=1000→`1 k`, n=1499→`1 k`, n=1500→`2 k`. _Requirements: R2.2, R2.4, R4.4_ _Design: D§3.1, D§3.8, D§7.1_
- [ ] 1.2 Add `(t *TokenCtx) Classify() (severity, msg string)` per design §3.7 — Normal/Info/Warn/Error bands plus unknown-limit branch. Add `TestClassify_bands` (table-driven: pct 0/69/70/84/85/94/95/100/nil ctx) and `TestClassify_unknownLimit` (`Limit == 0``("", "")`). _Requirements: R3.1, R3.2, R3.3, R3.5_ _Design: D§3.7, D§7.1_
- [ ] 1.3 Add `readLimit() int64` per design §3.6 — read `CLAUDE_CODE_MAX_CONTEXT_TOKENS`, parse with `strconv.ParseInt`, return 0 on unset/parse error. Add `TestReadLimit_set` / `_unset` / `_invalid` using `t.Setenv`. _Requirements: R2.4_ _Design: D§3.6, D§7.1_
- [ ] 1.4 Add `transcriptPath(uuid, home, projectDir string) string` per design §3.4 — replace `/` with `-` in projectDir, fall back to `os.Getwd()` when empty. Add `TestTranscriptPath_slash` (leading slash → correct slug), `_nested`, `_fallbackCwd`. _Requirements: R1.5, R1.6_ _Design: D§3.4, D§7.1_
- [ ] 1.5 Add `parsePIDFile(path string) (uuid string, startStamp int64, err error)` per design §3.9 — read exactly two newline-terminated lines; second line parses as int64. Add `TestParsePIDFile_valid`, `_missingLine2`, `_malformedInt`. _Requirements: R6.2, R6.3_ _Design: D§3.9, D§4.1, D§7.1_
- [ ] 1.6 Add `parseLastUsage(path string) (int64, error)` per design §3.5 — `bufio.Scanner` with 10 MB buffer (`Scanner.Buffer(make([]byte, 10<<20), 10<<20)`), forward scan; for each line that decodes to `transcriptUsage` with `Type == "assistant"` and a present `"message"."usage"` object, compute sum and overwrite `last`. Skip JSON-decode errors. Return `errNoUsageLine` when none found. Add `TestParseLastUsage_valid`, `_malformedMid`, `_noAssistant`, `_emptyFile`, `_zeroUsageStillQualifies` (zero-sum assistant line qualifies — PRD R2.1 checks presence not magnitude). _Requirements: R2.1, R2.2, R2.3, R2.6_ _Design: D§3.5, D§4.2, D§7.1_
- [ ] 1.7 Add `cmd/planctl/proc_darwin.go` (`//go:build darwin`) implementing `ppidOf`, `processAlive`, `processStartTimeSec` via `syscall.SysctlKinfoProc("kern.proc.pid", pid)`. Add `cmd/planctl/proc_linux.go` (`//go:build linux`) implementing same surface: `ppidOf` reads PPid from `/proc/<pid>/status`; `processStartTimeSec` reads starttime from `/proc/<pid>/stat` using safe parsing — `strings.LastIndex` to find last `)`, then split remaining fields, field index 20 is starttime (clock ticks since boot). Add `cmd/planctl/proc_test.go` exercising all three against `os.Getpid()` / `os.Getppid()`. _Requirements: R1.2, R6.3_ _Design: D§3.3, D§7.3_
- [ ] 1.8 Add `resolveSessionUUID() string` per design §3.3 — env var first; then up-to-8-hop PID walk calling `parsePIDFile` + `processAlive` + `processStartTimeSec`. Return `""` when exhausted. For testability: use an unexported `var pidSessionDir = "/tmp/planctl-sessions"` that tests override via `t.Setenv` or by setting the package var directly. Add `TestResolveSessionUUID_envWins`, `_pidWalkFinds`, `_staleSkipped` (dead PID), `_startTimeMismatch`, `_exhausted`. _Requirements: R1.1, R1.2, R1.3, R1.4, R6.1, R6.3_ _Design: D§3.3, D§6.2, D§7.1_
- [ ] 1.9 Wire `ReadTokenCtx() *TokenCtx` per design §3.2 — compose `resolveSessionUUID``transcriptPath` (reading `HOME`, `CLAUDE_PROJECT_DIR`) → `parseLastUsage``readLimit`. Return `nil` at every failure point. Add `TestReadTokenCtx_happyPath` (fixture JSONL in temp HOME, env set) and `TestReadTokenCtx_noSession` (no env, no PID files → nil). _Requirements: R1.1R1.6, R2.1R2.6_ _Design: D§3.2, D§7.1_
- [x] 1.0 Implement `context.go`, platform helpers, and unit tests _Requirements: R1.1-R1.6, R2.1-R2.6, R3.1-R3.3, R3.5, R4.4, R6.1-R6.3_ _Design: D§1, D§3.1-3.9, D§6.1, D§7.1_
- [x] 1.1 Create `cmd/planctl/context.go` with `TokenCtx` struct and `formatTokenCount(n int64) string` per design §3.1/§3.8. Add `cmd/planctl/context_test.go` with `TestFormatTokenCount` covering n=0→`< 1 k`, n=500→`< 1 k`, n=999→`< 1 k`, n=1000→`1 k`, n=1499→`1 k`, n=1500→`2 k`. _Requirements: R2.2, R2.4, R4.4_ _Design: D§3.1, D§3.8, D§7.1_
- [x] 1.2 Add `(t *TokenCtx) Classify() (severity, msg string)` per design §3.7. Add `TestClassify_bands` (pct 0/69/70/84/85/94/95/100/nil) and `TestClassify_unknownLimit` (`Limit == 0``("", "")`). _Requirements: R3.1, R3.2, R3.3, R3.5_ _Design: D§3.7, D§7.1_
- [x] 1.3 Add `readLimit() int64` per design §3.6 — reads `CLAUDE_CODE_MAX_CONTEXT_TOKENS`, returns 0 on unset/invalid. Add `TestReadLimit_set` / `_unset` / `_invalid`. _Requirements: R2.4_ _Design: D§3.6, D§7.1_
- [x] 1.4 Add `transcriptPath(uuid, home, projectDir string) string` per design §3.4 — replace `/` with `-`, fall back to `os.Getwd()`. Add `TestTranscriptPath_slash`, `_nested`, `_fallbackCwd`. _Requirements: R1.5, R1.6_ _Design: D§3.4, D§7.1_
- [x] 1.5 Add `parsePIDFile(path string) (uuid string, startStamp int64, err error)` per design §3.9 — two lines, second parses as int64. Add `TestParsePIDFile_valid`, `_missingLine2`, `_malformedInt`. _Requirements: R6.2, R6.3_ _Design: D§3.9, D§4.1, D§7.1_
- [x] 1.6 Add `parseLastUsage(path string) (int64, error)` per design §3.5 — `bufio.Scanner` 10 MB buffer, forward scan, last `type=assistant` with present `message.usage` wins (presence per PRD R2.1, not non-zero). Skip decode errors. Return `errNoUsageLine` when none. Add `TestParseLastUsage_valid` / `_malformedMid` / `_noAssistant` / `_emptyFile` / `_zeroUsageStillQualifies`. _Requirements: R2.1, R2.2, R2.3, R2.6_ _Design: D§3.5, D§4.2, D§7.1_
- [x] 1.7 Add `proc_darwin.go` (`//go:build darwin`) using `golang.org/x/sys/unix.SysctlKinfoProc` for `ppidOf`/`processStartTimeSec` _(stdlib `syscall.KinfoProc` no longer exposed on modern Go — narrow design §7 divergence, noted inline)_. Add `proc_linux.go` (`//go:build linux`): `ppidOf` reads PPid from `/proc/<pid>/status`; `processStartTimeSec` reads starttime from `/proc/<pid>/stat` via safe parsing — `strings.LastIndex` to find last `)`, then field 20 of remainder (raw clock ticks since boot; hook writes ticks too per §5.5/§4.1, so comparison units match). Add `proc_test.go` exercising all three against `os.Getpid()`/`os.Getppid()`. _Requirements: R1.2, R6.3_ _Design: D§3.3, D§7.3_
- [x] 1.8 Add `resolveSessionUUID() string` per design §3.3 — env var first; then up-to-8-hop PID walk via injectable-helper inner function `resolveViaPIDWalk`. Use unexported `var pidSessionDir = "/tmp/planctl-sessions"` for testability. Add `TestResolveSessionUUID_envWins` + `TestResolveViaPIDWalk_pidWalkFinds` / `_staleSkipped` / `_startTimeMismatch` / `_exhausted`. _Requirements: R1.1, R1.2, R1.3, R1.4, R6.1, R6.3_ _Design: D§3.3, D§6.2, D§6.3, D§7.1_
- [x] 1.9 Wire `ReadTokenCtx() *TokenCtx` per design §3.2 — compose resolveSessionUUID → transcriptPath → parseLastUsage → readLimit; nil at every failure. Add `TestReadTokenCtx_happyPath` and `_noSession`. No subprocesses invoked (D§6.4). _Requirements: R1.1-R1.6, R2.1-R2.6_ _Design: D§3.2, D§6.4, D§7.1_
- [ ] 2.0 Wire token context into planctl output (`main.go`, `emit.go`, E2E golden tests) _Requirements: R3.4, R4.1R4.5, R5.1R5.4_ _Design: D§4.3, D§4.4, D§5.3, D§5.4, D§7.2_
- [ ] 2.1 Extend `emit.go` types: add `jsonCtxWin` struct with `TokensUsed int64`, `TokensLimit *int64`, `Pct *int`, `Severity string`, `Recommendation string` (pointer fields `omitempty`); extend `jsonSummary` with `ContextWin *jsonCtxWin \`json:"context_window,omitempty"\``. _Requirements: R5.1, R5.2, R5.3_ _Design: D§5.4_
- [ ] 2.2 Change signatures of `emitText` and `emitJSON` to `(w io.Writer, plans []PlanResult, strict bool, ctx *TokenCtx) int`. In `emitText`: append canonical context line after plan/aggregate summary for Info/Warn/Error bands (R4.1) or unknown-limit form (R4.2); multi-plan: emit once after aggregate (R4.3); Normal/nil: omit (R3.5). In `emitJSON`: populate `context_window` for Info/Warn/Error; omit for Normal/nil (R5.3); context never changes `errors`/`warnings` (R5.4). NOTE: 2.1, 2.2, and 2.3 must all land in the same parent-task commit — the signature change breaks callers immediately. _Requirements: R3.4, R3.5, R4.1R4.3, R4.5, R5.1R5.4_ _Design: D§4.3, D§4.4, D§5.3_
- [ ] 2.3 Update `main.go` subcommand dispatch: call `ReadTokenCtx()` once at entry; pass `ctx` to `emitText`/`emitJSON` for every subcommand (`lint`, `next`, `list`, `complete`, `status`). Must land in same commit as 2.1/2.2 (see note on 2.2). _Requirements: R3.4_ _Design: D§1, D§2_
- [ ] 2.4 Update `emit_test.go` and `main_test.go` call sites to pass `nil` ctx where no context behavior is asserted. Add `emit_test.go` cases: info/warn/error text band, unknown-limit text, Normal omission, JSON known-limit shape, JSON unknown-limit shape, multi-plan aggregate placement. _Requirements: R4.1R4.5, R5.1R5.4_ _Design: D§4.3, D§4.4, D§7.2_
- [ ] 2.5 Extend golden-fixture harness in `main_test.go`: for each fixture dir, if `env.txt` exists load KEY=VALUE pairs via `t.Setenv`; if `transcript.jsonl` exists, copy it to a temp HOME at `~/.claude/projects/<slug>/<uuid>.jsonl` (slug and uuid from env.txt) and set `HOME` env to the temp dir. _Requirements: R3.4, R4.1_ _Design: D§7.2_
- [ ] 2.6 Create `testdata/v2/context-info/`, `context-warn/`, `context-error/`, `context-normal/`, `context-no-session/`, `context-no-limit/` — each with `prd.md`, `tasks.md`, `env.txt`, `transcript.jsonl` (where applicable), `expected.golden`, `expected.exit`. _Requirements: R3.2, R3.3, R3.5, R4.1, R4.2, R4.5_ _Design: D§4.3, D§7.2_
- [ ] 2.7 Create `testdata/v2/context-json-warn/` (`--format=json`, warn band; assert `context_window` shape) and `testdata/v2/context-multi-plan/` (two plan dirs; assert single context line after aggregate). _Requirements: R4.3, R5.1R5.4_ _Design: D§4.3, D§4.4, D§7.2_
- [ ] 2.8 Run `go test ./cmd/planctl/...`; confirm all existing fixtures remain byte-identical and the eight new context fixtures pass. _Requirements: R4.5_ _Design: D§7.2_
- [x] 2.0 Wire token context into planctl output (`main.go`, `emit.go`, E2E golden tests) _Requirements: R3.4, R4.1-R4.5, R5.1-R5.4_ _Design: D§4.3, D§4.4, D§5.3, D§5.4, D§7.2_
- [x] 2.1 Extend `emit.go` types: add `jsonCtxWin` struct with `TokensUsed int64`, `TokensLimit *int64`, `Pct *int`, `Severity string`, `Recommendation string` (pointer fields `omitempty`); extend `jsonSummary` with `ContextWin *jsonCtxWin`. _Requirements: R5.1, R5.2, R5.3_ _Design: D§5.4_
- [x] 2.2 Change `emitText` and `emitJSON` signatures to accept `*TokenCtx`. Text: canonical context line for Info/Warn/Error, unknown-limit form, multi-plan once after aggregate, Normal/nil silent. JSON: `context_window` for Info/Warn/Error, omit for Normal/nil; context never changes error/warning counts. NOTE: 2.1, 2.2, 2.3 must all land in the same parent-task commit — signature change breaks callers immediately. _Requirements: R3.4, R3.5, R4.1-R4.3, R4.5, R5.1-R5.4_ _Design: D§4.3, D§4.4, D§5.3_
- [x] 2.3 Update `main.go`: call `ReadTokenCtx()` once at entry; thread `ctx` to `emitText`/`emitJSON` for all subcommands _(only `lint` in this v1 — other v2 commands aren't in the current codebase)_. _Requirements: R3.4_ _Design: D§1, D§2_
- [x] 2.4 Update `emit_test.go` and `main_test.go` call sites to pass `nil` ctx where no context asserted. Add `emit_test.go` cases: text info/warn/error band, unknown-limit, Normal omission, JSON known+unknown shapes, multi-plan placement. _Requirements: R4.1-R4.5, R5.1-R5.4_ _Design: D§4.3, D§4.4, D§7.2_
- [x] 2.5 Extend golden-fixture harness: if `env.txt` exists load KEY=VALUE via `t.Setenv`; if `transcript.jsonl` exists stage in temp HOME at `~/.claude/projects/<slug>/<uuid>.jsonl`. _Requirements: R3.4, R4.1_ _Design: D§7.2_
- [x] 2.6 Create `testdata/v2/context-info/`, `context-warn/`, `context-error/`, `context-normal/`, `context-no-session/`, `context-no-limit/` — each with `prd.md`, `tasks.md`, `env.txt`, `transcript.jsonl` (where applicable), `expected.golden`, `expected.exit`. _Requirements: R3.2, R3.3, R3.5, R4.1, R4.2, R4.5_ _Design: D§4.3, D§7.2_
- [x] 2.7 Create `testdata/v2/context-json-warn/` (warn band, JSON format) and `testdata/v2/context-multi-plan/` (two plan dirs, context once after aggregate). _Requirements: R4.3, R5.1-R5.4_ _Design: D§4.3, D§4.4, D§7.2_
- [x] 2.8 Run `go test ./cmd/planctl/...`; all existing fixtures byte-identical, eight new context fixtures pass. _Requirements: R4.5_ _Design: D§7.2_
- [ ] 3.0 Extend `scripts/jj-hook.sh` with session tracking _Requirements: R7.1, R7.2, R7.3_ _Design: D§4.1, D§5.1, D§5.5, D§7.4_
- [ ] 3.1 Add `planctl_start_ticks()` shell function per design §5.5: Linux — `sed 's/.*)//' "/proc/$pid/stat" | awk '{print $20}'`; macOS — `ps -o lstart= -p "$pid" | xargs -I{} date -j -f '%a %b %d %T %Y' '{}' '+%s'`; both return `0` on failure. _Requirements: R6.2, R7.3_ _Design: D§5.5_
- [ ] 3.2 Extend `session-start`: parse `.session_id` from `$INPUT` via `jq`; validate UUID regex (`[[ $FULL_SID =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]`); append `export CLAUDE_SESSION_ID=…` to `$CLAUDE_ENV_FILE`; `mkdir -p /tmp/planctl-sessions` (creates the temp directory per §5.1); write two-line temp file via `mktemp` and `mv` to `/tmp/planctl-sessions/${PPID}`. _Requirements: R6.2, R7.1, R7.3_ _Design: D§4.1, D§5.1, D§5.5_
- [ ] 3.3 Extend `session-end`: parse `.session_id`; when non-empty, `rm -f /tmp/planctl-sessions/${PPID}`. _Requirements: R7.2, R7.3_ _Design: D§5.5_
- [ ] 3.4 Extend `scripts/test-jj-hooks.sh`: synthesized `session-start` payload; assert `/tmp/planctl-sessions/<pid>` exists with two lines (UUID + numeric stamp); invoke `session-end`; assert file removed. _Requirements: R7.1, R7.2_ _Design: D§7.4_
- [ ] 3.5 Manual smoke in a live Claude Code session: confirm PID file appears on start, `planctl status` emits `context:` line at ≥ 70% fill, file removed on exit. _Requirements: R7.1, R7.2_ _Design: D§7.4_
- [x] 3.0 Extend `scripts/jj-hook.sh` with session tracking _Requirements: R7.1, R7.2, R7.3_ _Design: D§4.1, D§5.1, D§5.5, D§7.4_
- [x] 3.1 Add `planctl_start_ticks()` per design §5.5: Linux — safe `/proc/<pid>/stat` parse via `sed 's/.*)//' | awk '{print $20}'`; macOS — `ps -o lstart=``date` epoch seconds. Returns 0 on failure. _Requirements: R6.2, R7.3_ _Design: D§5.5_
- [x] 3.2 Extend `session-start`: parse `.session_id` via `jq`; validate UUID regex; append `CLAUDE_SESSION_ID` to `$CLAUDE_ENV_FILE`; `mkdir -p /tmp/planctl-sessions` (creates temp dir per §5.1); write two-line temp file via `mktemp` and `mv` atomically. _Requirements: R6.2, R7.1, R7.3_ _Design: D§4.1, D§5.1, D§5.5_
- [x] 3.3 Extend `session-end`: `rm -f /tmp/planctl-sessions/${PPID}`. _Requirements: R7.2, R7.3_ _Design: D§5.5_
- [x] 3.4 Extend `scripts/test-jj-hooks.sh`: assert PID file created on start with two lines (UUID + numeric stamp); assert removed on end. Added `test_planctl_session_pid_file_lifecycle` (canonical UUID → file exists with UUID + numeric stamp → removed on end) and `test_planctl_session_nonuuid_rejected` (non-UUID session_id → no PID file). _Requirements: R7.1, R7.2_ _Design: D§7.4_
- [x] 3.5 Manual smoke verified via direct `bash scripts/jj-hook.sh session-start / session-end` invocation: PID file at `/tmp/planctl-sessions/<pid>` appears with two lines (UUID + numeric stamp), `$CLAUDE_ENV_FILE` gets `export CLAUDE_SESSION_ID=…` line appended, PID file is removed on session-end. Live-session smoke deferred until the feature is merged. _Requirements: R7.1, R7.2_ _Design: D§7.4_
- [ ] 4.0 Document hook integration in `INTEGRATION.md` _Requirements: R7.1, R7.2, R7.4_ _Design: D§5.5_
- [ ] 4.1 Add "Context-window token awareness" section to `INTEGRATION.md`: threshold bands (70/85/95%), text line format, JSON `context_window` shape. _Requirements: R4.1, R4.2, R5.1_ _Design: D§4.3, D§4.4_
- [ ] 4.2 Include copy-paste `session-start` / `session-end` additions (matching 3.0) and `planctl_start_ticks` helper. _Requirements: R7.1, R7.2, R7.3_ _Design: D§5.5_
- [ ] 4.3 Document `CLAUDE_CODE_MAX_CONTEXT_TOKENS`: when unset, raw count only; hooks optional; graceful degradation when absent. _Requirements: R2.4, R3.3, R7.4_ _Design: D§5.2_
- [x] 4.0 Document hook integration in `INTEGRATION.md` _Requirements: R7.1, R7.2, R7.4_ _Design: D§5.5_
- [x] 4.1 Add context-window section: threshold bands, text line format, JSON `context_window` shape. _Requirements: R4.1, R4.2, R5.1_ _Design: D§4.3, D§4.4_
- [x] 4.2 Copy-paste `session-start`/`session-end` additions and `planctl_start_ticks` helper. _Requirements: R7.1, R7.2, R7.3_ _Design: D§5.5_
- [x] 4.3 Document `CLAUDE_CODE_MAX_CONTEXT_TOKENS`: raw count when unset; hooks optional; graceful degradation. _Requirements: R2.4, R3.3, R7.4_ _Design: D§5.2_

2
go.mod
View file

@ -3,3 +3,5 @@ module forgejo.zerova.net/sid/template-jj
go 1.26.1
require github.com/yuin/goldmark v1.8.2
require golang.org/x/sys v0.43.0

2
go.sum
View file

@ -1,2 +1,4 @@
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=

View file

@ -30,6 +30,29 @@ ACTION="${1:-}"
REPO_ID=$(printf '%s' "$REPO_ROOT" | shasum -a 256 | cut -c1-12)
SOCK="/tmp/jj-commitd-${REPO_ID}.sock"
# spec:26174-planctl-context-tokens/R6.2+R7.3+D§5.5
# planctl_start_ticks prints a platform-appropriate start-time stamp for pid,
# matching the unit the Go `processStartTimeSec` helper reads for that PID:
# Linux — raw clock ticks since boot (/proc/<pid>/stat field 22). The stat
# file's `comm` field can contain ')' and spaces, so we strip everything
# up to and including the LAST ')' before tokenising (matches the safe
# parse in cmd/planctl/proc_linux.go).
# macOS — epoch seconds from `ps -o lstart=` converted with `date -j`.
# Prints 0 on failure so the caller can still atomically write a PID file
# whose start-time mismatches the live process — resolveSessionUUID will
# (correctly) skip it.
planctl_start_ticks() {
local pid="$1"
if [ -f "/proc/$pid/stat" ]; then
local tail
tail=$(sed 's/.*)//' "/proc/$pid/stat" 2>/dev/null) || { echo 0; return; }
# After the last ')': state ppid ... starttime (starttime is field 20 of tail).
echo "$tail" | awk '{print $20}' | grep -E '^[0-9]+$' || echo 0
else
ps -o lstart= -p "$pid" 2>/dev/null | xargs -I{} date -j -f '%a %b %d %T %Y' '{}' '+%s' 2>/dev/null || echo 0
fi
}
# ── Daemon management ────────────────────────────────────────────
find_daemon() {
@ -110,6 +133,30 @@ case "$ACTION" in
# Pass PPID (Claude Code process), not $$ (this short-lived script).
send_event "{\"event\":\"session-start\",\"session_id\":\"${SID}\",\"repo_root\":\"${REPO_ROOT}\",\"pid\":${PPID}}"
fi
# spec:26174-planctl-context-tokens/R6.2+R7.1+R7.3+D§4.1+D§5.1+D§5.5
# planctl context-window tracking:
# (1) Propagate the FULL session UUID via $CLAUDE_ENV_FILE (if set by
# Claude Code) so `planctl` can locate the transcript without PID
# walking.
# (2) Write a PID-keyed fallback file at /tmp/planctl-sessions/<PPID>
# containing UUID + process-start-stamp, so a planctl invoked from
# a descendant process whose env was lost can still resolve the
# session. Atomic mktemp+mv prevents partial reads.
# Validates the UUID with a canonical-form regex — anything looking other
# than hex-dash-hex is rejected to keep hostile/garbage input out of the
# env file and PID file.
FULL_SID=$(printf '%s\n' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null)
if [[ "$FULL_SID" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then
if [ -n "${CLAUDE_ENV_FILE:-}" ]; then
echo "export CLAUDE_SESSION_ID=${FULL_SID}" >> "$CLAUDE_ENV_FILE"
fi
START_TICKS=$(planctl_start_ticks "$PPID")
mkdir -p /tmp/planctl-sessions
TMPF=$(mktemp /tmp/planctl-sessions/.tmp.XXXXXX)
printf '%s\n%s\n' "$FULL_SID" "$START_TICKS" > "$TMPF"
mv -f "$TMPF" "/tmp/planctl-sessions/${PPID}"
fi
;;
session-end)
@ -123,6 +170,12 @@ case "$ACTION" in
else
fallback_session_end "$SID"
fi
# spec:26174-planctl-context-tokens/R7.2+R7.3+D§5.5
# Clean up planctl PID-keyed session file. rm -f is idempotent; if the
# file was never written (UUID validation failed on start, or no session
# info in the payload), the remove is a no-op.
rm -f "/tmp/planctl-sessions/${PPID}"
;;
post-edit)

View file

@ -806,6 +806,57 @@ test_session_start_creates_base_file() {
teardown_repo
}
# spec:26174-planctl-context-tokens/R7.1+R7.2+D§7.4
# Exercises the planctl context-window integration: session-start with a
# canonical-UUID session_id must create /tmp/planctl-sessions/<PPID> with the
# UUID on line 1 and a numeric start-stamp on line 2; session-end must remove it.
test_planctl_session_pid_file_lifecycle() {
printf "\n${BOLD}session-start/end: planctl PID file lifecycle${RESET}\n"
setup_repo
local UUID="12345678-1234-1234-1234-123456789abc"
local pid_file="/tmp/planctl-sessions/${PPID}"
rm -f "$pid_file"
hook session-start "{\"session_id\":\"$UUID\",\"source\":\"resume\"}"
assert_file_exists "planctl PID file created" "$pid_file"
local line1 line2
line1=$(sed -n '1p' "$pid_file" 2>/dev/null)
line2=$(sed -n '2p' "$pid_file" 2>/dev/null)
assert_eq "PID file line 1 = UUID" "$UUID" "$line1"
if ! [[ "$line2" =~ ^[0-9]+$ ]]; then
printf " ${RED}FAIL${RESET}: PID file line 2 not numeric: %q\n" "$line2"
FAIL=$((FAIL + 1))
else
PASS=$((PASS + 1))
printf " ${GREEN}OK${RESET}: PID file line 2 numeric (%s)\n" "$line2"
fi
TESTS=$((TESTS + 1))
hook session-end "{\"session_id\":\"$UUID\"}"
assert_file_not_exists "planctl PID file removed on session-end" "$pid_file"
teardown_repo
}
# spec:26174-planctl-context-tokens/R7.1+D§4.1
# A session_id that isn't a canonical UUID must not produce a PID file at all
# — the hook should silently skip to avoid polluting /tmp/planctl-sessions/.
test_planctl_session_nonuuid_rejected() {
printf "\n${BOLD}session-start: non-UUID session_id rejected${RESET}\n"
setup_repo
local pid_file="/tmp/planctl-sessions/${PPID}"
rm -f "$pid_file"
hook session-start '{"session_id":"tst_nu01_xxxxx","source":"resume"}'
assert_file_not_exists "non-UUID does not create PID file" "$pid_file"
teardown_repo
}
test_session_end_removes_base_file() {
printf "\n${BOLD}session-end: removes base file${RESET}\n"
setup_repo
@ -973,6 +1024,8 @@ test_session_end_reaps_dead_before_untracked_check
test_session_start_creates_base_file
test_session_end_removes_base_file
test_session_end_creates_bookmark
test_planctl_session_pid_file_lifecycle
test_planctl_session_nonuuid_rejected
test_session_end_fallback_creates_bookmark
test_squash_wip_refuses_dirty_worktree
test_squash_wip