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:
parent
54d32b10e1
commit
935b86b791
11 changed files with 924 additions and 13 deletions
240
cmd/planctl/context.go
Normal file
240
cmd/planctl/context.go
Normal 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
420
cmd/planctl/context_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
44
cmd/planctl/proc_darwin.go
Normal file
44
cmd/planctl/proc_darwin.go
Normal 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
61
cmd/planctl/proc_linux.go
Normal 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))
|
||||
}
|
||||
34
cmd/planctl/proc_stat_parse.go
Normal file
34
cmd/planctl/proc_stat_parse.go
Normal 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
|
||||
}
|
||||
54
cmd/planctl/proc_stat_parse_test.go
Normal file
54
cmd/planctl/proc_stat_parse_test.go
Normal 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
52
cmd/planctl/proc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
2
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
|
||||
|
|
|
|||
2
go.sum
2
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=
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue