From 935b86b79110b47f7636abb70ac7b91e1b7295d5 Mon Sep 17 00:00:00 2001 From: sid Date: Thu, 23 Apr 2026 17:07:24 -0600 Subject: [PATCH] =?UTF-8?q?feat(planctl):=20context-window=20token=20aware?= =?UTF-8?q?ness=20=E2=80=94=20core=20(parent=201.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- cmd/planctl/context.go | 240 ++++++++++ cmd/planctl/context_test.go | 420 ++++++++++++++++++ cmd/planctl/proc_darwin.go | 44 ++ cmd/planctl/proc_linux.go | 61 +++ cmd/planctl/proc_stat_parse.go | 34 ++ cmd/planctl/proc_stat_parse_test.go | 54 +++ cmd/planctl/proc_test.go | 52 +++ .../codex-sessions.md | 2 + .../26174-planctl-context-tokens/tasks.md | 26 +- go.mod | 2 + go.sum | 2 + 11 files changed, 924 insertions(+), 13 deletions(-) create mode 100644 cmd/planctl/context.go create mode 100644 cmd/planctl/context_test.go create mode 100644 cmd/planctl/proc_darwin.go create mode 100644 cmd/planctl/proc_linux.go create mode 100644 cmd/planctl/proc_stat_parse.go create mode 100644 cmd/planctl/proc_stat_parse_test.go create mode 100644 cmd/planctl/proc_test.go 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..41f8cb0 --- /dev/null +++ b/cmd/planctl/context_test.go @@ -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) + } +} 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/dev/plans/26174-planctl-context-tokens/codex-sessions.md b/dev/plans/26174-planctl-context-tokens/codex-sessions.md index c727aef..a6f5b5d 100644 --- a/dev/plans/26174-planctl-context-tokens/codex-sessions.md +++ b/dev/plans/26174-planctl-context-tokens/codex-sessions.md @@ -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 diff --git a/dev/plans/26174-planctl-context-tokens/tasks.md b/dev/plans/26174-planctl-context-tokens/tasks.md index 306fc77..50fc446 100644 --- a/dev/plans/26174-planctl-context-tokens/tasks.md +++ b/dev/plans/26174-planctl-context-tokens/tasks.md @@ -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//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_ - - [ ] 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//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`. _Requirements: R5.1, R5.2, R5.3_ _Design: D§5.4_ 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=