feat(planctl): context-window token awareness — core (parent 1.0)

- TokenCtx + Classify + formatTokenCount (R2.2, R3.1-3.5, R4.4)
- ReadTokenCtx orchestrator: env > PID walk > transcript parse (R1.1-1.6, R2.1-2.6)
- parseLastUsage: 10MB scanner, JSONL last-assistant-wins, zero-usage qualifies (R2.1-2.3)
- parsePIDFile: strict two-line layout; rejects partial/corrupted writes (R6.2-6.3)
- resolveViaPIDWalk: 8-hop ancestor walk, liveness + start-stamp check (R1.2, R6.1, R6.3)
- proc_darwin.go (x/sys/unix kinfo_proc) + proc_linux.go (/proc/stat field 22)
- parseStatStartTime: platform-agnostic, handles comm with parens/spaces

Parent 1.0 from dev/plans/26174-planctl-context-tokens/tasks.md
This commit is contained in:
sid 2026-04-23 17:07:24 -06:00
parent 54d32b10e1
commit 935b86b791
11 changed files with 924 additions and 13 deletions

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

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

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

@ -0,0 +1,420 @@
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+D§3.5
// Zero-usage lines still qualify per PRD R2.1 (presence, not non-zero).
func TestParseLastUsage_zeroUsageStillQualifies(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "t.jsonl")
content := `{"type":"assistant","message":{"usage":{"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
`
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
got, err := parseLastUsage(p)
if err != nil {
t.Fatalf("parseLastUsage: %v", err)
}
if got != 0 {
t.Errorf("got %d, want 0", got)
}
}
// spec:26174-planctl-context-tokens/R1.1+D§3.3
func TestResolveSessionUUID_envWins(t *testing.T) {
t.Setenv("CLAUDE_SESSION_ID", "env-uuid-12345")
got := resolveSessionUUID()
if got != "env-uuid-12345" {
t.Errorf("resolveSessionUUID = %q, want env-uuid-12345", got)
}
}
// spec:26174-planctl-context-tokens/R1.2+R6.1+R6.3+D§3.3
func TestResolveViaPIDWalk_pidWalkFinds(t *testing.T) {
dir := t.TempDir()
// Ancestor chain: 100 -> 200 (winner) -> 300 -> 1
parents := map[int]int{100: 200, 200: 300, 300: 1}
parentOf := func(pid int) int { return parents[pid] }
alive := func(pid int) bool { return pid == 200 }
stampOf := func(pid int) (int64, error) {
if pid == 200 {
return 1_700_000_000, nil
}
return 0, nil
}
p := filepath.Join(dir, "200")
if err := os.WriteFile(p, []byte("the-uuid\n1700000000\n"), 0o600); err != nil {
t.Fatal(err)
}
got := resolveViaPIDWalk(100, dir, parentOf, alive, stampOf)
if got != "the-uuid" {
t.Errorf("got %q, want the-uuid", got)
}
}
// spec:26174-planctl-context-tokens/R6.3+D§3.3
func TestResolveViaPIDWalk_staleSkipped(t *testing.T) {
dir := t.TempDir()
parents := map[int]int{100: 200, 200: 1}
parentOf := func(pid int) int { return parents[pid] }
// dead: returns false → skip
alive := func(_ int) bool { return false }
stampOf := func(_ int) (int64, error) { return 1_700_000_000, nil }
p := filepath.Join(dir, "200")
if err := os.WriteFile(p, []byte("the-uuid\n1700000000\n"), 0o600); err != nil {
t.Fatal(err)
}
got := resolveViaPIDWalk(100, dir, parentOf, alive, stampOf)
if got != "" {
t.Errorf("stale PID should not resolve, got %q", got)
}
}
// spec:26174-planctl-context-tokens/R6.3+D§3.3
func TestResolveViaPIDWalk_startTimeMismatch(t *testing.T) {
dir := t.TempDir()
parents := map[int]int{100: 200, 200: 1}
parentOf := func(pid int) int { return parents[pid] }
alive := func(_ int) bool { return true }
// file says 1700000000, actual says 9999999999 → mismatch → skip
stampOf := func(_ int) (int64, error) { return 9_999_999_999, nil }
p := filepath.Join(dir, "200")
if err := os.WriteFile(p, []byte("the-uuid\n1700000000\n"), 0o600); err != nil {
t.Fatal(err)
}
got := resolveViaPIDWalk(100, dir, parentOf, alive, stampOf)
if got != "" {
t.Errorf("start-time mismatch should not resolve, got %q", got)
}
}
// spec:26174-planctl-context-tokens/R1.4+D§3.3
func TestResolveViaPIDWalk_exhausted(t *testing.T) {
dir := t.TempDir()
// Deep chain with no files anywhere.
parentOf := func(pid int) int {
if pid <= 1 {
return 0
}
return pid - 1
}
alive := func(_ int) bool { return true }
stampOf := func(_ int) (int64, error) { return 0, nil }
got := resolveViaPIDWalk(100, dir, parentOf, alive, stampOf)
if got != "" {
t.Errorf("exhausted walk should return empty, got %q", got)
}
}
// spec:26174-planctl-context-tokens/R1.1+R1.5+R2.1+R2.2+R2.4+D§3.2
func TestReadTokenCtx_happyPath(t *testing.T) {
home := t.TempDir()
projectDir := "/path/to/proj"
slug := "-path-to-proj"
uuid := "abc-123"
dir := filepath.Join(home, ".claude", "projects", slug)
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
content := `{"type":"assistant","message":{"usage":{"input_tokens":10,"cache_creation_input_tokens":100,"cache_read_input_tokens":1000}}}
`
if err := os.WriteFile(filepath.Join(dir, uuid+".jsonl"), []byte(content), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("HOME", home)
t.Setenv("CLAUDE_PROJECT_DIR", projectDir)
t.Setenv("CLAUDE_SESSION_ID", uuid)
t.Setenv("CLAUDE_CODE_MAX_CONTEXT_TOKENS", "2000")
ctx := ReadTokenCtx()
if ctx == nil {
t.Fatal("ReadTokenCtx returned nil, want non-nil")
}
if ctx.Used != 1110 {
t.Errorf("Used = %d, want 1110", ctx.Used)
}
if ctx.Limit != 2000 {
t.Errorf("Limit = %d, want 2000", ctx.Limit)
}
}
// spec:26174-planctl-context-tokens/R1.4+D§3.2
func TestReadTokenCtx_noSession(t *testing.T) {
t.Setenv("CLAUDE_SESSION_ID", "")
// Re-route PID walk to an empty dir so ancestor probing finds nothing.
empty := t.TempDir()
origDir := pidSessionDir
pidSessionDir = empty
t.Cleanup(func() { pidSessionDir = origDir })
if ctx := ReadTokenCtx(); ctx != nil {
t.Errorf("expected nil for no session, got %+v", ctx)
}
}

View file

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

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

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

View file

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

View file

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

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

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

View file

@ -1,2 +1,4 @@
- 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

View file

@ -38,20 +38,20 @@ 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. 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_
- [ ] 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_
- [ ] 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_
- [ ] 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_
- [ ] 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_
- [ ] 1.7 Add `proc_darwin.go` (`//go:build darwin`) using `syscall.SysctlKinfoProc` for `ppidOf`/`processStartTimeSec`. Add `proc_linux.go` (`//go:build linux`): `ppidOf` reads PPid from `/proc/<pid>/status`; `processStartTimeSec` reads starttime from `/proc/<pid>/stat` via safe parsing — `strings.LastIndex` to find last `)`, then field 20 of remainder (raw clock ticks since boot; hook writes ticks too per §5.5/§4.1, so comparison units match). Add `proc_test.go` exercising all three against `os.Getpid()`/`os.Getppid()`. _Requirements: R1.2, R6.3_ _Design: D§3.3, D§7.3_
- [ ] 1.8 Add `resolveSessionUUID() string` per design §3.3 — env var first; then up-to-8-hop PID walk. Use unexported `var pidSessionDir = "/tmp/planctl-sessions"` for testability. Add `TestResolveSessionUUID_envWins` / `_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_
- [ ] 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_
- [x] 1.0 Implement `context.go`, platform helpers, and unit tests _Requirements: R1.1-R1.6, R2.1-R2.6, R3.1-R3.3, R3.5, R4.4, R6.1-R6.3_ _Design: D§1, D§3.1-3.9, D§6.1, D§7.1_
- [x] 1.1 Create `cmd/planctl/context.go` with `TokenCtx` struct and `formatTokenCount(n int64) string` per design §3.1/§3.8. Add `cmd/planctl/context_test.go` with `TestFormatTokenCount` covering n=0→`< 1 k`, n=500→`< 1 k`, n=999→`< 1 k`, n=1000→`1 k`, n=1499→`1 k`, n=1500→`2 k`. _Requirements: R2.2, R2.4, R4.4_ _Design: D§3.1, D§3.8, D§7.1_
- [x] 1.2 Add `(t *TokenCtx) Classify() (severity, msg string)` per design §3.7. Add `TestClassify_bands` (pct 0/69/70/84/85/94/95/100/nil) and `TestClassify_unknownLimit` (`Limit == 0``("", "")`). _Requirements: R3.1, R3.2, R3.3, R3.5_ _Design: D§3.7, D§7.1_
- [x] 1.3 Add `readLimit() int64` per design §3.6 — reads `CLAUDE_CODE_MAX_CONTEXT_TOKENS`, returns 0 on unset/invalid. Add `TestReadLimit_set` / `_unset` / `_invalid`. _Requirements: R2.4_ _Design: D§3.6, D§7.1_
- [x] 1.4 Add `transcriptPath(uuid, home, projectDir string) string` per design §3.4 — replace `/` with `-`, fall back to `os.Getwd()`. Add `TestTranscriptPath_slash`, `_nested`, `_fallbackCwd`. _Requirements: R1.5, R1.6_ _Design: D§3.4, D§7.1_
- [x] 1.5 Add `parsePIDFile(path string) (uuid string, startStamp int64, err error)` per design §3.9 — two lines, second parses as int64. Add `TestParsePIDFile_valid`, `_missingLine2`, `_malformedInt`. _Requirements: R6.2, R6.3_ _Design: D§3.9, D§4.1, D§7.1_
- [x] 1.6 Add `parseLastUsage(path string) (int64, error)` per design §3.5 — `bufio.Scanner` 10 MB buffer, forward scan, last `type=assistant` with present `message.usage` wins (presence per PRD R2.1, not non-zero). Skip decode errors. Return `errNoUsageLine` when none. Add `TestParseLastUsage_valid` / `_malformedMid` / `_noAssistant` / `_emptyFile` / `_zeroUsageStillQualifies`. _Requirements: R2.1, R2.2, R2.3, R2.6_ _Design: D§3.5, D§4.2, D§7.1_
- [x] 1.7 Add `proc_darwin.go` (`//go:build darwin`) using `golang.org/x/sys/unix.SysctlKinfoProc` for `ppidOf`/`processStartTimeSec` _(stdlib `syscall.KinfoProc` no longer exposed on modern Go — narrow design §7 divergence, noted inline)_. Add `proc_linux.go` (`//go:build linux`): `ppidOf` reads PPid from `/proc/<pid>/status`; `processStartTimeSec` reads starttime from `/proc/<pid>/stat` via safe parsing — `strings.LastIndex` to find last `)`, then field 20 of remainder (raw clock ticks since boot; hook writes ticks too per §5.5/§4.1, so comparison units match). Add `proc_test.go` exercising all three against `os.Getpid()`/`os.Getppid()`. _Requirements: R1.2, R6.3_ _Design: D§3.3, D§7.3_
- [x] 1.8 Add `resolveSessionUUID() string` per design §3.3 — env var first; then up-to-8-hop PID walk via injectable-helper inner function `resolveViaPIDWalk`. Use unexported `var pidSessionDir = "/tmp/planctl-sessions"` for testability. Add `TestResolveSessionUUID_envWins` + `TestResolveViaPIDWalk_pidWalkFinds` / `_staleSkipped` / `_startTimeMismatch` / `_exhausted`. _Requirements: R1.1, R1.2, R1.3, R1.4, R6.1, R6.3_ _Design: D§3.3, D§6.2, D§6.3, D§7.1_
- [x] 1.9 Wire `ReadTokenCtx() *TokenCtx` per design §3.2 — compose resolveSessionUUID → transcriptPath → parseLastUsage → readLimit; nil at every failure. Add `TestReadTokenCtx_happyPath` and `_noSession`. No subprocesses invoked (D§6.4). _Requirements: R1.1-R1.6, R2.1-R2.6_ _Design: D§3.2, D§6.4, D§7.1_
- [ ] 2.0 Wire token context into planctl output (`main.go`, `emit.go`, E2E golden tests) _Requirements: R3.4, R4.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`. _Requirements: R5.1, R5.2, R5.3_ _Design: D§5.4_

2
go.mod
View file

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

2
go.sum
View file

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