diff --git a/AGENTS.md b/AGENTS.md index 495ad6f..f33ca9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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-.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 ../-`. See `dev/README.md` → "Primary isolation: jj workspaces". +- **Each session commits its own tracked files** under a per-session commit message (`wip(claude:): `) and bookmark (`wip/claude-` 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=` before starting will label commits as `wip(:)` and set a `feat/` 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-.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) diff --git a/INTEGRATION.md b/INTEGRATION.md index a606a84..da81672 100644 --- a/INTEGRATION.md +++ b/INTEGRATION.md @@ -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) | +| 70–84% | Info | `info` | `plan to wrap up this session soon.` | +| 85–94% | 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//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//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/` 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. diff --git a/cmd/planctl/context.go b/cmd/planctl/context.go new file mode 100644 index 0000000..607ce86 --- /dev/null +++ b/cmd/planctl/context.go @@ -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: +// +// \n +// \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." + } +} diff --git a/cmd/planctl/context_test.go b/cmd/planctl/context_test.go new file mode 100644 index 0000000..c228972 --- /dev/null +++ b/cmd/planctl/context_test.go @@ -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) + } +} diff --git a/cmd/planctl/emit.go b/cmd/planctl/emit.go index 35a642b..a6f8a95 100644 --- a/cmd/planctl/emit.go +++ b/cmd/planctl/emit.go @@ -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//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 diff --git a/cmd/planctl/emit_test.go b/cmd/planctl/emit_test.go index b382c69..a518d54 100644 --- a/cmd/planctl/emit_test.go +++ b/cmd/planctl/emit_test.go @@ -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()) + } +} diff --git a/cmd/planctl/main.go b/cmd/planctl/main.go index 29122e1..7864920 100644 --- a/cmd/planctl/main.go +++ b/cmd/planctl/main.go @@ -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) +} diff --git a/cmd/planctl/main_test.go b/cmd/planctl/main_test.go index b60cfbf..03801cc 100644 --- a/cmd/planctl/main_test.go +++ b/cmd/planctl/main_test.go @@ -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//.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 diff --git a/cmd/planctl/proc_darwin.go b/cmd/planctl/proc_darwin.go new file mode 100644 index 0000000..361de38 --- /dev/null +++ b/cmd/planctl/proc_darwin.go @@ -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 +} diff --git a/cmd/planctl/proc_linux.go b/cmd/planctl/proc_linux.go new file mode 100644 index 0000000..2ebdd48 --- /dev/null +++ b/cmd/planctl/proc_linux.go @@ -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//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//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//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)) +} diff --git a/cmd/planctl/proc_stat_parse.go b/cmd/planctl/proc_stat_parse.go new file mode 100644 index 0000000..78004fb --- /dev/null +++ b/cmd/planctl/proc_stat_parse.go @@ -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//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 +} diff --git a/cmd/planctl/proc_stat_parse_test.go b/cmd/planctl/proc_stat_parse_test.go new file mode 100644 index 0000000..fc173b4 --- /dev/null +++ b/cmd/planctl/proc_stat_parse_test.go @@ -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") + } +} diff --git a/cmd/planctl/proc_test.go b/cmd/planctl/proc_test.go new file mode 100644 index 0000000..acf1f98 --- /dev/null +++ b/cmd/planctl/proc_test.go @@ -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) + } +} diff --git a/cmd/planctl/tasks.go b/cmd/planctl/tasks.go index 37f68c6..1e6be71 100644 --- a/cmd/planctl/tasks.go +++ b/cmd/planctl/tasks.go @@ -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) { diff --git a/cmd/planctl/testdata/v2/context-error/env.txt b/cmd/planctl/testdata/v2/context-error/env.txt new file mode 100644 index 0000000..e7015f5 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-error/env.txt @@ -0,0 +1,3 @@ +CLAUDE_SESSION_ID=fixture-uuid +CLAUDE_PROJECT_DIR=/fixture/project +CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000 diff --git a/cmd/planctl/testdata/v2/context-error/expected.exit b/cmd/planctl/testdata/v2/context-error/expected.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-error/expected.exit @@ -0,0 +1 @@ +0 diff --git a/cmd/planctl/testdata/v2/context-error/expected.golden b/cmd/planctl/testdata/v2/context-error/expected.golden new file mode 100644 index 0000000..9683ab1 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-error/expected.golden @@ -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. diff --git a/cmd/planctl/testdata/v2/context-error/prd.md b/cmd/planctl/testdata/v2/context-error/prd.md new file mode 100644 index 0000000..864d61a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-error/prd.md @@ -0,0 +1,6 @@ +# PRD — context fixture + +## 4. Functional Requirements + +### R1. Greet +- R1.1 THE SYSTEM SHALL greet the user on startup. diff --git a/cmd/planctl/testdata/v2/context-error/tasks.md b/cmd/planctl/testdata/v2/context-error/tasks.md new file mode 100644 index 0000000..3746b46 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-error/tasks.md @@ -0,0 +1,3 @@ +# Tasks — context fixture + +- [ ] 1.0 Implement greet _Requirements: R1.1_ diff --git a/cmd/planctl/testdata/v2/context-error/transcript.jsonl b/cmd/planctl/testdata/v2/context-error/transcript.jsonl new file mode 100644 index 0000000..166fb31 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-error/transcript.jsonl @@ -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}}} diff --git a/cmd/planctl/testdata/v2/context-info/env.txt b/cmd/planctl/testdata/v2/context-info/env.txt new file mode 100644 index 0000000..e7015f5 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-info/env.txt @@ -0,0 +1,3 @@ +CLAUDE_SESSION_ID=fixture-uuid +CLAUDE_PROJECT_DIR=/fixture/project +CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000 diff --git a/cmd/planctl/testdata/v2/context-info/expected.exit b/cmd/planctl/testdata/v2/context-info/expected.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-info/expected.exit @@ -0,0 +1 @@ +0 diff --git a/cmd/planctl/testdata/v2/context-info/expected.golden b/cmd/planctl/testdata/v2/context-info/expected.golden new file mode 100644 index 0000000..0e7f788 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-info/expected.golden @@ -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. diff --git a/cmd/planctl/testdata/v2/context-info/prd.md b/cmd/planctl/testdata/v2/context-info/prd.md new file mode 100644 index 0000000..864d61a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-info/prd.md @@ -0,0 +1,6 @@ +# PRD — context fixture + +## 4. Functional Requirements + +### R1. Greet +- R1.1 THE SYSTEM SHALL greet the user on startup. diff --git a/cmd/planctl/testdata/v2/context-info/tasks.md b/cmd/planctl/testdata/v2/context-info/tasks.md new file mode 100644 index 0000000..3746b46 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-info/tasks.md @@ -0,0 +1,3 @@ +# Tasks — context fixture + +- [ ] 1.0 Implement greet _Requirements: R1.1_ diff --git a/cmd/planctl/testdata/v2/context-info/transcript.jsonl b/cmd/planctl/testdata/v2/context-info/transcript.jsonl new file mode 100644 index 0000000..e173836 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-info/transcript.jsonl @@ -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}}} diff --git a/cmd/planctl/testdata/v2/context-json-warn/args.txt b/cmd/planctl/testdata/v2/context-json-warn/args.txt new file mode 100644 index 0000000..484e429 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-json-warn/args.txt @@ -0,0 +1 @@ +lint --format=json FIXTURE diff --git a/cmd/planctl/testdata/v2/context-json-warn/env.txt b/cmd/planctl/testdata/v2/context-json-warn/env.txt new file mode 100644 index 0000000..e7015f5 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-json-warn/env.txt @@ -0,0 +1,3 @@ +CLAUDE_SESSION_ID=fixture-uuid +CLAUDE_PROJECT_DIR=/fixture/project +CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000 diff --git a/cmd/planctl/testdata/v2/context-json-warn/expected.exit b/cmd/planctl/testdata/v2/context-json-warn/expected.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-json-warn/expected.exit @@ -0,0 +1 @@ +0 diff --git a/cmd/planctl/testdata/v2/context-json-warn/expected.golden b/cmd/planctl/testdata/v2/context-json-warn/expected.golden new file mode 100644 index 0000000..1abadf1 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-json-warn/expected.golden @@ -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."}} diff --git a/cmd/planctl/testdata/v2/context-json-warn/prd.md b/cmd/planctl/testdata/v2/context-json-warn/prd.md new file mode 100644 index 0000000..1a7cd58 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-json-warn/prd.md @@ -0,0 +1,6 @@ +# PRD — context-json-warn + +## 4. Functional Requirements + +### R1. Greet +- R1.1 THE SYSTEM SHALL greet the user on startup. diff --git a/cmd/planctl/testdata/v2/context-json-warn/tasks.md b/cmd/planctl/testdata/v2/context-json-warn/tasks.md new file mode 100644 index 0000000..784468e --- /dev/null +++ b/cmd/planctl/testdata/v2/context-json-warn/tasks.md @@ -0,0 +1,3 @@ +# Tasks — context-json-warn + +- [ ] 1.0 Implement greet _Requirements: R1.1_ diff --git a/cmd/planctl/testdata/v2/context-json-warn/transcript.jsonl b/cmd/planctl/testdata/v2/context-json-warn/transcript.jsonl new file mode 100644 index 0000000..262a37a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-json-warn/transcript.jsonl @@ -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}}} diff --git a/cmd/planctl/testdata/v2/context-multi-plan/args.txt b/cmd/planctl/testdata/v2/context-multi-plan/args.txt new file mode 100644 index 0000000..dfea599 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-multi-plan/args.txt @@ -0,0 +1 @@ +lint diff --git a/cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26170-alpha/prd.md b/cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26170-alpha/prd.md new file mode 100644 index 0000000..3ca5b9b --- /dev/null +++ b/cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26170-alpha/prd.md @@ -0,0 +1,6 @@ +# PRD — alpha + +## 4. Functional Requirements + +### R1. Greet +- R1.1 THE SYSTEM SHALL greet the user on startup. diff --git a/cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26170-alpha/tasks.md b/cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26170-alpha/tasks.md new file mode 100644 index 0000000..2bda188 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26170-alpha/tasks.md @@ -0,0 +1,3 @@ +# Tasks — alpha + +- [ ] 1.0 Implement greet _Requirements: R1.1_ diff --git a/cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26172-beta/prd.md b/cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26172-beta/prd.md new file mode 100644 index 0000000..9d413e3 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26172-beta/prd.md @@ -0,0 +1,6 @@ +# PRD — beta + +## 4. Functional Requirements + +### R1. Greet +- R1.1 THE SYSTEM SHALL greet the user on startup. diff --git a/cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26172-beta/tasks.md b/cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26172-beta/tasks.md new file mode 100644 index 0000000..8a480d1 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26172-beta/tasks.md @@ -0,0 +1,3 @@ +# Tasks — beta + +- [ ] 1.0 Implement greet _Requirements: R1.1_ diff --git a/cmd/planctl/testdata/v2/context-multi-plan/env.txt b/cmd/planctl/testdata/v2/context-multi-plan/env.txt new file mode 100644 index 0000000..e7015f5 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-multi-plan/env.txt @@ -0,0 +1,3 @@ +CLAUDE_SESSION_ID=fixture-uuid +CLAUDE_PROJECT_DIR=/fixture/project +CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000 diff --git a/cmd/planctl/testdata/v2/context-multi-plan/expected.exit b/cmd/planctl/testdata/v2/context-multi-plan/expected.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-multi-plan/expected.exit @@ -0,0 +1 @@ +0 diff --git a/cmd/planctl/testdata/v2/context-multi-plan/expected.golden b/cmd/planctl/testdata/v2/context-multi-plan/expected.golden new file mode 100644 index 0000000..3ef7b01 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-multi-plan/expected.golden @@ -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. diff --git a/cmd/planctl/testdata/v2/context-multi-plan/transcript.jsonl b/cmd/planctl/testdata/v2/context-multi-plan/transcript.jsonl new file mode 100644 index 0000000..e173836 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-multi-plan/transcript.jsonl @@ -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}}} diff --git a/cmd/planctl/testdata/v2/context-no-limit/env.txt b/cmd/planctl/testdata/v2/context-no-limit/env.txt new file mode 100644 index 0000000..904d9e4 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-no-limit/env.txt @@ -0,0 +1,2 @@ +CLAUDE_SESSION_ID=fixture-uuid +CLAUDE_PROJECT_DIR=/fixture/project diff --git a/cmd/planctl/testdata/v2/context-no-limit/expected.exit b/cmd/planctl/testdata/v2/context-no-limit/expected.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-no-limit/expected.exit @@ -0,0 +1 @@ +0 diff --git a/cmd/planctl/testdata/v2/context-no-limit/expected.golden b/cmd/planctl/testdata/v2/context-no-limit/expected.golden new file mode 100644 index 0000000..c13b071 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-no-limit/expected.golden @@ -0,0 +1,2 @@ +context-no-limit: clean (1 tasks, 1 requirements, 0 design sections) +context: 143 k tokens (limit unknown) diff --git a/cmd/planctl/testdata/v2/context-no-limit/prd.md b/cmd/planctl/testdata/v2/context-no-limit/prd.md new file mode 100644 index 0000000..864d61a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-no-limit/prd.md @@ -0,0 +1,6 @@ +# PRD — context fixture + +## 4. Functional Requirements + +### R1. Greet +- R1.1 THE SYSTEM SHALL greet the user on startup. diff --git a/cmd/planctl/testdata/v2/context-no-limit/tasks.md b/cmd/planctl/testdata/v2/context-no-limit/tasks.md new file mode 100644 index 0000000..3746b46 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-no-limit/tasks.md @@ -0,0 +1,3 @@ +# Tasks — context fixture + +- [ ] 1.0 Implement greet _Requirements: R1.1_ diff --git a/cmd/planctl/testdata/v2/context-no-limit/transcript.jsonl b/cmd/planctl/testdata/v2/context-no-limit/transcript.jsonl new file mode 100644 index 0000000..a98629d --- /dev/null +++ b/cmd/planctl/testdata/v2/context-no-limit/transcript.jsonl @@ -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}}} diff --git a/cmd/planctl/testdata/v2/context-no-session/expected.exit b/cmd/planctl/testdata/v2/context-no-session/expected.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-no-session/expected.exit @@ -0,0 +1 @@ +0 diff --git a/cmd/planctl/testdata/v2/context-no-session/expected.golden b/cmd/planctl/testdata/v2/context-no-session/expected.golden new file mode 100644 index 0000000..721b1d3 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-no-session/expected.golden @@ -0,0 +1 @@ +context-no-session: clean (1 tasks, 1 requirements, 0 design sections) diff --git a/cmd/planctl/testdata/v2/context-no-session/prd.md b/cmd/planctl/testdata/v2/context-no-session/prd.md new file mode 100644 index 0000000..864d61a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-no-session/prd.md @@ -0,0 +1,6 @@ +# PRD — context fixture + +## 4. Functional Requirements + +### R1. Greet +- R1.1 THE SYSTEM SHALL greet the user on startup. diff --git a/cmd/planctl/testdata/v2/context-no-session/tasks.md b/cmd/planctl/testdata/v2/context-no-session/tasks.md new file mode 100644 index 0000000..3746b46 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-no-session/tasks.md @@ -0,0 +1,3 @@ +# Tasks — context fixture + +- [ ] 1.0 Implement greet _Requirements: R1.1_ diff --git a/cmd/planctl/testdata/v2/context-normal/env.txt b/cmd/planctl/testdata/v2/context-normal/env.txt new file mode 100644 index 0000000..e7015f5 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-normal/env.txt @@ -0,0 +1,3 @@ +CLAUDE_SESSION_ID=fixture-uuid +CLAUDE_PROJECT_DIR=/fixture/project +CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000 diff --git a/cmd/planctl/testdata/v2/context-normal/expected.exit b/cmd/planctl/testdata/v2/context-normal/expected.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-normal/expected.exit @@ -0,0 +1 @@ +0 diff --git a/cmd/planctl/testdata/v2/context-normal/expected.golden b/cmd/planctl/testdata/v2/context-normal/expected.golden new file mode 100644 index 0000000..5fb6cc5 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-normal/expected.golden @@ -0,0 +1 @@ +context-normal: clean (1 tasks, 1 requirements, 0 design sections) diff --git a/cmd/planctl/testdata/v2/context-normal/prd.md b/cmd/planctl/testdata/v2/context-normal/prd.md new file mode 100644 index 0000000..864d61a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-normal/prd.md @@ -0,0 +1,6 @@ +# PRD — context fixture + +## 4. Functional Requirements + +### R1. Greet +- R1.1 THE SYSTEM SHALL greet the user on startup. diff --git a/cmd/planctl/testdata/v2/context-normal/tasks.md b/cmd/planctl/testdata/v2/context-normal/tasks.md new file mode 100644 index 0000000..3746b46 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-normal/tasks.md @@ -0,0 +1,3 @@ +# Tasks — context fixture + +- [ ] 1.0 Implement greet _Requirements: R1.1_ diff --git a/cmd/planctl/testdata/v2/context-normal/transcript.jsonl b/cmd/planctl/testdata/v2/context-normal/transcript.jsonl new file mode 100644 index 0000000..7c7e228 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-normal/transcript.jsonl @@ -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}}} diff --git a/cmd/planctl/testdata/v2/context-warn/env.txt b/cmd/planctl/testdata/v2/context-warn/env.txt new file mode 100644 index 0000000..e7015f5 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-warn/env.txt @@ -0,0 +1,3 @@ +CLAUDE_SESSION_ID=fixture-uuid +CLAUDE_PROJECT_DIR=/fixture/project +CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000 diff --git a/cmd/planctl/testdata/v2/context-warn/expected.exit b/cmd/planctl/testdata/v2/context-warn/expected.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-warn/expected.exit @@ -0,0 +1 @@ +0 diff --git a/cmd/planctl/testdata/v2/context-warn/expected.golden b/cmd/planctl/testdata/v2/context-warn/expected.golden new file mode 100644 index 0000000..dce96fc --- /dev/null +++ b/cmd/planctl/testdata/v2/context-warn/expected.golden @@ -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. diff --git a/cmd/planctl/testdata/v2/context-warn/prd.md b/cmd/planctl/testdata/v2/context-warn/prd.md new file mode 100644 index 0000000..864d61a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-warn/prd.md @@ -0,0 +1,6 @@ +# PRD — context fixture + +## 4. Functional Requirements + +### R1. Greet +- R1.1 THE SYSTEM SHALL greet the user on startup. diff --git a/cmd/planctl/testdata/v2/context-warn/tasks.md b/cmd/planctl/testdata/v2/context-warn/tasks.md new file mode 100644 index 0000000..3746b46 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-warn/tasks.md @@ -0,0 +1,3 @@ +# Tasks — context fixture + +- [ ] 1.0 Implement greet _Requirements: R1.1_ diff --git a/cmd/planctl/testdata/v2/context-warn/transcript.jsonl b/cmd/planctl/testdata/v2/context-warn/transcript.jsonl new file mode 100644 index 0000000..262a37a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-warn/transcript.jsonl @@ -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}}} diff --git a/dev/README.md b/dev/README.md index f73917c..e4d447d 100644 --- a/dev/README.md +++ b/dev/README.md @@ -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:)` 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: diff --git a/dev/plans/26174-planctl-context-tokens/codex-sessions.md b/dev/plans/26174-planctl-context-tokens/codex-sessions.md new file mode 100644 index 0000000..eb5e23e --- /dev/null +++ b/dev/plans/26174-planctl-context-tokens/codex-sessions.md @@ -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 diff --git a/dev/plans/26174-planctl-context-tokens/design.md b/dev/plans/26174-planctl-context-tokens/design.md index e98e65f..3b0a5ad 100644 --- a/dev/plans/26174-planctl-context-tokens/design.md +++ b/dev/plans/26174-planctl-context-tokens/design.md @@ -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/\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//.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//status` and `/proc//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//status (safe: line-based, not field-based). +func ppidOf(pid int) (int, error) { ... } + +// processStartTimeSec reads the start-time field from /proc//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/.jsonl` +Example: `/Users/sid/repos/template-jj` → slug `-Users-sid-repos-template-jj` +Full path: `~/.claude/projects/-Users-sid-repos-template-jj/.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): | 85–94 | `"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/` (where `` is the Claude Code process PID). +File: `/tmp/planctl-sessions/` (PPID = Claude Code process PID at hook time). ``` \n -\n +\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//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: 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): 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 / 70–84 / 85–94 / ≥ 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_ diff --git a/dev/plans/26174-planctl-context-tokens/prd.md b/dev/plans/26174-planctl-context-tokens/prd.md new file mode 100644 index 0000000..a80613b --- /dev/null +++ b/dev/plans/26174-planctl-context-tokens/prd.md @@ -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/` 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//.jsonl`, where `` 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: k / k tokens (%) — + ``` + + The `` 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: 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/` and SHALL contain two lines: `` on line 1 and the start time of the `$PPID` process on line 2 (as a Unix timestamp or the raw value from `/proc//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//.jsonl` — one JSON object per line. +- The relevant schema (verified from live transcripts, Claude Code v2.1.80): + ```json + { + "type": "assistant", + "sessionId": "", + "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//status` (Linux). +- Walk up to 8 hops; stop at PID 1 (init). +- Check `/tmp/planctl-sessions/` 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/` SHALL contain exactly two newline-terminated lines: +``` + + +``` +`` is the process start time as a decimal integer representing seconds since the Unix epoch (UTC). On Linux: `/proc//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-`) 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.)* diff --git a/dev/plans/26174-planctl-context-tokens/tasks.md b/dev/plans/26174-planctl-context-tokens/tasks.md index 3634941..0d85bcf 100644 --- a/dev/plans/26174-planctl-context-tokens/tasks.md +++ b/dev/plans/26174-planctl-context-tokens/tasks.md @@ -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//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//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/`; `session-end` removes that file; adds `planctl_start_ticks` helper. -- `scripts/test-jj-hooks.sh` — modified: start/end lifecycle assertion for `/tmp/planctl-sessions/`. -- `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/` where `` is `R`, `D§`, or `T`. Example: `// spec:26174-planctl-context-tokens/R2.1`. +- Source-code traceability tag format: `// spec:26174-planctl-context-tokens/`. 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.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_ - - [ ] 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//status`; `processStartTimeSec` reads starttime from `/proc//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.1–R1.6, R2.1–R2.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//status`; `processStartTimeSec` reads starttime from `/proc//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.1–R4.5, R5.1–R5.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.1–R4.3, R4.5, R5.1–R5.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.1–R4.5, R5.1–R5.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//.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.1–R5.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//.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/` 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//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/` 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_ diff --git a/go.mod b/go.mod index 4dbd585..75712a1 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 6a37955..f01aee6 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/scripts/jj-hook.sh b/scripts/jj-hook.sh index 9f22618..82e8b93 100755 --- a/scripts/jj-hook.sh +++ b/scripts/jj-hook.sh @@ -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//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/ + # 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) diff --git a/scripts/test-jj-hooks.sh b/scripts/test-jj-hooks.sh index 219f170..7dd406d 100755 --- a/scripts/test-jj-hooks.sh +++ b/scripts/test-jj-hooks.sh @@ -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/ 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