merge: integrate planctl v2 task-cmds + context-window token awareness
Some checks failed
planctl / build-test (1.22, macos-latest) (push) Has been cancelled
planctl / build-test (1.22, ubuntu-latest) (push) Has been cancelled
planctl / build-test (1.22, windows-latest) (push) Has been cancelled
planctl / build-test (stable, macos-latest) (push) Has been cancelled
planctl / build-test (stable, ubuntu-latest) (push) Has been cancelled
planctl / build-test (stable, windows-latest) (push) Has been cancelled
planctl / bench (push) Has been cancelled
Some checks failed
planctl / build-test (1.22, macos-latest) (push) Has been cancelled
planctl / build-test (1.22, ubuntu-latest) (push) Has been cancelled
planctl / build-test (1.22, windows-latest) (push) Has been cancelled
planctl / build-test (stable, macos-latest) (push) Has been cancelled
planctl / build-test (stable, ubuntu-latest) (push) Has been cancelled
planctl / build-test (stable, windows-latest) (push) Has been cancelled
planctl / bench (push) Has been cancelled
This commit is contained in:
commit
e913ce1880
73 changed files with 2222 additions and 249 deletions
|
|
@ -85,9 +85,12 @@ jj git push # pushes to origin
|
|||
|
||||
When multiple agents are active on the same repository simultaneously (the default when using this template's `jj-commitd` session-commit daemon):
|
||||
|
||||
- **Each session commits its own tracked files.** See `INTEGRATION.md` for the per-session-bookmark and base-revision-tracking mechanics.
|
||||
- **Cross-session file conflicts are logged by the daemon** — check `/tmp/jj-commitd-<repo_id>.log` if edits seem to disappear.
|
||||
- **Don't assume the working copy is yours.** Before writing, `jj status` and confirm `@` is the change you expect. If a pre-flight step (like "is this a plans commit or a feature branch?") is relevant to your skill, run it every invocation — don't rely on session state.
|
||||
- **Use `jj workspace add` for true parallel isolation.** Each workspace gets its own `@` pointer; a branch switch in one session never flips the working tree underneath another. Setup: `jj workspace add --name <role> ../<project>-<role>`. See `dev/README.md` → "Primary isolation: jj workspaces".
|
||||
- **Each session commits its own tracked files** under a per-session commit message (`wip(claude:<session_id>): <files>`) and bookmark (`wip/claude-<session_id>` set at session end). History stays attributable even when agents interleave.
|
||||
- **Use the session inventory on start.** The daemon's `session-start` response lists every other active session and its tracked files. Log it and check for overlap with files your session plans to edit — if another session holds them, coordinate with the user before writing. See `docs/jj-commitd.md` → "Session inventory consumption".
|
||||
- **`JJ_AGENT_FEATURE` labels your work (planned).** Exporting `JJ_AGENT_FEATURE=<feature-name>` before starting will label commits as `wip(<feature>:<session_id>)` and set a `feat/<feature>` bookmark at session end — human-readable names instead of bare UUIDs. Not yet implemented; see `dev/README.md` → "Planned: JJ_AGENT_FEATURE".
|
||||
- **Cross-session file conflicts are logged.** Grep `/tmp/jj-commitd-<repo_id>.log` for `conflict` if edits seem to disappear.
|
||||
- **Don't assume the working copy is yours.** Before writing, `jj status` and confirm `@` is the change you expect. If a pre-flight step is relevant to your skill, run it every invocation — don't rely on session state.
|
||||
|
||||
## Placeholder tokens (for template users)
|
||||
|
||||
|
|
|
|||
118
INTEGRATION.md
118
INTEGRATION.md
|
|
@ -322,3 +322,121 @@ The `.claude/skills/` pack is readable documentation even for tools that don't n
|
|||
- **Cursor / other IDEs** typically read `AGENTS.md` or `.cursorrules`; point those at `AGENTS.md`.
|
||||
|
||||
If you use multiple agents, per-tool session logs (`codex-sessions.md`, `copilot-sessions.md`, `gemini-sessions.md`, `kiro-sessions.md`) keep the review lineage unambiguous for each feature.
|
||||
|
||||
---
|
||||
|
||||
## Context-Window Token Awareness (planctl)
|
||||
|
||||
Claude Code agents using `planctl` can receive threshold-based recommendations about their own context-window fill, so they can commit and start a fresh session *before* running out of tokens mid-task. The signal surfaces in every `planctl lint` invocation when session data is available.
|
||||
|
||||
### What the output looks like
|
||||
|
||||
When context fill reaches 70%, 85%, or 95% of the limit, `planctl` appends a `context:` line after the plan summary:
|
||||
|
||||
```text
|
||||
26174-planctl-context-tokens: clean (12 tasks, 6 requirements, 0 design sections)
|
||||
context: 142 k / 200 k tokens (71%) — plan to wrap up this session soon.
|
||||
```
|
||||
|
||||
Band behavior:
|
||||
|
||||
| Fill | Band | Severity | Suffix |
|
||||
|---|---|---|---|
|
||||
| < 70% | Normal | — | silent (no `context:` line) |
|
||||
| 70–84% | Info | `info` | `plan to wrap up this session soon.` |
|
||||
| 85–94% | Warn | `warn` | `commit current work and start a new session after this task.` |
|
||||
| ≥ 95% | Error | `error` | `stop new work; commit and close out immediately.` |
|
||||
|
||||
Token counts are expressed in thousands rounded to the nearest integer (`143 k`, `200 k`); counts below 1000 display as `< 1 k`.
|
||||
|
||||
### JSON output
|
||||
|
||||
With `--format=json`, the summary object gains a peer `context_window` key:
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": { "plans": 1, "errors": 0, "warnings": 0 },
|
||||
"context_window": {
|
||||
"tokens_used": 142000,
|
||||
"tokens_limit": 200000,
|
||||
"pct": 71,
|
||||
"severity": "info",
|
||||
"recommendation": "plan to wrap up this session soon."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In multi-plan output (e.g. a repo-root `planctl lint` that walks `dev/plans/**`), the `context:` line appears **once** after the aggregate `N plans linted, …` summary — context is session-level, not plan-level.
|
||||
|
||||
### When context is omitted
|
||||
|
||||
- **No session data**: no env vars, no PID-keyed session file found → output is identical to the baseline (graceful degradation).
|
||||
- **Normal band (fill < 70%)**: no `context:` line emitted, to keep healthy sessions quiet.
|
||||
- **Unknown limit**: when `CLAUDE_CODE_MAX_CONTEXT_TOKENS` is unset, `planctl` emits a raw count (`context: 143 k tokens (limit unknown)`) and, in JSON, only the `tokens_used` field inside `context_window`.
|
||||
|
||||
`context_window` **never shifts** the summary's `errors` / `warnings` counts — it's advisory output, not a lint diagnostic.
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Source | Purpose |
|
||||
|---|---|---|
|
||||
| `CLAUDE_SESSION_ID` | Hook (see below) or Claude Code | Primary session UUID |
|
||||
| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` | Claude Code | Context-window limit (`200000` for Opus 4.7) |
|
||||
| `CLAUDE_PROJECT_DIR` | Claude Code | Project slug derivation for transcript lookup |
|
||||
|
||||
If `CLAUDE_CODE_MAX_CONTEXT_TOKENS` is not set, `planctl` emits the raw count without a percentage or recommendation. **Hooks are optional**: with no hook and no env var, `planctl` degrades silently — same output as any other CLI tool.
|
||||
|
||||
### Hook integration (optional but recommended)
|
||||
|
||||
`scripts/jj-hook.sh` already ships the integration. If you copied the template, you have it. The hook adds two behaviors:
|
||||
|
||||
**`session-start` case** (template ships this — excerpt):
|
||||
|
||||
```bash
|
||||
FULL_SID=$(printf '%s\n' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null)
|
||||
if [[ "$FULL_SID" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then
|
||||
# Primary: propagate session ID to all subprocesses via env
|
||||
[ -n "${CLAUDE_ENV_FILE:-}" ] && \
|
||||
echo "export CLAUDE_SESSION_ID=${FULL_SID}" >> "$CLAUDE_ENV_FILE"
|
||||
|
||||
# Secondary: PID-keyed fallback for processes that lost env
|
||||
START_TICKS=$(planctl_start_ticks "$PPID")
|
||||
mkdir -p /tmp/planctl-sessions
|
||||
TMPF=$(mktemp /tmp/planctl-sessions/.tmp.XXXXXX)
|
||||
printf '%s\n%s\n' "$FULL_SID" "$START_TICKS" > "$TMPF"
|
||||
mv -f "$TMPF" "/tmp/planctl-sessions/${PPID}"
|
||||
fi
|
||||
```
|
||||
|
||||
**`session-end` case** cleans up the PID-keyed file:
|
||||
|
||||
```bash
|
||||
rm -f "/tmp/planctl-sessions/${PPID}"
|
||||
```
|
||||
|
||||
**`planctl_start_ticks` helper** — platform-specific process-start-time readback used by the PID-reuse defense in `cmd/planctl/context.go`:
|
||||
|
||||
```bash
|
||||
planctl_start_ticks() {
|
||||
local pid="$1"
|
||||
if [ -f "/proc/$pid/stat" ]; then
|
||||
# Linux: field 22 of /proc/<pid>/stat is starttime in clock ticks since boot.
|
||||
# `comm` can contain ')' and spaces — strip up to and including the LAST ')'.
|
||||
local tail
|
||||
tail=$(sed 's/.*)//' "/proc/$pid/stat" 2>/dev/null) || { echo 0; return; }
|
||||
echo "$tail" | awk '{print $20}' | grep -E '^[0-9]+$' || echo 0
|
||||
else
|
||||
# macOS: ps lstart → epoch seconds.
|
||||
ps -o lstart= -p "$pid" 2>/dev/null | xargs -I{} date -j -f '%a %b %d %T %Y' '{}' '+%s' 2>/dev/null || echo 0
|
||||
fi
|
||||
}
|
||||
```
|
||||
|
||||
The Linux branch writes *clock ticks since boot* and the Go resolver (`cmd/planctl/proc_linux.go`) reads the same unit from `/proc/<pid>/stat` — units match within a platform. macOS uses epoch seconds on both sides via `kinfo_proc.kp_proc.p_starttime`. No cross-platform comparison is ever made.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
- **`planctl` output never shows a `context:` line**: confirm you're running inside a Claude Code session. `echo $CLAUDE_SESSION_ID` should print a UUID. If not, check that the `SessionStart` hook fires and your shell sources `$CLAUDE_ENV_FILE`.
|
||||
- **Wrong percentage**: `CLAUDE_CODE_MAX_CONTEXT_TOKENS` may be mis-set. Without it, you'll see `(limit unknown)` instead of a percentage.
|
||||
- **Multiple concurrent sessions**: each agent's `planctl` resolves its own session via ancestor-PID walk, backed by `/tmp/planctl-sessions/<PPID>` files. Liveness + start-stamp checks prevent cross-session contamination even across PID reuse.
|
||||
- **No hook installed**: `planctl` works without the hook — if neither `CLAUDE_SESSION_ID` is set nor a PID-keyed file is found during the 8-hop ancestor walk, the context line is silently omitted.
|
||||
|
|
|
|||
240
cmd/planctl/context.go
Normal file
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."
|
||||
}
|
||||
}
|
||||
478
cmd/planctl/context_test.go
Normal file
478
cmd/planctl/context_test.go
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// spec:26174-planctl-context-tokens/R4.4+D§3.8
|
||||
func TestFormatTokenCount(t *testing.T) {
|
||||
cases := []struct {
|
||||
in int64
|
||||
want string
|
||||
}{
|
||||
{0, "< 1 k"},
|
||||
{500, "< 1 k"},
|
||||
{999, "< 1 k"},
|
||||
{1000, "1 k"},
|
||||
{1499, "1 k"},
|
||||
{1500, "2 k"},
|
||||
{143000, "143 k"},
|
||||
{1000000, "1000 k"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := formatTokenCount(c.in); got != c.want {
|
||||
t.Errorf("formatTokenCount(%d) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R3.1+R3.2+R3.5+D§3.7
|
||||
func TestClassify_bands(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ctx *TokenCtx
|
||||
wantSev string
|
||||
wantMsg string
|
||||
}{
|
||||
{"nil ctx", nil, "", ""},
|
||||
{"pct 0", &TokenCtx{Used: 0, Limit: 200000}, "", ""},
|
||||
{"pct 69", &TokenCtx{Used: 138000, Limit: 200000}, "", ""},
|
||||
{"pct 70 info lower", &TokenCtx{Used: 140000, Limit: 200000}, "info", "plan to wrap up this session soon."},
|
||||
{"pct 84 info upper", &TokenCtx{Used: 168000, Limit: 200000}, "info", "plan to wrap up this session soon."},
|
||||
{"pct 85 warn lower", &TokenCtx{Used: 170000, Limit: 200000}, "warn", "commit current work and start a new session after this task."},
|
||||
{"pct 94 warn upper", &TokenCtx{Used: 188000, Limit: 200000}, "warn", "commit current work and start a new session after this task."},
|
||||
{"pct 95 error lower", &TokenCtx{Used: 190000, Limit: 200000}, "error", "stop new work; commit and close out immediately."},
|
||||
{"pct 100 error", &TokenCtx{Used: 200000, Limit: 200000}, "error", "stop new work; commit and close out immediately."},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
sev, msg := c.ctx.Classify()
|
||||
if sev != c.wantSev || msg != c.wantMsg {
|
||||
t.Errorf("Classify() = (%q, %q), want (%q, %q)", sev, msg, c.wantSev, c.wantMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R3.3+D§3.7
|
||||
func TestClassify_unknownLimit(t *testing.T) {
|
||||
ctx := &TokenCtx{Used: 143000, Limit: 0}
|
||||
sev, msg := ctx.Classify()
|
||||
if sev != "" || msg != "" {
|
||||
t.Errorf("Classify() with Limit=0 = (%q, %q), want (\"\", \"\")", sev, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R2.4+D§3.6
|
||||
func TestReadLimit_set(t *testing.T) {
|
||||
t.Setenv("CLAUDE_CODE_MAX_CONTEXT_TOKENS", "200000")
|
||||
if got := readLimit(); got != 200000 {
|
||||
t.Errorf("readLimit() = %d, want 200000", got)
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R2.4+D§3.6
|
||||
func TestReadLimit_unset(t *testing.T) {
|
||||
t.Setenv("CLAUDE_CODE_MAX_CONTEXT_TOKENS", "")
|
||||
if got := readLimit(); got != 0 {
|
||||
t.Errorf("readLimit() unset = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R2.4+D§3.6
|
||||
func TestReadLimit_invalid(t *testing.T) {
|
||||
t.Setenv("CLAUDE_CODE_MAX_CONTEXT_TOKENS", "not-a-number")
|
||||
if got := readLimit(); got != 0 {
|
||||
t.Errorf("readLimit() invalid = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R1.5+D§3.4
|
||||
func TestTranscriptPath_slash(t *testing.T) {
|
||||
got := transcriptPath("abc", "/Users/x", "/Users/x/repos/foo")
|
||||
want := "/Users/x/.claude/projects/-Users-x-repos-foo/abc.jsonl"
|
||||
if got != want {
|
||||
t.Errorf("transcriptPath = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R1.5+D§3.4
|
||||
func TestTranscriptPath_nested(t *testing.T) {
|
||||
got := transcriptPath("uuid", "/home/u", "/a/b/c/d")
|
||||
want := "/home/u/.claude/projects/-a-b-c-d/uuid.jsonl"
|
||||
if got != want {
|
||||
t.Errorf("transcriptPath nested = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R1.6+D§3.4
|
||||
func TestTranscriptPath_fallbackCwd(t *testing.T) {
|
||||
got := transcriptPath("uuid", "/h", "")
|
||||
// fallback uses os.Getwd; just assert shape contains home and the uuid.jsonl suffix
|
||||
if !strings.HasPrefix(got, "/h/.claude/projects/") {
|
||||
t.Errorf("fallback path prefix wrong: %q", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "/uuid.jsonl") {
|
||||
t.Errorf("fallback path suffix wrong: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R6.2+D§3.9
|
||||
func TestParsePIDFile_valid(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "1234")
|
||||
if err := os.WriteFile(p, []byte("817fec78-6ff1-4fb2-a17c-5708ea5df968\n1700000000\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uuid, stamp, err := parsePIDFile(p)
|
||||
if err != nil {
|
||||
t.Fatalf("parsePIDFile: %v", err)
|
||||
}
|
||||
if uuid != "817fec78-6ff1-4fb2-a17c-5708ea5df968" {
|
||||
t.Errorf("uuid = %q", uuid)
|
||||
}
|
||||
if stamp != 1700000000 {
|
||||
t.Errorf("stamp = %d, want 1700000000", stamp)
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R6.3+D§3.9
|
||||
func TestParsePIDFile_missingLine2(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "1234")
|
||||
if err := os.WriteFile(p, []byte("817fec78-6ff1-4fb2-a17c-5708ea5df968\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := parsePIDFile(p); err == nil {
|
||||
t.Fatal("expected error for missing line 2")
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R6.3+D§3.9
|
||||
func TestParsePIDFile_malformedInt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "1234")
|
||||
if err := os.WriteFile(p, []byte("817fec78-6ff1-4fb2-a17c-5708ea5df968\nnot-a-number\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := parsePIDFile(p); err == nil {
|
||||
t.Fatal("expected error for malformed int")
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R6.3+D§3.9+D§4.1
|
||||
func TestParsePIDFile_trailingContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "1234")
|
||||
if err := os.WriteFile(p, []byte("uuid-abc\n1700000000\nunexpected\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := parsePIDFile(p); err == nil {
|
||||
t.Fatal("expected error for trailing content")
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R6.3+D§3.9+D§4.1
|
||||
// Blank third line also disallowed — design §4.1 specifies exactly two newline-terminated lines.
|
||||
func TestParsePIDFile_blankThirdLine(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "1234")
|
||||
if err := os.WriteFile(p, []byte("uuid-abc\n1700000000\n\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := parsePIDFile(p); err == nil {
|
||||
t.Fatal("expected error for blank third line")
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R6.3+D§3.9+D§4.1
|
||||
// Missing final newline indicates a partial/truncated write.
|
||||
func TestParsePIDFile_missingFinalNewline(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "1234")
|
||||
if err := os.WriteFile(p, []byte("uuid-abc\n1700000000"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := parsePIDFile(p); err == nil {
|
||||
t.Fatal("expected error for missing final newline")
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R2.1+R2.2+D§3.5
|
||||
func TestParseLastUsage_valid(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "t.jsonl")
|
||||
content := `{"type":"user","message":{"content":"hi"}}
|
||||
{"type":"assistant","message":{"usage":{"input_tokens":10,"cache_creation_input_tokens":100,"cache_read_input_tokens":1000}}}
|
||||
{"type":"assistant","message":{"usage":{"input_tokens":20,"cache_creation_input_tokens":200,"cache_read_input_tokens":2000}}}
|
||||
`
|
||||
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := parseLastUsage(p)
|
||||
if err != nil {
|
||||
t.Fatalf("parseLastUsage: %v", err)
|
||||
}
|
||||
// last line wins: 20 + 200 + 2000 = 2220
|
||||
if got != 2220 {
|
||||
t.Errorf("got %d, want 2220", got)
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R2.3+D§3.5
|
||||
func TestParseLastUsage_malformedMid(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "t.jsonl")
|
||||
content := `{"type":"assistant","message":{"usage":{"input_tokens":1}}}
|
||||
{not valid json at all
|
||||
{"type":"assistant","message":{"usage":{"input_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
|
||||
`
|
||||
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := parseLastUsage(p)
|
||||
if err != nil {
|
||||
t.Fatalf("parseLastUsage: %v", err)
|
||||
}
|
||||
if got != 5 {
|
||||
t.Errorf("got %d, want 5 (last good line after malformed skip)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R2.3+D§3.5
|
||||
func TestParseLastUsage_noAssistant(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "t.jsonl")
|
||||
content := `{"type":"user","message":{"content":"hi"}}
|
||||
{"type":"system","message":{"content":"boot"}}
|
||||
`
|
||||
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := parseLastUsage(p); err == nil {
|
||||
t.Fatal("expected errNoUsageLine")
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R2.3+D§3.5
|
||||
func TestParseLastUsage_emptyFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "t.jsonl")
|
||||
if err := os.WriteFile(p, []byte(""), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := parseLastUsage(p); err == nil {
|
||||
t.Fatal("expected errNoUsageLine for empty file")
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R2.1+R2.6+D§3.5+D§7.1
|
||||
// parseLastUsage must handle oversized-but-valid JSONL lines — design §3.5
|
||||
// sizes the scanner buffer at 10 MB to accommodate large messages. Here we
|
||||
// stuff the assistant line with a 5 MB inert string field (ignored by the
|
||||
// transcriptUsage struct) to confirm the scanner doesn't truncate.
|
||||
func TestParseLastUsage_largeLine(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "big.jsonl")
|
||||
f, err := os.Create(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pad := strings.Repeat("x", 5*1024*1024)
|
||||
if _, err := f.WriteString(`{"type":"user","message":{"content":"hi"}}` + "\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Single 5 MB assistant line with a harmless "noise" field plus the
|
||||
// usage object we do care about.
|
||||
line := `{"type":"assistant","noise":"` + pad + `","message":{"usage":{"input_tokens":11,"cache_creation_input_tokens":22,"cache_read_input_tokens":33}}}` + "\n"
|
||||
if _, err := f.WriteString(line); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := parseLastUsage(p)
|
||||
if err != nil {
|
||||
t.Fatalf("parseLastUsage on 5 MB line: %v", err)
|
||||
}
|
||||
if got != 66 {
|
||||
t.Errorf("got %d, want 66 (11+22+33)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R2.3+R2.6+D§3.5
|
||||
// A line exceeding the scanner's 10 MB cap must error out cleanly (no panic)
|
||||
// — the design specifically sizes the buffer at 10 MB; lines larger than
|
||||
// that should surface a scanner error via parseLastUsage's sc.Err() check.
|
||||
func TestParseLastUsage_oversizedLineErrors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "huge.jsonl")
|
||||
f, err := os.Create(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pad := strings.Repeat("x", 11*1024*1024) // 11 MB > 10 MB buffer
|
||||
line := `{"type":"assistant","noise":"` + pad + `","message":{"usage":{"input_tokens":1}}}` + "\n"
|
||||
if _, err := f.WriteString(line); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := parseLastUsage(p); err == nil {
|
||||
t.Fatal("expected error on oversized line; got none")
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R2.1+D§3.5
|
||||
// Zero-usage lines still qualify per PRD R2.1 (presence, not non-zero).
|
||||
func TestParseLastUsage_zeroUsageStillQualifies(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "t.jsonl")
|
||||
content := `{"type":"assistant","message":{"usage":{"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
|
||||
`
|
||||
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := parseLastUsage(p)
|
||||
if err != nil {
|
||||
t.Fatalf("parseLastUsage: %v", err)
|
||||
}
|
||||
if got != 0 {
|
||||
t.Errorf("got %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R1.1+D§3.3
|
||||
func TestResolveSessionUUID_envWins(t *testing.T) {
|
||||
t.Setenv("CLAUDE_SESSION_ID", "env-uuid-12345")
|
||||
got := resolveSessionUUID()
|
||||
if got != "env-uuid-12345" {
|
||||
t.Errorf("resolveSessionUUID = %q, want env-uuid-12345", got)
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R1.2+R6.1+R6.3+D§3.3
|
||||
func TestResolveViaPIDWalk_pidWalkFinds(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Ancestor chain: 100 -> 200 (winner) -> 300 -> 1
|
||||
parents := map[int]int{100: 200, 200: 300, 300: 1}
|
||||
parentOf := func(pid int) int { return parents[pid] }
|
||||
alive := func(pid int) bool { return pid == 200 }
|
||||
stampOf := func(pid int) (int64, error) {
|
||||
if pid == 200 {
|
||||
return 1_700_000_000, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
p := filepath.Join(dir, "200")
|
||||
if err := os.WriteFile(p, []byte("the-uuid\n1700000000\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := resolveViaPIDWalk(100, dir, parentOf, alive, stampOf)
|
||||
if got != "the-uuid" {
|
||||
t.Errorf("got %q, want the-uuid", got)
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R6.3+D§3.3
|
||||
func TestResolveViaPIDWalk_staleSkipped(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
parents := map[int]int{100: 200, 200: 1}
|
||||
parentOf := func(pid int) int { return parents[pid] }
|
||||
// dead: returns false → skip
|
||||
alive := func(_ int) bool { return false }
|
||||
stampOf := func(_ int) (int64, error) { return 1_700_000_000, nil }
|
||||
p := filepath.Join(dir, "200")
|
||||
if err := os.WriteFile(p, []byte("the-uuid\n1700000000\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := resolveViaPIDWalk(100, dir, parentOf, alive, stampOf)
|
||||
if got != "" {
|
||||
t.Errorf("stale PID should not resolve, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R6.3+D§3.3
|
||||
func TestResolveViaPIDWalk_startTimeMismatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
parents := map[int]int{100: 200, 200: 1}
|
||||
parentOf := func(pid int) int { return parents[pid] }
|
||||
alive := func(_ int) bool { return true }
|
||||
// file says 1700000000, actual says 9999999999 → mismatch → skip
|
||||
stampOf := func(_ int) (int64, error) { return 9_999_999_999, nil }
|
||||
p := filepath.Join(dir, "200")
|
||||
if err := os.WriteFile(p, []byte("the-uuid\n1700000000\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := resolveViaPIDWalk(100, dir, parentOf, alive, stampOf)
|
||||
if got != "" {
|
||||
t.Errorf("start-time mismatch should not resolve, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R1.4+D§3.3
|
||||
func TestResolveViaPIDWalk_exhausted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Deep chain with no files anywhere.
|
||||
parentOf := func(pid int) int {
|
||||
if pid <= 1 {
|
||||
return 0
|
||||
}
|
||||
return pid - 1
|
||||
}
|
||||
alive := func(_ int) bool { return true }
|
||||
stampOf := func(_ int) (int64, error) { return 0, nil }
|
||||
got := resolveViaPIDWalk(100, dir, parentOf, alive, stampOf)
|
||||
if got != "" {
|
||||
t.Errorf("exhausted walk should return empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R1.1+R1.5+R2.1+R2.2+R2.4+D§3.2
|
||||
func TestReadTokenCtx_happyPath(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
projectDir := "/path/to/proj"
|
||||
slug := "-path-to-proj"
|
||||
uuid := "abc-123"
|
||||
dir := filepath.Join(home, ".claude", "projects", slug)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := `{"type":"assistant","message":{"usage":{"input_tokens":10,"cache_creation_input_tokens":100,"cache_read_input_tokens":1000}}}
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, uuid+".jsonl"), []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("CLAUDE_PROJECT_DIR", projectDir)
|
||||
t.Setenv("CLAUDE_SESSION_ID", uuid)
|
||||
t.Setenv("CLAUDE_CODE_MAX_CONTEXT_TOKENS", "2000")
|
||||
|
||||
ctx := ReadTokenCtx()
|
||||
if ctx == nil {
|
||||
t.Fatal("ReadTokenCtx returned nil, want non-nil")
|
||||
}
|
||||
if ctx.Used != 1110 {
|
||||
t.Errorf("Used = %d, want 1110", ctx.Used)
|
||||
}
|
||||
if ctx.Limit != 2000 {
|
||||
t.Errorf("Limit = %d, want 2000", ctx.Limit)
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R1.4+D§3.2
|
||||
func TestReadTokenCtx_noSession(t *testing.T) {
|
||||
t.Setenv("CLAUDE_SESSION_ID", "")
|
||||
// Re-route PID walk to an empty dir so ancestor probing finds nothing.
|
||||
empty := t.TempDir()
|
||||
origDir := pidSessionDir
|
||||
pidSessionDir = empty
|
||||
t.Cleanup(func() { pidSessionDir = origDir })
|
||||
if ctx := ReadTokenCtx(); ctx != nil {
|
||||
t.Errorf("expected nil for no session, got %+v", ctx)
|
||||
}
|
||||
}
|
||||
|
|
@ -40,7 +40,11 @@ type PlanResult struct {
|
|||
// Directory-level diagnostics (Path == "", typically missing-prd /
|
||||
// missing-closeout-file) render with the plan-dir basename substituted
|
||||
// for the path.
|
||||
func emitText(w io.Writer, plans []PlanResult, strict bool) int {
|
||||
// spec:26174-planctl-context-tokens/R3.4+R3.5+R4.1+R4.2+R4.3+R4.5+D§4.3
|
||||
// ctx (when non-nil) drives the context-window line: emitted after the plan
|
||||
// summary in single-plan mode, after the aggregate summary in multi-plan mode
|
||||
// (R4.3 — once, not per-plan). Normal-band / nil ctx emits nothing (R3.5).
|
||||
func emitText(w io.Writer, plans []PlanResult, strict bool, ctx *TokenCtx) int {
|
||||
multi := len(plans) > 1
|
||||
for i, p := range plans {
|
||||
if multi {
|
||||
|
|
@ -54,9 +58,33 @@ func emitText(w io.Writer, plans []PlanResult, strict bool) int {
|
|||
if multi {
|
||||
writeAggregateText(w, plans)
|
||||
}
|
||||
writeContextText(w, ctx)
|
||||
return computeExit(plans, strict)
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R3.3+R3.4+R3.5+R4.1+R4.2+D§4.3
|
||||
// writeContextText appends the canonical `context:` line for Info/Warn/Error
|
||||
// bands, or the unknown-limit form when Limit == 0. Silent for nil ctx or
|
||||
// Normal band. Format matches PRD R4.1 exactly.
|
||||
func writeContextText(w io.Writer, ctx *TokenCtx) {
|
||||
if ctx == nil {
|
||||
return
|
||||
}
|
||||
if ctx.Limit == 0 {
|
||||
// Unknown-limit branch is emitted regardless of threshold (R4.2).
|
||||
fmt.Fprintf(w, "context: %s tokens (limit unknown)\n", formatTokenCount(ctx.Used))
|
||||
return
|
||||
}
|
||||
sev, msg := ctx.Classify()
|
||||
if sev == "" {
|
||||
// Normal band: silent (R3.5).
|
||||
return
|
||||
}
|
||||
pct := ctx.Used * 100 / ctx.Limit
|
||||
fmt.Fprintf(w, "context: %s / %s tokens (%d%%) — %s\n",
|
||||
formatTokenCount(ctx.Used), formatTokenCount(ctx.Limit), pct, msg)
|
||||
}
|
||||
|
||||
// writePlanText emits the body of a single plan: either the R5.3 clean-
|
||||
// summary line (zero diagnostics) or the R5.2 diagnostic lines. Shared
|
||||
// by single-plan and multi-plan branches of emitText.
|
||||
|
|
@ -94,7 +122,12 @@ func writeAggregateText(w io.Writer, plans []PlanResult) {
|
|||
// diagnostics), but still contribute to the aggregate summary — so
|
||||
// a `planctl lint` run over N clean plans emits exactly one object
|
||||
// (the summary).
|
||||
func emitJSON(w io.Writer, plans []PlanResult, strict bool) int {
|
||||
// spec:26174-planctl-context-tokens/R5.1+R5.2+R5.3+R5.4+D§4.4
|
||||
// ctx (when non-nil) contributes a context_window field to the final summary
|
||||
// object. Normal band / nil ctx → no context_window key. Unknown limit (R5.2)
|
||||
// → only tokens_used key set. Context never affects the errors/warnings counts
|
||||
// in summary (R5.4).
|
||||
func emitJSON(w io.Writer, plans []PlanResult, strict bool, ctx *TokenCtx) int {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
for _, p := range plans {
|
||||
|
|
@ -110,14 +143,44 @@ func emitJSON(w io.Writer, plans []PlanResult, strict bool) int {
|
|||
}
|
||||
}
|
||||
errs, warns := countSeverities(plans)
|
||||
_ = enc.Encode(jsonSummary{Summary: summaryBody{
|
||||
Plans: len(plans),
|
||||
Errors: errs,
|
||||
Warnings: warns,
|
||||
}})
|
||||
_ = enc.Encode(jsonSummary{
|
||||
Summary: summaryBody{
|
||||
Plans: len(plans),
|
||||
Errors: errs,
|
||||
Warnings: warns,
|
||||
},
|
||||
ContextWin: buildJSONCtxWin(ctx),
|
||||
})
|
||||
return computeExit(plans, strict)
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R5.1+R5.2+R5.3+D§4.4
|
||||
// buildJSONCtxWin returns a populated jsonCtxWin or nil. nil means "don't emit
|
||||
// the key at all" (Normal band / no session data). R5.2 unknown-limit case
|
||||
// leaves TokensLimit/Pct/Severity/Recommendation zero-valued so omitempty
|
||||
// drops them on serialization.
|
||||
func buildJSONCtxWin(ctx *TokenCtx) *jsonCtxWin {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
out := &jsonCtxWin{TokensUsed: ctx.Used}
|
||||
if ctx.Limit == 0 {
|
||||
return out
|
||||
}
|
||||
sev, msg := ctx.Classify()
|
||||
if sev == "" {
|
||||
// Normal band — omit the entire key (R5.3).
|
||||
return nil
|
||||
}
|
||||
pct := int(ctx.Used * 100 / ctx.Limit)
|
||||
limit := ctx.Limit
|
||||
out.TokensLimit = &limit
|
||||
out.Pct = &pct
|
||||
out.Severity = sev
|
||||
out.Recommendation = msg
|
||||
return out
|
||||
}
|
||||
|
||||
// jsonDiag is the JSONL payload for one diagnostic. Field tags pin the
|
||||
// exact key order produced by encoding/json and keep the contract
|
||||
// grep-stable for downstream consumers.
|
||||
|
|
@ -140,8 +203,25 @@ type summaryBody struct {
|
|||
|
||||
// jsonSummary wraps summaryBody under the "summary" key so the final
|
||||
// JSONL line is distinguishable from the per-diagnostic objects.
|
||||
//
|
||||
// spec:26174-planctl-context-tokens/R5.1+R5.2+R5.3+D§5.4
|
||||
// ContextWin is omitempty so Normal-band / nil-ctx invocations serialize
|
||||
// without a context_window key.
|
||||
type jsonSummary struct {
|
||||
Summary summaryBody `json:"summary"`
|
||||
Summary summaryBody `json:"summary"`
|
||||
ContextWin *jsonCtxWin `json:"context_window,omitempty"`
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R5.1+R5.2+D§5.4
|
||||
// jsonCtxWin is the context_window payload in JSON mode. Pointer fields for
|
||||
// TokensLimit and Pct are omitempty: when the limit is unknown per R5.2, they
|
||||
// marshal out entirely (rather than as 0).
|
||||
type jsonCtxWin struct {
|
||||
TokensUsed int64 `json:"tokens_used"`
|
||||
TokensLimit *int64 `json:"tokens_limit,omitempty"`
|
||||
Pct *int `json:"pct,omitempty"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
Recommendation string `json:"recommendation,omitempty"`
|
||||
}
|
||||
|
||||
// severityLabel renders a Severity as its canonical string form. Used
|
||||
|
|
@ -171,14 +251,12 @@ func countSeverities(plans []PlanResult) (errs, warns int) {
|
|||
|
||||
// ─── v2 emit functions ────────────────────────────────────────────────────────
|
||||
|
||||
// planRelFile returns the plan-dir-relative file path used in user-facing output.
|
||||
// planDir is the basename of the plan directory; file is the filename within it
|
||||
// (typically "tasks.md"). Result is "planDir/file", matching R1.3 examples.
|
||||
// planRelFile returns the cwd-relative file path used in user-facing output.
|
||||
// planDir is the cwd-relative plan directory (e.g. "dev/plans/26174-foo" or ".").
|
||||
// filepath.Join normalises "." so single-plan invocations emit "tasks.md" not
|
||||
// "./tasks.md", while nested plans emit "dev/plans/<slug>/tasks.md" per R1.3.
|
||||
func planRelFile(planDir, file string) string {
|
||||
if planDir == "" {
|
||||
return file
|
||||
}
|
||||
return planDir + "/" + file
|
||||
return filepath.Join(planDir, file)
|
||||
}
|
||||
|
||||
// spec:planctl-task-cmds/R1.3+R1.4+D§4+D§7
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ func TestEmitText_SinglePlanClean(t *testing.T) {
|
|||
TaskCount: 45, ReqCount: 32, DesCount: 9,
|
||||
}}
|
||||
var buf bytes.Buffer
|
||||
exit := emitText(&buf, plans, false)
|
||||
exit := emitText(&buf, plans, false, nil)
|
||||
if exit != 0 {
|
||||
t.Errorf("want exit 0 (clean), got %d", exit)
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ func TestEmitText_SinglePlanDirty(t *testing.T) {
|
|||
},
|
||||
}}
|
||||
var buf bytes.Buffer
|
||||
exit := emitText(&buf, plans, false)
|
||||
exit := emitText(&buf, plans, false, nil)
|
||||
if exit != 1 {
|
||||
t.Errorf("want exit 1 (error present), got %d", exit)
|
||||
}
|
||||
|
|
@ -82,7 +82,7 @@ func TestEmitText_MultiPlan(t *testing.T) {
|
|||
},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
exit := emitText(&buf, plans, false)
|
||||
exit := emitText(&buf, plans, false, nil)
|
||||
if exit != 1 {
|
||||
t.Errorf("want exit 1 (error in 3rd plan), got %d", exit)
|
||||
}
|
||||
|
|
@ -114,10 +114,10 @@ func TestEmitText_StrictPromotesWarnings(t *testing.T) {
|
|||
{Path: "prd.md", Line: 1, Severity: SevWarning, Code: CodeEARSViolation, Message: "w"},
|
||||
},
|
||||
}}
|
||||
if got := emitText(new(bytes.Buffer), plans, false); got != 0 {
|
||||
if got := emitText(new(bytes.Buffer), plans, false, nil); got != 0 {
|
||||
t.Errorf("non-strict warning-only: want exit 0, got %d", got)
|
||||
}
|
||||
if got := emitText(new(bytes.Buffer), plans, true); got != 1 {
|
||||
if got := emitText(new(bytes.Buffer), plans, true, nil); got != 1 {
|
||||
t.Errorf("strict warning-only: want exit 1, got %d", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -141,7 +141,7 @@ func TestEmitJSON_ValidJSONL(t *testing.T) {
|
|||
},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
exit := emitJSON(&buf, plans, false)
|
||||
exit := emitJSON(&buf, plans, false, nil)
|
||||
if exit != 1 {
|
||||
t.Errorf("want exit 1, got %d", exit)
|
||||
}
|
||||
|
|
@ -183,7 +183,7 @@ func TestEmitJSON_CleanPlans(t *testing.T) {
|
|||
{PlanDir: "b"},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
exit := emitJSON(&buf, plans, false)
|
||||
exit := emitJSON(&buf, plans, false, nil)
|
||||
if exit != 0 {
|
||||
t.Errorf("want exit 0, got %d", exit)
|
||||
}
|
||||
|
|
@ -236,10 +236,10 @@ func TestEmit_ExitCodes(t *testing.T) {
|
|||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := emitText(new(bytes.Buffer), tc.plans, tc.strict); got != tc.want {
|
||||
if got := emitText(new(bytes.Buffer), tc.plans, tc.strict, nil); got != tc.want {
|
||||
t.Errorf("emitText: want %d, got %d", tc.want, got)
|
||||
}
|
||||
if got := emitJSON(new(bytes.Buffer), tc.plans, tc.strict); got != tc.want {
|
||||
if got := emitJSON(new(bytes.Buffer), tc.plans, tc.strict, nil); got != tc.want {
|
||||
t.Errorf("emitJSON: want %d, got %d", tc.want, got)
|
||||
}
|
||||
})
|
||||
|
|
@ -778,3 +778,163 @@ func TestEmitStatus_MultiPlan_JSON(t *testing.T) {
|
|||
t.Errorf("want done=1, in_progress=1; got %v", sc)
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R3.4+R3.5+R4.1+D§4.3
|
||||
func TestEmitText_ContextInfo(t *testing.T) {
|
||||
plans := []PlanResult{{PlanDir: "p", TaskCount: 1}}
|
||||
ctx := &TokenCtx{Used: 142000, Limit: 200000} // 71% → info
|
||||
var buf bytes.Buffer
|
||||
emitText(&buf, plans, false, ctx)
|
||||
want := "context: 142 k / 200 k tokens (71%) — plan to wrap up this session soon.\n"
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Errorf("missing context line, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R4.1+D§4.3
|
||||
func TestEmitText_ContextWarn(t *testing.T) {
|
||||
plans := []PlanResult{{PlanDir: "p", TaskCount: 1}}
|
||||
ctx := &TokenCtx{Used: 174000, Limit: 200000} // 87%
|
||||
var buf bytes.Buffer
|
||||
emitText(&buf, plans, false, ctx)
|
||||
want := "context: 174 k / 200 k tokens (87%) — commit current work and start a new session after this task.\n"
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Errorf("missing warn context line, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R4.1+D§4.3
|
||||
func TestEmitText_ContextError(t *testing.T) {
|
||||
plans := []PlanResult{{PlanDir: "p", TaskCount: 1}}
|
||||
ctx := &TokenCtx{Used: 192000, Limit: 200000} // 96%
|
||||
var buf bytes.Buffer
|
||||
emitText(&buf, plans, false, ctx)
|
||||
want := "context: 192 k / 200 k tokens (96%) — stop new work; commit and close out immediately.\n"
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Errorf("missing error context line, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R3.5+D§4.3
|
||||
func TestEmitText_ContextNormalSilent(t *testing.T) {
|
||||
plans := []PlanResult{{PlanDir: "p", TaskCount: 1}}
|
||||
ctx := &TokenCtx{Used: 50000, Limit: 200000} // 25% → Normal band, silent
|
||||
var buf bytes.Buffer
|
||||
emitText(&buf, plans, false, ctx)
|
||||
if strings.Contains(buf.String(), "context:") {
|
||||
t.Errorf("Normal band should emit no context line, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R3.3+R4.2+D§4.3
|
||||
func TestEmitText_ContextUnknownLimit(t *testing.T) {
|
||||
plans := []PlanResult{{PlanDir: "p", TaskCount: 1}}
|
||||
ctx := &TokenCtx{Used: 143000, Limit: 0}
|
||||
var buf bytes.Buffer
|
||||
emitText(&buf, plans, false, ctx)
|
||||
want := "context: 143 k tokens (limit unknown)\n"
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Errorf("missing unknown-limit context line, got %q", buf.String())
|
||||
}
|
||||
if strings.Contains(buf.String(), "—") {
|
||||
t.Errorf("unknown-limit should emit no recommendation suffix, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R4.3+D§4.3
|
||||
func TestEmitText_ContextMultiPlanOnce(t *testing.T) {
|
||||
plans := []PlanResult{
|
||||
{PlanDir: "a", TaskCount: 1},
|
||||
{PlanDir: "b", TaskCount: 1},
|
||||
}
|
||||
ctx := &TokenCtx{Used: 142000, Limit: 200000}
|
||||
var buf bytes.Buffer
|
||||
emitText(&buf, plans, false, ctx)
|
||||
out := buf.String()
|
||||
n := strings.Count(out, "context:")
|
||||
if n != 1 {
|
||||
t.Errorf("multi-plan should emit context line exactly once, got %d\n%s", n, out)
|
||||
}
|
||||
// Must come AFTER the aggregate summary line ("2 plans linted, ...").
|
||||
agg := strings.Index(out, "2 plans linted")
|
||||
ctxIdx := strings.Index(out, "context:")
|
||||
if agg < 0 || ctxIdx < 0 || ctxIdx < agg {
|
||||
t.Errorf("context line must follow aggregate summary; agg=%d ctx=%d\n%s", agg, ctxIdx, out)
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R5.1+R5.4+D§4.4
|
||||
func TestEmitJSON_ContextWindowKnown(t *testing.T) {
|
||||
plans := []PlanResult{{PlanDir: "p", TaskCount: 1}}
|
||||
ctx := &TokenCtx{Used: 142000, Limit: 200000}
|
||||
var buf bytes.Buffer
|
||||
emitJSON(&buf, plans, false, ctx)
|
||||
// Last line is the summary object.
|
||||
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
||||
last := lines[len(lines)-1]
|
||||
var obj struct {
|
||||
Summary map[string]int `json:"summary"`
|
||||
ContextWindow struct {
|
||||
TokensUsed int64 `json:"tokens_used"`
|
||||
TokensLimit int64 `json:"tokens_limit"`
|
||||
Pct int `json:"pct"`
|
||||
Severity string `json:"severity"`
|
||||
Recommendation string `json:"recommendation"`
|
||||
} `json:"context_window"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(last), &obj); err != nil {
|
||||
t.Fatalf("unmarshal: %v\n%s", err, last)
|
||||
}
|
||||
if obj.Summary["errors"] != 0 || obj.Summary["warnings"] != 0 {
|
||||
t.Errorf("context should not affect error/warning counts: %+v", obj.Summary)
|
||||
}
|
||||
if obj.ContextWindow.TokensUsed != 142000 || obj.ContextWindow.TokensLimit != 200000 {
|
||||
t.Errorf("tokens = %+v", obj.ContextWindow)
|
||||
}
|
||||
if obj.ContextWindow.Pct != 71 || obj.ContextWindow.Severity != "info" {
|
||||
t.Errorf("pct/severity wrong: %+v", obj.ContextWindow)
|
||||
}
|
||||
if obj.ContextWindow.Recommendation == "" {
|
||||
t.Error("recommendation empty")
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R5.2+D§4.4
|
||||
func TestEmitJSON_ContextWindowUnknownLimit(t *testing.T) {
|
||||
plans := []PlanResult{{PlanDir: "p", TaskCount: 1}}
|
||||
ctx := &TokenCtx{Used: 143000, Limit: 0}
|
||||
var buf bytes.Buffer
|
||||
emitJSON(&buf, plans, false, ctx)
|
||||
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
||||
last := lines[len(lines)-1]
|
||||
// Should contain tokens_used but NOT tokens_limit/pct/severity/recommendation.
|
||||
if !strings.Contains(last, `"tokens_used":143000`) {
|
||||
t.Errorf("missing tokens_used: %s", last)
|
||||
}
|
||||
for _, omitted := range []string{"tokens_limit", "pct", "severity", "recommendation"} {
|
||||
if strings.Contains(last, omitted) {
|
||||
t.Errorf("unknown-limit should omit %q: %s", omitted, last)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R5.3+D§4.4
|
||||
func TestEmitJSON_ContextWindowNormalOmitted(t *testing.T) {
|
||||
plans := []PlanResult{{PlanDir: "p", TaskCount: 1}}
|
||||
ctx := &TokenCtx{Used: 50000, Limit: 200000} // Normal
|
||||
var buf bytes.Buffer
|
||||
emitJSON(&buf, plans, false, ctx)
|
||||
if strings.Contains(buf.String(), "context_window") {
|
||||
t.Errorf("Normal band should omit context_window: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R5.3+D§4.4
|
||||
func TestEmitJSON_ContextWindowNilOmitted(t *testing.T) {
|
||||
plans := []PlanResult{{PlanDir: "p", TaskCount: 1}}
|
||||
var buf bytes.Buffer
|
||||
emitJSON(&buf, plans, false, nil)
|
||||
if strings.Contains(buf.String(), "context_window") {
|
||||
t.Errorf("nil ctx should omit context_window: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,9 +54,16 @@ func run(args []string, stdout, stderr io.Writer) int {
|
|||
subcommand := args[0]
|
||||
rest := args[1:]
|
||||
|
||||
// spec:26174-planctl-context-tokens/R3.4+D§1+D§2
|
||||
// Resolve session token context once per invocation, at the top of run()
|
||||
// so it's available to every subcommand handler (current and future) and
|
||||
// so it can't be skipped by an early subcommand failure. ReadTokenCtx
|
||||
// returns nil when no session data is available; all emitters handle nil.
|
||||
ctx := ReadTokenCtx()
|
||||
|
||||
switch subcommand {
|
||||
case "lint":
|
||||
return runLint(rest, stdout, stderr)
|
||||
return runLint(rest, stdout, stderr, ctx)
|
||||
case "next":
|
||||
// spec:planctl-task-cmds/R5.5+D§1
|
||||
return runNext(rest, stdout, stderr)
|
||||
|
|
@ -85,7 +92,7 @@ func run(args []string, stdout, stderr io.Writer) int {
|
|||
//
|
||||
// v1 scope per PRD R6.2: strictly read-only — no writes to any plan
|
||||
// file.
|
||||
func runLint(args []string, stdout, stderr io.Writer) int {
|
||||
func runLint(args []string, stdout, stderr io.Writer, ctx *TokenCtx) int {
|
||||
flags, err := parseLintFlags(args)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err.Error())
|
||||
|
|
@ -111,9 +118,9 @@ func runLint(args []string, stdout, stderr io.Writer) int {
|
|||
results = append(results, lintPlan(p, flags))
|
||||
}
|
||||
if flags.format == "json" {
|
||||
return emitJSON(stdout, results, flags.strict)
|
||||
return emitJSON(stdout, results, flags.strict, ctx)
|
||||
}
|
||||
return emitText(stdout, results, flags.strict)
|
||||
return emitText(stdout, results, flags.strict, ctx)
|
||||
}
|
||||
|
||||
// spec:planctl/R5.3+D§3.5
|
||||
|
|
@ -552,6 +559,7 @@ Exit codes:
|
|||
return 2
|
||||
}
|
||||
for _, dir := range plans {
|
||||
relDir := filepath.Base(dir)
|
||||
p, err := loadPlan(dir)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err.Error())
|
||||
|
|
@ -562,7 +570,7 @@ Exit codes:
|
|||
return 2
|
||||
}
|
||||
idx := BuildIndex(p)
|
||||
records := buildTaskRecords(idx, p.Tasks, dir)
|
||||
records := buildTaskRecords(idx, p.Tasks, relDir)
|
||||
for i := range records {
|
||||
if !records[i].Checked {
|
||||
return emitNext(stdout, &records[i], format)
|
||||
|
|
@ -631,6 +639,7 @@ Exit codes:
|
|||
}
|
||||
var results []listPlanResult
|
||||
for _, dir := range plans {
|
||||
relDir := filepath.Base(dir)
|
||||
p, err := loadPlan(dir)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err.Error())
|
||||
|
|
@ -641,7 +650,7 @@ Exit codes:
|
|||
return 2
|
||||
}
|
||||
idx := BuildIndex(p)
|
||||
records := buildTaskRecords(idx, p.Tasks, dir)
|
||||
records := buildTaskRecords(idx, p.Tasks, relDir)
|
||||
results = append(results, listPlanResult{PlanDir: dir, Records: records})
|
||||
}
|
||||
return emitList(stdout, results, format, showAll)
|
||||
|
|
@ -729,7 +738,7 @@ Exit codes:
|
|||
fmt.Fprintf(stderr, "planctl: %s: tasks.md is missing\n", filepath.Base(plans[0]))
|
||||
return 2
|
||||
}
|
||||
records := buildTaskRecordsFromScan(p.Tasks, plans[0])
|
||||
records := buildTaskRecordsFromScan(p.Tasks, filepath.Base(plans[0]))
|
||||
rec, result, avail := findTaskByID(records, taskRef)
|
||||
switch result {
|
||||
case findBadFormat:
|
||||
|
|
@ -842,6 +851,7 @@ Exit codes:
|
|||
}
|
||||
var results []statusPlanResult
|
||||
for _, dir := range plans {
|
||||
relDir := filepath.Base(dir)
|
||||
p, err := loadPlan(dir)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err.Error())
|
||||
|
|
@ -854,7 +864,7 @@ Exit codes:
|
|||
return p.Tasks
|
||||
}
|
||||
return &ScanResult{}
|
||||
}(), dir)
|
||||
}(), relDir)
|
||||
var errs, warns int
|
||||
for _, d := range pr.Diagnostics {
|
||||
if d.Code == CodeMissingCloseoutFile {
|
||||
|
|
@ -931,3 +941,12 @@ func copyPlanDir(planDir string) (string, error) {
|
|||
}
|
||||
return tmp, nil
|
||||
}
|
||||
|
||||
// relOrBase returns the path of absDir relative to cwd. Falls back to
|
||||
// filepath.Base(absDir) if Rel fails (e.g. different drive on Windows).
|
||||
func relOrBase(cwd, absDir string) string {
|
||||
if rel, err := filepath.Rel(cwd, absDir); err == nil {
|
||||
return rel
|
||||
}
|
||||
return filepath.Base(absDir)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -442,6 +442,8 @@ func runGoldenFixture(t *testing.T, fixture string) {
|
|||
t.Fatalf("abs %s: %v", fixture, err)
|
||||
}
|
||||
args, cwd := loadFixtureInvocation(t, fixture)
|
||||
// spec:26174-planctl-context-tokens/R3.4+R4.1+D§7.2
|
||||
setupFixtureContextEnv(t, fixture)
|
||||
var stdout, stderr bytes.Buffer
|
||||
restore := chdir(t, cwd)
|
||||
defer restore()
|
||||
|
|
@ -480,6 +482,62 @@ func runGoldenFixture(t *testing.T, fixture string) {
|
|||
}
|
||||
}
|
||||
|
||||
// spec:26174-planctl-context-tokens/R3.4+R4.1+D§7.2
|
||||
// setupFixtureContextEnv wires per-fixture environment for the context-window
|
||||
// feature:
|
||||
//
|
||||
// - env.txt — one KEY=VALUE line per env var; trimmed, `#` comments skipped.
|
||||
// Each key is pinned via t.Setenv (auto-restored on test end).
|
||||
// - transcript.jsonl — staged under a temp HOME at
|
||||
// ~/.claude/projects/<slug>/<uuid>.jsonl where slug is derived from
|
||||
// CLAUDE_PROJECT_DIR (or fixture dir) with `/` → `-` and uuid is
|
||||
// CLAUDE_SESSION_ID. When transcript.jsonl is present but the required
|
||||
// env keys aren't, the staging step is skipped silently so non-context
|
||||
// fixtures remain unaffected.
|
||||
//
|
||||
// Fixtures without env.txt or transcript.jsonl are untouched — baseline fixtures
|
||||
// stay byte-identical.
|
||||
func setupFixtureContextEnv(t *testing.T, fixture string) {
|
||||
t.Helper()
|
||||
envPath := filepath.Join(fixture, "env.txt")
|
||||
if b, err := os.ReadFile(envPath); err == nil {
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
t.Setenv(strings.TrimSpace(k), strings.TrimSpace(v))
|
||||
}
|
||||
}
|
||||
tp := filepath.Join(fixture, "transcript.jsonl")
|
||||
tb, err := os.ReadFile(tp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Need a uuid + a slug to derive the transcript path. If either is missing,
|
||||
// skip staging — the fixture under test probably doesn't need it to pass.
|
||||
uuid := os.Getenv("CLAUDE_SESSION_ID")
|
||||
projectDir := os.Getenv("CLAUDE_PROJECT_DIR")
|
||||
if uuid == "" || projectDir == "" {
|
||||
return
|
||||
}
|
||||
// Stage a temp HOME and drop the transcript at the exact path planctl will look up.
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
slug := strings.ReplaceAll(projectDir, "/", "-")
|
||||
dir := filepath.Join(home, ".claude", "projects", slug)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir transcript dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, uuid+".jsonl"), tb, 0o600); err != nil {
|
||||
t.Fatalf("stage transcript: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// loadFixtureInvocation reads args.txt and cwd-rel.txt if present. The
|
||||
// args.txt tokens may reference the literal `FIXTURE` substring, which
|
||||
// is substituted with the absolute path of the fixture directory so
|
||||
|
|
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -85,7 +85,7 @@ func parseTaskID(text string) string {
|
|||
// not be nil; the result is in the same order as idx.TaskLines.
|
||||
func buildTaskRecords(idx Index, scan *ScanResult, planDir string) []TaskRecord {
|
||||
out := make([]TaskRecord, 0, len(idx.TaskLines))
|
||||
dirBase := filepath.Base(planDir)
|
||||
dirBase := planDir
|
||||
for _, tl := range idx.TaskLines {
|
||||
rawLine := ""
|
||||
if tl.Line >= 1 && tl.Line <= len(scan.Lines) {
|
||||
|
|
|
|||
3
cmd/planctl/testdata/v2/context-error/env.txt
vendored
Normal file
3
cmd/planctl/testdata/v2/context-error/env.txt
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
CLAUDE_SESSION_ID=fixture-uuid
|
||||
CLAUDE_PROJECT_DIR=/fixture/project
|
||||
CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000
|
||||
1
cmd/planctl/testdata/v2/context-error/expected.exit
vendored
Normal file
1
cmd/planctl/testdata/v2/context-error/expected.exit
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
0
|
||||
2
cmd/planctl/testdata/v2/context-error/expected.golden
vendored
Normal file
2
cmd/planctl/testdata/v2/context-error/expected.golden
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
context-error: clean (1 tasks, 1 requirements, 0 design sections)
|
||||
context: 192 k / 200 k tokens (96%) — stop new work; commit and close out immediately.
|
||||
6
cmd/planctl/testdata/v2/context-error/prd.md
vendored
Normal file
6
cmd/planctl/testdata/v2/context-error/prd.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# PRD — context fixture
|
||||
|
||||
## 4. Functional Requirements
|
||||
|
||||
### R1. Greet
|
||||
- R1.1 THE SYSTEM SHALL greet the user on startup.
|
||||
3
cmd/planctl/testdata/v2/context-error/tasks.md
vendored
Normal file
3
cmd/planctl/testdata/v2/context-error/tasks.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Tasks — context fixture
|
||||
|
||||
- [ ] 1.0 Implement greet _Requirements: R1.1_
|
||||
2
cmd/planctl/testdata/v2/context-error/transcript.jsonl
vendored
Normal file
2
cmd/planctl/testdata/v2/context-error/transcript.jsonl
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
{"type":"user","message":{"content":"hi"}}
|
||||
{"type":"assistant","sessionId":"fixture-uuid","message":{"usage":{"input_tokens":19200,"cache_creation_input_tokens":38400,"cache_read_input_tokens":134400,"output_tokens":100}}}
|
||||
3
cmd/planctl/testdata/v2/context-info/env.txt
vendored
Normal file
3
cmd/planctl/testdata/v2/context-info/env.txt
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
CLAUDE_SESSION_ID=fixture-uuid
|
||||
CLAUDE_PROJECT_DIR=/fixture/project
|
||||
CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000
|
||||
1
cmd/planctl/testdata/v2/context-info/expected.exit
vendored
Normal file
1
cmd/planctl/testdata/v2/context-info/expected.exit
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
0
|
||||
2
cmd/planctl/testdata/v2/context-info/expected.golden
vendored
Normal file
2
cmd/planctl/testdata/v2/context-info/expected.golden
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
context-info: clean (1 tasks, 1 requirements, 0 design sections)
|
||||
context: 142 k / 200 k tokens (71%) — plan to wrap up this session soon.
|
||||
6
cmd/planctl/testdata/v2/context-info/prd.md
vendored
Normal file
6
cmd/planctl/testdata/v2/context-info/prd.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# PRD — context fixture
|
||||
|
||||
## 4. Functional Requirements
|
||||
|
||||
### R1. Greet
|
||||
- R1.1 THE SYSTEM SHALL greet the user on startup.
|
||||
3
cmd/planctl/testdata/v2/context-info/tasks.md
vendored
Normal file
3
cmd/planctl/testdata/v2/context-info/tasks.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Tasks — context fixture
|
||||
|
||||
- [ ] 1.0 Implement greet _Requirements: R1.1_
|
||||
2
cmd/planctl/testdata/v2/context-info/transcript.jsonl
vendored
Normal file
2
cmd/planctl/testdata/v2/context-info/transcript.jsonl
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
{"type":"user","message":{"content":"hi"}}
|
||||
{"type":"assistant","sessionId":"fixture-uuid","message":{"usage":{"input_tokens":14200,"cache_creation_input_tokens":28400,"cache_read_input_tokens":99400,"output_tokens":100}}}
|
||||
1
cmd/planctl/testdata/v2/context-json-warn/args.txt
vendored
Normal file
1
cmd/planctl/testdata/v2/context-json-warn/args.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
lint --format=json FIXTURE
|
||||
3
cmd/planctl/testdata/v2/context-json-warn/env.txt
vendored
Normal file
3
cmd/planctl/testdata/v2/context-json-warn/env.txt
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
CLAUDE_SESSION_ID=fixture-uuid
|
||||
CLAUDE_PROJECT_DIR=/fixture/project
|
||||
CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000
|
||||
1
cmd/planctl/testdata/v2/context-json-warn/expected.exit
vendored
Normal file
1
cmd/planctl/testdata/v2/context-json-warn/expected.exit
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
0
|
||||
1
cmd/planctl/testdata/v2/context-json-warn/expected.golden
vendored
Normal file
1
cmd/planctl/testdata/v2/context-json-warn/expected.golden
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"summary":{"plans":1,"errors":0,"warnings":0},"context_window":{"tokens_used":174000,"tokens_limit":200000,"pct":87,"severity":"warn","recommendation":"commit current work and start a new session after this task."}}
|
||||
6
cmd/planctl/testdata/v2/context-json-warn/prd.md
vendored
Normal file
6
cmd/planctl/testdata/v2/context-json-warn/prd.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# PRD — context-json-warn
|
||||
|
||||
## 4. Functional Requirements
|
||||
|
||||
### R1. Greet
|
||||
- R1.1 THE SYSTEM SHALL greet the user on startup.
|
||||
3
cmd/planctl/testdata/v2/context-json-warn/tasks.md
vendored
Normal file
3
cmd/planctl/testdata/v2/context-json-warn/tasks.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Tasks — context-json-warn
|
||||
|
||||
- [ ] 1.0 Implement greet _Requirements: R1.1_
|
||||
2
cmd/planctl/testdata/v2/context-json-warn/transcript.jsonl
vendored
Normal file
2
cmd/planctl/testdata/v2/context-json-warn/transcript.jsonl
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
{"type":"user","message":{"content":"hi"}}
|
||||
{"type":"assistant","sessionId":"fixture-uuid","message":{"usage":{"input_tokens":17400,"cache_creation_input_tokens":34800,"cache_read_input_tokens":121800,"output_tokens":100}}}
|
||||
1
cmd/planctl/testdata/v2/context-multi-plan/args.txt
vendored
Normal file
1
cmd/planctl/testdata/v2/context-multi-plan/args.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
lint
|
||||
6
cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26170-alpha/prd.md
vendored
Normal file
6
cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26170-alpha/prd.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# PRD — alpha
|
||||
|
||||
## 4. Functional Requirements
|
||||
|
||||
### R1. Greet
|
||||
- R1.1 THE SYSTEM SHALL greet the user on startup.
|
||||
3
cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26170-alpha/tasks.md
vendored
Normal file
3
cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26170-alpha/tasks.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Tasks — alpha
|
||||
|
||||
- [ ] 1.0 Implement greet _Requirements: R1.1_
|
||||
6
cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26172-beta/prd.md
vendored
Normal file
6
cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26172-beta/prd.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# PRD — beta
|
||||
|
||||
## 4. Functional Requirements
|
||||
|
||||
### R1. Greet
|
||||
- R1.1 THE SYSTEM SHALL greet the user on startup.
|
||||
3
cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26172-beta/tasks.md
vendored
Normal file
3
cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26172-beta/tasks.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Tasks — beta
|
||||
|
||||
- [ ] 1.0 Implement greet _Requirements: R1.1_
|
||||
3
cmd/planctl/testdata/v2/context-multi-plan/env.txt
vendored
Normal file
3
cmd/planctl/testdata/v2/context-multi-plan/env.txt
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
CLAUDE_SESSION_ID=fixture-uuid
|
||||
CLAUDE_PROJECT_DIR=/fixture/project
|
||||
CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000
|
||||
1
cmd/planctl/testdata/v2/context-multi-plan/expected.exit
vendored
Normal file
1
cmd/planctl/testdata/v2/context-multi-plan/expected.exit
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
0
|
||||
7
cmd/planctl/testdata/v2/context-multi-plan/expected.golden
vendored
Normal file
7
cmd/planctl/testdata/v2/context-multi-plan/expected.golden
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
=== 26170-alpha ===
|
||||
26170-alpha: clean (1 tasks, 1 requirements, 0 design sections)
|
||||
|
||||
=== 26172-beta ===
|
||||
26172-beta: clean (1 tasks, 1 requirements, 0 design sections)
|
||||
2 plans linted, 0 errors, 0 warnings
|
||||
context: 142 k / 200 k tokens (71%) — plan to wrap up this session soon.
|
||||
2
cmd/planctl/testdata/v2/context-multi-plan/transcript.jsonl
vendored
Normal file
2
cmd/planctl/testdata/v2/context-multi-plan/transcript.jsonl
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
{"type":"user","message":{"content":"hi"}}
|
||||
{"type":"assistant","sessionId":"fixture-uuid","message":{"usage":{"input_tokens":14200,"cache_creation_input_tokens":28400,"cache_read_input_tokens":99400,"output_tokens":100}}}
|
||||
2
cmd/planctl/testdata/v2/context-no-limit/env.txt
vendored
Normal file
2
cmd/planctl/testdata/v2/context-no-limit/env.txt
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
CLAUDE_SESSION_ID=fixture-uuid
|
||||
CLAUDE_PROJECT_DIR=/fixture/project
|
||||
1
cmd/planctl/testdata/v2/context-no-limit/expected.exit
vendored
Normal file
1
cmd/planctl/testdata/v2/context-no-limit/expected.exit
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
0
|
||||
2
cmd/planctl/testdata/v2/context-no-limit/expected.golden
vendored
Normal file
2
cmd/planctl/testdata/v2/context-no-limit/expected.golden
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
context-no-limit: clean (1 tasks, 1 requirements, 0 design sections)
|
||||
context: 143 k tokens (limit unknown)
|
||||
6
cmd/planctl/testdata/v2/context-no-limit/prd.md
vendored
Normal file
6
cmd/planctl/testdata/v2/context-no-limit/prd.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# PRD — context fixture
|
||||
|
||||
## 4. Functional Requirements
|
||||
|
||||
### R1. Greet
|
||||
- R1.1 THE SYSTEM SHALL greet the user on startup.
|
||||
3
cmd/planctl/testdata/v2/context-no-limit/tasks.md
vendored
Normal file
3
cmd/planctl/testdata/v2/context-no-limit/tasks.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Tasks — context fixture
|
||||
|
||||
- [ ] 1.0 Implement greet _Requirements: R1.1_
|
||||
2
cmd/planctl/testdata/v2/context-no-limit/transcript.jsonl
vendored
Normal file
2
cmd/planctl/testdata/v2/context-no-limit/transcript.jsonl
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
{"type":"user","message":{"content":"hi"}}
|
||||
{"type":"assistant","sessionId":"fixture-uuid","message":{"usage":{"input_tokens":14300,"cache_creation_input_tokens":28600,"cache_read_input_tokens":100100,"output_tokens":100}}}
|
||||
1
cmd/planctl/testdata/v2/context-no-session/expected.exit
vendored
Normal file
1
cmd/planctl/testdata/v2/context-no-session/expected.exit
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
0
|
||||
1
cmd/planctl/testdata/v2/context-no-session/expected.golden
vendored
Normal file
1
cmd/planctl/testdata/v2/context-no-session/expected.golden
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
context-no-session: clean (1 tasks, 1 requirements, 0 design sections)
|
||||
6
cmd/planctl/testdata/v2/context-no-session/prd.md
vendored
Normal file
6
cmd/planctl/testdata/v2/context-no-session/prd.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# PRD — context fixture
|
||||
|
||||
## 4. Functional Requirements
|
||||
|
||||
### R1. Greet
|
||||
- R1.1 THE SYSTEM SHALL greet the user on startup.
|
||||
3
cmd/planctl/testdata/v2/context-no-session/tasks.md
vendored
Normal file
3
cmd/planctl/testdata/v2/context-no-session/tasks.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Tasks — context fixture
|
||||
|
||||
- [ ] 1.0 Implement greet _Requirements: R1.1_
|
||||
3
cmd/planctl/testdata/v2/context-normal/env.txt
vendored
Normal file
3
cmd/planctl/testdata/v2/context-normal/env.txt
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
CLAUDE_SESSION_ID=fixture-uuid
|
||||
CLAUDE_PROJECT_DIR=/fixture/project
|
||||
CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000
|
||||
1
cmd/planctl/testdata/v2/context-normal/expected.exit
vendored
Normal file
1
cmd/planctl/testdata/v2/context-normal/expected.exit
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
0
|
||||
1
cmd/planctl/testdata/v2/context-normal/expected.golden
vendored
Normal file
1
cmd/planctl/testdata/v2/context-normal/expected.golden
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
context-normal: clean (1 tasks, 1 requirements, 0 design sections)
|
||||
6
cmd/planctl/testdata/v2/context-normal/prd.md
vendored
Normal file
6
cmd/planctl/testdata/v2/context-normal/prd.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# PRD — context fixture
|
||||
|
||||
## 4. Functional Requirements
|
||||
|
||||
### R1. Greet
|
||||
- R1.1 THE SYSTEM SHALL greet the user on startup.
|
||||
3
cmd/planctl/testdata/v2/context-normal/tasks.md
vendored
Normal file
3
cmd/planctl/testdata/v2/context-normal/tasks.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Tasks — context fixture
|
||||
|
||||
- [ ] 1.0 Implement greet _Requirements: R1.1_
|
||||
2
cmd/planctl/testdata/v2/context-normal/transcript.jsonl
vendored
Normal file
2
cmd/planctl/testdata/v2/context-normal/transcript.jsonl
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
{"type":"user","message":{"content":"hi"}}
|
||||
{"type":"assistant","sessionId":"fixture-uuid","message":{"usage":{"input_tokens":10000,"cache_creation_input_tokens":20000,"cache_read_input_tokens":70000,"output_tokens":100}}}
|
||||
3
cmd/planctl/testdata/v2/context-warn/env.txt
vendored
Normal file
3
cmd/planctl/testdata/v2/context-warn/env.txt
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
CLAUDE_SESSION_ID=fixture-uuid
|
||||
CLAUDE_PROJECT_DIR=/fixture/project
|
||||
CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000
|
||||
1
cmd/planctl/testdata/v2/context-warn/expected.exit
vendored
Normal file
1
cmd/planctl/testdata/v2/context-warn/expected.exit
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
0
|
||||
2
cmd/planctl/testdata/v2/context-warn/expected.golden
vendored
Normal file
2
cmd/planctl/testdata/v2/context-warn/expected.golden
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
context-warn: clean (1 tasks, 1 requirements, 0 design sections)
|
||||
context: 174 k / 200 k tokens (87%) — commit current work and start a new session after this task.
|
||||
6
cmd/planctl/testdata/v2/context-warn/prd.md
vendored
Normal file
6
cmd/planctl/testdata/v2/context-warn/prd.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# PRD — context fixture
|
||||
|
||||
## 4. Functional Requirements
|
||||
|
||||
### R1. Greet
|
||||
- R1.1 THE SYSTEM SHALL greet the user on startup.
|
||||
3
cmd/planctl/testdata/v2/context-warn/tasks.md
vendored
Normal file
3
cmd/planctl/testdata/v2/context-warn/tasks.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Tasks — context fixture
|
||||
|
||||
- [ ] 1.0 Implement greet _Requirements: R1.1_
|
||||
2
cmd/planctl/testdata/v2/context-warn/transcript.jsonl
vendored
Normal file
2
cmd/planctl/testdata/v2/context-warn/transcript.jsonl
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
{"type":"user","message":{"content":"hi"}}
|
||||
{"type":"assistant","sessionId":"fixture-uuid","message":{"usage":{"input_tokens":17400,"cache_creation_input_tokens":34800,"cache_read_input_tokens":121800,"output_tokens":100}}}
|
||||
|
|
@ -131,6 +131,18 @@ An agent's pre-flight compares `@`'s bookmark against `$AGENT_BOOKMARK`; mismatc
|
|||
|
||||
This is a convention, not a jj feature. `jj` itself reads `JJ_USER` / `JJ_EMAIL` / `JJ_OP_USERNAME` but has no "my bookmark" concept — the env var is a tripwire for the agent's own pre-flight logic. For real isolation, use workspaces; the env var is the fallback.
|
||||
|
||||
#### Planned: JJ_AGENT_FEATURE
|
||||
|
||||
A future `JJ_AGENT_FEATURE` env var will give commits and end-of-session bookmarks a human-readable feature name instead of the raw session UUID:
|
||||
|
||||
```bash
|
||||
export JJ_AGENT_FEATURE=auth # example
|
||||
# commits become: wip(auth:abc12345): file1, file2
|
||||
# session-end bookmark: feat/auth (instead of wip/claude-abc12345)
|
||||
```
|
||||
|
||||
Not yet implemented — the daemon and hook script need changes to read and propagate it. When available, set it alongside `AGENT_BOOKMARK` in the same env block. Until then, the raw `wip(claude:<session_id>)` messages are the identifiers to use.
|
||||
|
||||
### Pre-flight check (every agent, every session start)
|
||||
|
||||
If not using a dedicated workspace, run before any file write:
|
||||
|
|
|
|||
6
dev/plans/26174-planctl-context-tokens/codex-sessions.md
Normal file
6
dev/plans/26174-planctl-context-tokens/codex-sessions.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
- 2026-04-23T16:49:16Z prd-review 019dbb3e-b945-7962-bcb1-cd3ad6b098d1
|
||||
- 2026-04-23T17:41:00Z design-review 019dbb64-1df2-7842-85f4-ab58efac6bb4
|
||||
- 2026-04-23T17:44:50Z tasks-review 019dbc84-7470-7130-95f7-38678a69dd9a
|
||||
- 2026-04-23T17:28:43Z code-review-parent-1 019dbcac-a359-72c1-b3ba-1d9f9c5105fb
|
||||
- 2026-04-23T18:31:09Z code-review-parent-2 019dbce5-cb69-7eb0-b82f-58ce99a04562
|
||||
- 2026-04-23T18:56:02Z pre-pr-review 019dbcfc-911a-7ee2-835a-31d9461f9f7d
|
||||
|
|
@ -4,12 +4,16 @@
|
|||
|
||||
planctl is a flat `package main` in `cmd/planctl/`. All source files share a single package, so the new `context.go` file integrates without any package-boundary changes.
|
||||
|
||||
**New file:**
|
||||
**New files:**
|
||||
- `cmd/planctl/context.go` — session discovery, JSONL parsing, threshold classification
|
||||
- `cmd/planctl/context_test.go` — unit tests
|
||||
- `cmd/planctl/proc_darwin.go` — build-tagged: `ppidOf`, `processStartTimeSec` (macOS)
|
||||
- `cmd/planctl/proc_linux.go` — build-tagged: `ppidOf`, `processStartTimeSec` (Linux)
|
||||
|
||||
**Modified files:**
|
||||
- `cmd/planctl/main.go` — call `ReadTokenCtx()` once in `runLint()`, pass result to emitters
|
||||
- `cmd/planctl/main.go` — call `ReadTokenCtx()` once at the top of `run()`, thread `ctx *TokenCtx` into all subcommand handlers
|
||||
- `cmd/planctl/emit.go` — add `*TokenCtx` parameter to `emitText` / `emitJSON`; append context line after summary
|
||||
- `cmd/planctl/main_test.go` — extend golden-file E2E tests
|
||||
- `scripts/jj-hook.sh` — `session-start` case: write session UUID to `$CLAUDE_ENV_FILE` and PID file; `session-end` case: remove PID file
|
||||
|
||||
No new Go module dependencies. Platform differences (macOS vs. Linux) for PID ancestry are handled by build-tagged helper files within `cmd/planctl/`.
|
||||
|
|
@ -28,11 +32,11 @@ flowchart TD
|
|||
C --> D{CLAUDE_SESSION_ID\nenv set?}
|
||||
D -- yes --> G[use env UUID]
|
||||
D -- no --> E[walk process ancestors\n up to 8 hops]
|
||||
E --> F{/tmp/planctl-sessions/<pid>\nexists + process alive\n+ start-time matches?}
|
||||
E --> F{/tmp/planctl-sessions/pid\nexists + process alive\n+ start-time matches?}
|
||||
F -- yes --> G
|
||||
F -- no → next hop / exhausted --> H[nil TokenCtx]
|
||||
F -- no, next hop or exhausted --> H[return nil TokenCtx]
|
||||
|
||||
G --> I[derive transcript path:\n~/.claude/projects/<slug>/<uuid>.jsonl]
|
||||
G --> I[derive transcript path:\n~/.claude/projects/slug/uuid.jsonl]
|
||||
I --> J[open file;\nbufio.Scanner 10 MB buf]
|
||||
J --> K{file readable?}
|
||||
K -- no --> H
|
||||
|
|
@ -60,7 +64,7 @@ _Requirements: R1.1, R1.2, R1.4, R2.1, R2.2, R2.3, R2.4, R3.1, R3.2, R3.3, R4.1,
|
|||
|
||||
All types and functions below live in `cmd/planctl/context.go` unless noted.
|
||||
|
||||
### 3.1 `TokenCtx`
|
||||
### §3.1 `TokenCtx`
|
||||
|
||||
```go
|
||||
// TokenCtx holds the resolved token-usage data for the current session.
|
||||
|
|
@ -75,7 +79,7 @@ type TokenCtx struct {
|
|||
|
||||
_Requirements: R2.2, R2.4, R3.3_
|
||||
|
||||
### 3.2 `ReadTokenCtx() *TokenCtx`
|
||||
### §3.2 `ReadTokenCtx() *TokenCtx`
|
||||
|
||||
```go
|
||||
// ReadTokenCtx is the ambient constructor: it reads the calling process's
|
||||
|
|
@ -91,9 +95,11 @@ Internal call chain:
|
|||
3. `parseLastUsage(path string) (int64, error)` — JSONL scan (§3.5)
|
||||
4. `readLimit() int64` — env var read (§3.6)
|
||||
|
||||
_Requirements: R1.1, R1.2, R1.3, R1.4, R1.5, R1.6, R2.1, R2.2, R2.3, R2.4_
|
||||
Called once at the top of `run()` (not inside `runLint()`), so the `ctx` value is available to every subcommand handler — current (`runLint`) and future (`runNext`, `runList`, `runComplete`, `runStatus`). Each handler that emits output receives `ctx *TokenCtx` as a parameter.
|
||||
|
||||
### 3.3 `resolveSessionUUID() string`
|
||||
_Requirements: R1.1, R1.2, R1.3, R1.4, R1.5, R1.6, R2.1, R2.2, R2.3, R2.4, R3.4_
|
||||
|
||||
### §3.3 `resolveSessionUUID() string`
|
||||
|
||||
```go
|
||||
// resolveSessionUUID returns the session UUID string, or "" if not found.
|
||||
|
|
@ -101,33 +107,64 @@ _Requirements: R1.1, R1.2, R1.3, R1.4, R1.5, R1.6, R2.1, R2.2, R2.3, R2.4_
|
|||
func resolveSessionUUID() string
|
||||
```
|
||||
|
||||
PID walk logic:
|
||||
PID walk algorithm:
|
||||
|
||||
```
|
||||
uuid = os.Getenv("CLAUDE_SESSION_ID")
|
||||
if uuid != "": return uuid
|
||||
|
||||
pid = os.Getpid()
|
||||
repeat up to 8 times:
|
||||
pid = ppidOf(pid) // platform-specific; returns 0 on error
|
||||
if pid <= 1: break
|
||||
path = "/tmp/planctl-sessions/" + strconv.Itoa(pid)
|
||||
if !fileExists(path): continue
|
||||
uuid, startSec = parsePIDFile(path)
|
||||
if !processAlive(pid): continue // kill(pid,0) failed
|
||||
if processStartTimeSec(pid) != startSec: continue // PID reused
|
||||
return uuid
|
||||
for i := 0; i < 8; i++:
|
||||
pid = ppidOf(pid) // platform-specific (§3.3a); returns 0 on error
|
||||
if pid <= 1: break
|
||||
path = "/tmp/planctl-sessions/" + strconv.Itoa(pid)
|
||||
if !fileExists(path): continue
|
||||
uuid, startSec, err = parsePIDFile(path)
|
||||
if err != nil: continue
|
||||
if !processAlive(pid): continue // syscall.Kill(pid, 0) failed
|
||||
if processStartTimeSec(pid) != startSec: continue // PID was reused
|
||||
return uuid
|
||||
return ""
|
||||
```
|
||||
|
||||
Platform helpers live in build-tagged files:
|
||||
- `cmd/planctl/proc_darwin.go` — `ppidOf`, `processStartTimeSec` via `syscall.SysctlKinfoProc`
|
||||
- `cmd/planctl/proc_linux.go` — `ppidOf`, `processStartTimeSec` via `/proc/<pid>/status` and `/proc/<pid>/stat`
|
||||
`processAlive(pid int) bool` wraps `syscall.Kill(pid, 0)`.
|
||||
|
||||
_Requirements: R1.1, R1.2, R1.3, R1.4, R6.1, R6.3_
|
||||
#### §3.3a Platform helpers (build-tagged)
|
||||
|
||||
### 3.4 `transcriptPath(uuid, home, projectDir string) string`
|
||||
`cmd/planctl/proc_darwin.go` (`//go:build darwin`):
|
||||
```go
|
||||
func ppidOf(pid int) (int, error) {
|
||||
info, err := syscall.SysctlKinfoProc("kern.proc.pid", pid)
|
||||
if err != nil { return 0, err }
|
||||
return int(info.Eproc.Ppid), nil
|
||||
}
|
||||
|
||||
func processStartTimeSec(pid int) (int64, error) {
|
||||
info, err := syscall.SysctlKinfoProc("kern.proc.pid", pid)
|
||||
if err != nil { return 0, err }
|
||||
return int64(info.Proc.P_starttime.Sec), nil
|
||||
}
|
||||
```
|
||||
|
||||
`cmd/planctl/proc_linux.go` (`//go:build linux`):
|
||||
```go
|
||||
// ppidOf reads PPid from /proc/<pid>/status (safe: line-based, not field-based).
|
||||
func ppidOf(pid int) (int, error) { ... }
|
||||
|
||||
// processStartTimeSec reads the start-time field from /proc/<pid>/stat robustly:
|
||||
// the stat file format is "pid (comm) state ppid ... starttime ..."; the comm
|
||||
// field can contain spaces and parentheses, so field 22 is NOT safe to read with
|
||||
// awk '{print $22}'. Instead: find the last ')' in the line, then count fields
|
||||
// from there. Returns clock-ticks-since-boot (preserves full precision for
|
||||
// comparison; no conversion to seconds that would discard subsecond resolution).
|
||||
func processStartTimeSec(pid int) (int64, error) { ... }
|
||||
```
|
||||
|
||||
_Requirements: R1.2, R6.1, R6.3_
|
||||
|
||||
### §3.4 `transcriptPath(uuid, home, projectDir string) string`
|
||||
|
||||
```go
|
||||
// transcriptPath assembles the Claude Code transcript path.
|
||||
// slug = projectDir with every '/' replaced by '-'.
|
||||
// Falls back to os.Getwd() if projectDir is "".
|
||||
func transcriptPath(uuid, home, projectDir string) string {
|
||||
if projectDir == "" {
|
||||
projectDir, _ = os.Getwd()
|
||||
|
|
@ -137,14 +174,16 @@ func transcriptPath(uuid, home, projectDir string) string {
|
|||
}
|
||||
```
|
||||
|
||||
Example: `/Users/sid/repos/template-jj` → `-Users-sid-repos-template-jj`
|
||||
Path: `~/.claude/projects/-Users-sid-repos-template-jj/<uuid>.jsonl`
|
||||
Example: `/Users/sid/repos/template-jj` → slug `-Users-sid-repos-template-jj`
|
||||
Full path: `~/.claude/projects/-Users-sid-repos-template-jj/<uuid>.jsonl`
|
||||
|
||||
`home` is `os.UserHomeDir()` resolved once in `ReadTokenCtx`.
|
||||
|
||||
_Requirements: R1.5, R1.6_
|
||||
|
||||
### 3.5 `parseLastUsage(path string) (int64, error)`
|
||||
### §3.5 `parseLastUsage(path string) (int64, error)`
|
||||
|
||||
Scans the JSONL file for the last assistant line with a usage object.
|
||||
Minimal JSON type for partial decoding:
|
||||
|
||||
```go
|
||||
type transcriptUsage struct {
|
||||
|
|
@ -161,36 +200,35 @@ type transcriptUsage struct {
|
|||
|
||||
Algorithm:
|
||||
1. Open file; create `bufio.Scanner` with `Scanner.Buffer(make([]byte, 10<<20), 10<<20)` (10 MB max per line).
|
||||
2. Read all lines; for each line that can be JSON-decoded into `transcriptUsage` with `Type == "assistant"` and non-zero usage, record the sum `input + cache_creation + cache_read`.
|
||||
3. Return the last recorded sum (walking forward preserves last-wins without a reverse seek).
|
||||
4. If no qualifying line found: return `(0, errNoUsageLine)`.
|
||||
2. Forward scan all lines; for each line that decodes to `transcriptUsage` with `Type == "assistant"`, compute the sum and overwrite `last`.
|
||||
3. Return `last` after EOF. If no qualifying line found: return `(0, errNoUsageLine)`.
|
||||
|
||||
Malformed lines (JSON decode error) are silently skipped (continue).
|
||||
Malformed lines (JSON decode error) are silently skipped — `encoding/json.Unmarshal` error → `continue`.
|
||||
|
||||
_Requirements: R2.1, R2.2, R2.3, R2.6_
|
||||
|
||||
### 3.6 `readLimit() int64`
|
||||
### §3.6 `readLimit() int64`
|
||||
|
||||
```go
|
||||
// readLimit returns CLAUDE_CODE_MAX_CONTEXT_TOKENS as an int64, or 0 if unset/invalid.
|
||||
func readLimit() int64
|
||||
func readLimit() int64 {
|
||||
v := os.Getenv("CLAUDE_CODE_MAX_CONTEXT_TOKENS")
|
||||
if v == "" { return 0 }
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil { return 0 }
|
||||
return n
|
||||
}
|
||||
```
|
||||
|
||||
Parses `os.Getenv("CLAUDE_CODE_MAX_CONTEXT_TOKENS")` with `strconv.ParseInt`; returns 0 on parse error.
|
||||
|
||||
_Requirements: R2.4_
|
||||
|
||||
### 3.7 `(t *TokenCtx) Classify() (severity, msg string)`
|
||||
### §3.7 `(t *TokenCtx) Classify() (severity, msg string)`
|
||||
|
||||
```go
|
||||
// Classify returns the band's severity label and recommendation message suffix.
|
||||
// Returns ("", "") for the Normal band or when t is nil.
|
||||
// When Limit == 0, severity = "" and msg = "" (raw count only, no recommendation).
|
||||
// Classify returns the band's severity label and recommendation suffix.
|
||||
// Returns ("", "") for Normal band, unknown limit, or nil receiver.
|
||||
func (t *TokenCtx) Classify() (severity, msg string)
|
||||
```
|
||||
|
||||
Threshold table (matching PRD R3.2):
|
||||
|
||||
| pct | severity | msg |
|
||||
|-----|----------|-----|
|
||||
| < 70 | `""` | `""` |
|
||||
|
|
@ -198,29 +236,29 @@ Threshold table (matching PRD R3.2):
|
|||
| 85–94 | `"warn"` | `"commit current work and start a new session after this task."` |
|
||||
| ≥ 95 | `"error"` | `"stop new work; commit and close out immediately."` |
|
||||
|
||||
When `Limit == 0`: return `("", "")` — the emitter uses `formatTokenCount(t.Used)` + `(limit unknown)`.
|
||||
When `t.Limit == 0`: return `("", "")`. The emitter detects `Limit == 0` separately and emits the raw-count form (§4.3).
|
||||
|
||||
_Requirements: R3.1, R3.2, R3.3, R3.5_
|
||||
|
||||
### 3.8 `formatTokenCount(n int64) string`
|
||||
### §3.8 `formatTokenCount(n int64) string`
|
||||
|
||||
```go
|
||||
// formatTokenCount returns n expressed in thousands: "143 k", "< 1 k", etc.
|
||||
// formatTokenCount renders n in thousands, rounding to nearest (PRD R4.4).
|
||||
// Values under 1000 are always "< 1 k" regardless of rounding.
|
||||
func formatTokenCount(n int64) string {
|
||||
k := n / 1000
|
||||
if k == 0 { return "< 1 k" }
|
||||
if n < 1000 { return "< 1 k" }
|
||||
k := (n + 500) / 1000
|
||||
return strconv.FormatInt(k, 10) + " k"
|
||||
}
|
||||
```
|
||||
|
||||
Boundary table: n=0→`< 1 k`, n=500→`< 1 k`, n=999→`< 1 k`, n=1000→`1 k`, n=1499→`1 k`, n=1500→`2 k`.
|
||||
|
||||
_Requirements: R4.4_
|
||||
|
||||
### 3.9 `parsePIDFile(path string) (uuid string, startSec int64, err error)`
|
||||
### §3.9 `parsePIDFile(path string) (uuid string, startSec int64, err error)`
|
||||
|
||||
```go
|
||||
// parsePIDFile reads a two-line PID file: line 1 = session UUID, line 2 = start-time-seconds.
|
||||
func parsePIDFile(path string) (uuid string, startSec int64, err error)
|
||||
```
|
||||
Reads exactly two newline-terminated lines from the file. Returns error if either line is missing or `startSec` is not a valid decimal integer.
|
||||
|
||||
_Requirements: R6.2, R6.3_
|
||||
|
||||
|
|
@ -228,23 +266,23 @@ _Requirements: R6.2, R6.3_
|
|||
|
||||
## §4. API / Protocol Contracts
|
||||
|
||||
### 4.1 PID file format
|
||||
### §4.1 PID file format
|
||||
|
||||
File path: `/tmp/planctl-sessions/<ppid>` (where `<ppid>` is the Claude Code process PID).
|
||||
File: `/tmp/planctl-sessions/<ppid>` (PPID = Claude Code process PID at hook time).
|
||||
|
||||
```
|
||||
<session-uuid>\n
|
||||
<start-time-seconds>\n
|
||||
<start-stamp>\n
|
||||
```
|
||||
|
||||
- Line 1: full UUID (e.g. `817fec78-6ff1-4fb2-a17c-5708ea5df968`)
|
||||
- Line 2: decimal integer seconds since Unix epoch (UTC) when the Claude Code process started
|
||||
- Line 1: full RFC 4122 UUID
|
||||
- Line 2: platform-specific start-time stamp — raw clock ticks since boot on Linux (from `/proc/<pid>/stat` via `sed 's/.*)//' | awk '{print $20}'`), seconds since Unix epoch on macOS (from `ps -o lstart=`). The Go resolver (`processStartTimeSec`) returns the same unit on each platform, so cross-platform comparisons are never made.
|
||||
|
||||
Hook writes atomically by writing to a temp file then `mv` to the final path (prevents partial reads).
|
||||
Written atomically by the hook via `mktemp` + `mv`.
|
||||
|
||||
_Requirements: R6.2_
|
||||
|
||||
### 4.2 Transcript JSONL schema (consumed fields only)
|
||||
### §4.2 Transcript JSONL schema (consumed fields)
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
@ -259,38 +297,40 @@ _Requirements: R6.2_
|
|||
}
|
||||
```
|
||||
|
||||
Unknown top-level fields are ignored by `encoding/json` by default. The `output_tokens` field is present but not consumed (excluded from context fill per PRD §5).
|
||||
`output_tokens` is present but not consumed (excluded per PRD §5). Unknown fields are ignored by `encoding/json`.
|
||||
|
||||
_Requirements: R2.1, R2.2_
|
||||
|
||||
### 4.3 Text output — context line
|
||||
### §4.3 Text output — context line placement
|
||||
|
||||
Single-plan, known limit, band ≠ Normal:
|
||||
```
|
||||
# Single-plan, known limit, band ≠ Normal:
|
||||
<plan-summary-line>
|
||||
context: 143 k / 200 k tokens (71%) — plan to wrap up this session soon.
|
||||
```
|
||||
|
||||
Single-plan, unknown limit:
|
||||
```
|
||||
# Single-plan, unknown limit (emitted regardless of token count):
|
||||
<plan-summary-line>
|
||||
context: 143 k tokens (limit unknown)
|
||||
```
|
||||
|
||||
Multi-plan — context line follows the aggregate summary:
|
||||
```
|
||||
# Multi-plan — context line follows aggregate summary:
|
||||
3 plans linted, 0 errors, 0 warnings
|
||||
context: 143 k / 200 k tokens (71%) — plan to wrap up this session soon.
|
||||
|
||||
# Normal band (pct < 70, limit known) or nil ctx: no context line.
|
||||
```
|
||||
|
||||
Normal band (pct < 70, limit known): no context line emitted.
|
||||
Nil `TokenCtx`: no context line emitted.
|
||||
In `emitText`:
|
||||
- Single-plan path: `writeContextLine(w, ctx)` called after `writePlanText`.
|
||||
- Multi-plan path: `writeContextLine(w, ctx)` called after `writeAggregateText`.
|
||||
|
||||
`writeContextLine` is a no-op when `ctx == nil` or (limit known AND pct < 70).
|
||||
|
||||
_Requirements: R3.3, R3.4, R3.5, R4.1, R4.2, R4.3_
|
||||
|
||||
### 4.4 JSON output — `context_window` field
|
||||
### §4.4 JSON output — `context_window` field
|
||||
|
||||
```json
|
||||
// Known limit, band ≠ Normal:
|
||||
{
|
||||
"summary": { "plans": 1, "errors": 0, "warnings": 0 },
|
||||
"context_window": {
|
||||
|
|
@ -301,14 +341,12 @@ _Requirements: R3.3, R3.4, R3.5, R4.1, R4.2, R4.3_
|
|||
"recommendation": "plan to wrap up this session soon."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Unknown limit (only `tokens_used` key present):
|
||||
```json
|
||||
// Unknown limit:
|
||||
{ "summary": { ... }, "context_window": { "tokens_used": 143000 } }
|
||||
```
|
||||
|
||||
Normal band or nil ctx: `context_window` key omitted entirely.
|
||||
// Normal band or nil ctx: no context_window key.
|
||||
```
|
||||
|
||||
_Requirements: R5.1, R5.2, R5.3, R5.4_
|
||||
|
||||
|
|
@ -316,180 +354,203 @@ _Requirements: R5.1, R5.2, R5.3, R5.4_
|
|||
|
||||
## §5. State / Schema Changes
|
||||
|
||||
### 5.1 New temp directory
|
||||
### §5.1 New temp directory
|
||||
|
||||
`/tmp/planctl-sessions/` — created on first write by the hook (`mkdir -p`). Files in this directory are named by PID and written/deleted by `scripts/jj-hook.sh`.
|
||||
`/tmp/planctl-sessions/` — created by hook (`mkdir -p`). Files named by PID; one per active session; removed at `session-end`.
|
||||
|
||||
### 5.2 Environment variables consumed (read-only)
|
||||
### §5.2 Environment variables consumed
|
||||
|
||||
| Variable | Source | Used for |
|
||||
| Variable | Source | Purpose |
|
||||
|---|---|---|
|
||||
| `CLAUDE_SESSION_ID` | Set by hook via `$CLAUDE_ENV_FILE` | Primary session UUID |
|
||||
| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` | Set by Claude Code | Context window limit |
|
||||
| `CLAUDE_PROJECT_DIR` | Set by Claude Code | Project slug derivation |
|
||||
| `CLAUDE_SESSION_ID` | Hook via `$CLAUDE_ENV_FILE` at `SessionStart` | Primary session UUID |
|
||||
| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` | Claude Code | Context window limit |
|
||||
| `CLAUDE_PROJECT_DIR` | Claude Code | Project slug for transcript path |
|
||||
|
||||
planctl does not write any environment variable.
|
||||
planctl writes no files and sets no environment variables.
|
||||
|
||||
### 5.3 `emitText` / `emitJSON` signature change
|
||||
_Requirements: R1.1, R1.5, R2.4_
|
||||
|
||||
### §5.3 Emitter signature change (`cmd/planctl/emit.go`)
|
||||
|
||||
```go
|
||||
// Before
|
||||
// Before:
|
||||
func emitText(w io.Writer, plans []PlanResult, strict bool) int
|
||||
func emitJSON(w io.Writer, plans []PlanResult, strict bool) int
|
||||
|
||||
// After
|
||||
// After:
|
||||
func emitText(w io.Writer, plans []PlanResult, strict bool, ctx *TokenCtx) int
|
||||
func emitJSON(w io.Writer, plans []PlanResult, strict bool, ctx *TokenCtx) int
|
||||
```
|
||||
|
||||
The added `ctx *TokenCtx` is always nil-safe; callers in `runLint` pass the value from `ReadTokenCtx()`.
|
||||
`run()` in `main.go` calls `ReadTokenCtx()` once and passes `ctx` to each subcommand handler.
|
||||
|
||||
### 5.4 JSON summary type extension
|
||||
_Requirements: R3.4, R4.1, R4.3, R5.1_
|
||||
|
||||
### §5.4 JSON summary type extension (`cmd/planctl/emit.go`)
|
||||
|
||||
```go
|
||||
// jsonSummary extended with optional ContextWindow
|
||||
type jsonSummary struct {
|
||||
Summary summaryBody `json:"summary"`
|
||||
ContextWin *jsonCtxWin `json:"context_window,omitempty"`
|
||||
Summary summaryBody `json:"summary"`
|
||||
ContextWin *jsonCtxWin `json:"context_window,omitempty"`
|
||||
}
|
||||
|
||||
type jsonCtxWin struct {
|
||||
TokensUsed int64 `json:"tokens_used"`
|
||||
TokensLimit *int64 `json:"tokens_limit,omitempty"` // nil when unknown
|
||||
Pct *int `json:"pct,omitempty"` // nil when unknown
|
||||
Severity string `json:"severity,omitempty"`
|
||||
TokensUsed int64 `json:"tokens_used"`
|
||||
TokensLimit *int64 `json:"tokens_limit,omitempty"`
|
||||
Pct *int `json:"pct,omitempty"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
Recommendation string `json:"recommendation,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
`omitempty` on pointer fields achieves the "omit when unknown" behavior from R5.2 without conditional branches in the encoder.
|
||||
`omitempty` on pointer fields achieves "omit when unknown" without conditional encoder branches.
|
||||
|
||||
_Requirements: R5.1, R5.2, R5.3_
|
||||
|
||||
### 5.5 Hook changes (`scripts/jj-hook.sh`)
|
||||
### §5.5 Hook changes (`scripts/jj-hook.sh`)
|
||||
|
||||
**`session-start` case** — additions after the existing `ensure_daemon` / `send_event` block:
|
||||
`session-start` — after existing daemon block:
|
||||
|
||||
```bash
|
||||
FULL_SID=$(printf '%s\n' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null)
|
||||
if [ -n "$FULL_SID" ]; then
|
||||
# Primary: propagate session ID to all subprocesses via env
|
||||
# Validate UUID format before writing to env file (prevents shell injection via $CLAUDE_ENV_FILE).
|
||||
if [[ "$FULL_SID" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then
|
||||
# Primary: env var propagated to all subprocesses.
|
||||
[ -n "${CLAUDE_ENV_FILE:-}" ] && \
|
||||
echo "export CLAUDE_SESSION_ID=${FULL_SID}" >> "$CLAUDE_ENV_FILE"
|
||||
printf 'export CLAUDE_SESSION_ID=%s\n' "$FULL_SID" >> "$CLAUDE_ENV_FILE"
|
||||
|
||||
# Secondary: PID-keyed session file for concurrent-session fallback.
|
||||
# Stores UUID + process start time for identity verification (R6.2/R6.3).
|
||||
START_SEC=$(planctl_start_time "$PPID") # helper function (see below)
|
||||
# Secondary: PID-keyed file for concurrent-session fallback (R6.2/R6.3).
|
||||
START_TICKS=$(planctl_start_ticks "$PPID")
|
||||
mkdir -p /tmp/planctl-sessions
|
||||
TMPF=$(mktemp /tmp/planctl-sessions/.tmp.XXXXXX)
|
||||
printf '%s\n%s\n' "$FULL_SID" "$START_SEC" > "$TMPF"
|
||||
printf '%s\n%s\n' "$FULL_SID" "$START_TICKS" > "$TMPF"
|
||||
mv "$TMPF" "/tmp/planctl-sessions/${PPID}"
|
||||
fi
|
||||
```
|
||||
|
||||
`planctl_start_time` shell function (platform-specific):
|
||||
`planctl_start_ticks` helper (named `_ticks` to clarify the Linux return unit):
|
||||
|
||||
```bash
|
||||
planctl_start_time() {
|
||||
planctl_start_ticks() {
|
||||
local pid="$1"
|
||||
if [ -f "/proc/$pid/stat" ]; then
|
||||
# Linux: field 22 of /proc/pid/stat is start time in clock ticks since boot.
|
||||
local ticks btime clk_tck
|
||||
ticks=$(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null)
|
||||
btime=$(awk '/btime/{print $2}' /proc/stat 2>/dev/null)
|
||||
clk_tck=$(getconf CLK_TCK 2>/dev/null || echo 100)
|
||||
echo $(( btime + ticks / clk_tck ))
|
||||
# Linux: /proc/pid/stat field 2 (comm) can contain spaces and parens.
|
||||
# Safe parse: strip everything up to and including the last ')' first,
|
||||
# then field 20 of the remainder is starttime (clock ticks since boot).
|
||||
sed 's/.*)//' "/proc/$pid/stat" 2>/dev/null | awk '{print $20}' || echo 0
|
||||
else
|
||||
# macOS: parse 'ps lstart' and convert to epoch seconds.
|
||||
# macOS: use p_starttime seconds from SysctlKinfoProc (set by Go code).
|
||||
# Shell fallback: 'ps -o lstart=' → epoch seconds via date.
|
||||
ps -o lstart= -p "$pid" 2>/dev/null | \
|
||||
xargs -I{} date -j -f '%a %b %d %T %Y' '{}' '+%s' 2>/dev/null || echo 0
|
||||
fi
|
||||
}
|
||||
```
|
||||
|
||||
**`session-end` case** — additions:
|
||||
`session-end` — after existing block:
|
||||
|
||||
```bash
|
||||
FULL_SID_END=$(printf '%s\n' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null)
|
||||
if [ -n "$FULL_SID_END" ]; then
|
||||
rm -f "/tmp/planctl-sessions/${PPID}"
|
||||
fi
|
||||
[ -n "$FULL_SID_END" ] && rm -f "/tmp/planctl-sessions/${PPID}"
|
||||
```
|
||||
|
||||
_Requirements: R7.1, R7.2, R7.3_
|
||||
_Requirements: R7.1, R7.2, R7.3, R7.4_
|
||||
|
||||
---
|
||||
|
||||
## §6. Security & Resource Considerations
|
||||
|
||||
### 6.1 External (untrusted) input
|
||||
### §6.1 External (untrusted) input
|
||||
|
||||
The transcript JSONL file at `~/.claude/projects/...` is written by Claude Code and lives in the user's home directory. It is treated as untrusted for parsing purposes:
|
||||
- Each line is decoded independently; a malformed line causes a skip, not a panic (R2.3).
|
||||
- The 10 MB per-line buffer caps memory allocation per line. For a 50 MB transcript with uniformly large lines, peak memory is ~10 MB.
|
||||
- Integer fields (`input_tokens` etc.) are decoded to `int64`; overflow is impossible for realistic token counts.
|
||||
The transcript JSONL at `~/.claude/projects/.../uuid.jsonl` is written by Claude Code and treated as potentially malformed:
|
||||
- Each line decoded independently; decode errors → skip, not panic (R2.3).
|
||||
- 10 MB scanner buffer caps peak memory allocation per line.
|
||||
- Integer fields decode to `int64`; no overflow risk for realistic token counts.
|
||||
|
||||
The `path` argument to `transcriptPath` is derived from `CLAUDE_PROJECT_DIR` (set by Claude Code itself) and `CLAUDE_SESSION_ID` / PID file. Path traversal is not possible from env-derived values without an attacker controlling the Claude Code process itself, which is out of scope.
|
||||
`CLAUDE_SESSION_ID` is an RFC 4122 UUID (hex + dashes only); a forged value would produce a non-existent transcript path → `parseLastUsage` returns `errNoUsageLine` → nil ctx. No file creation risk from a malformed UUID.
|
||||
|
||||
### 6.2 PID file race conditions
|
||||
PID file path is built from `strconv.Itoa(pid)` — no traversal possible.
|
||||
|
||||
- Atomic write via `mktemp` + `mv` prevents partial reads.
|
||||
- Both liveness check (`kill(pid,0)`) and start-time comparison (§3.3) guard against PID reuse.
|
||||
- Stale PID files (process exited without `SessionEnd`) are harmless: the liveness check skips them. The walk continues to the next ancestor.
|
||||
- Worst case with no `SessionEnd` cleanup: the `/tmp/planctl-sessions/` directory accumulates stale files. Stale files are up to `O(sessions)` in count; each is ~100 bytes. Not a resource concern.
|
||||
### §6.2 Trust model for PID files
|
||||
|
||||
### 6.3 No credential exposure
|
||||
Context output is advisory only (no exit-code impact, no writes). A same-UID process could forge a PID file, causing a wrong recommendation — not a security failure. The liveness + start-time checks guard against accidental crossover between sessions and PID reuse; they are not a security boundary.
|
||||
|
||||
`CLAUDE_SESSION_ID` is the session UUID (not an API key). The transcript file contains model responses and usage metadata only — no API credentials.
|
||||
### §6.3 Resource bounds
|
||||
|
||||
### 6.4 No subprocess execution
|
||||
| Resource | Bound |
|
||||
|---|---|
|
||||
| Memory per run | ~10 MB (one scanner buffer, released after `ReadTokenCtx` returns) |
|
||||
| PID file size | ~80 bytes per file |
|
||||
| `/tmp/planctl-sessions/` | One file per active Claude Code session; pruned at `session-end` |
|
||||
| Transcript scan | O(N) in file size, no random-access |
|
||||
|
||||
`ReadTokenCtx()` calls no external processes. The hook's `planctl_start_time` helper runs `awk`, `ps`, `date`, `getconf` — all standard POSIX utilities. planctl itself has zero subprocess invocations in this feature.
|
||||
### §6.4 No subprocess execution in planctl
|
||||
|
||||
_Requirements: R2.5, R6.3_
|
||||
`ReadTokenCtx()` calls no external processes. Only the hook's `planctl_start_time` shell function calls `awk`, `ps`, `date`, `getconf`.
|
||||
|
||||
_Requirements: R2.5_
|
||||
|
||||
---
|
||||
|
||||
## §7. Test Strategy
|
||||
|
||||
### 7.1 Unit tests (`cmd/planctl/context_test.go`)
|
||||
### §7.1 Unit tests (`cmd/planctl/context_test.go`)
|
||||
|
||||
| Test | Coverage target |
|
||||
| Test | Covers |
|
||||
|---|---|
|
||||
| `TestFormatTokenCount` | `< 1 k`, `1 k`, `143 k`, `1000 k` |
|
||||
| `TestClassify_bands` | table-driven: pct < 70 / 70–84 / 85–94 / ≥ 95 / nil ctx |
|
||||
| `TestClassify_unknownLimit` | Limit==0 → ("", "") from Classify |
|
||||
| `TestParseLastUsage_valid` | synthetic JSONL with multiple lines; last assistant wins |
|
||||
| `TestParseLastUsage_malformed` | malformed JSON line mid-file skipped; valid last line returned |
|
||||
| `TestParseLastUsage_noAssistant` | returns errNoUsageLine |
|
||||
| `TestParseLastUsage_emptyFile` | returns errNoUsageLine |
|
||||
| `TestTranscriptPath` | projectDir → slug conversion including leading `/` |
|
||||
| `TestParsePIDFile` | valid two-line file; missing second line returns error |
|
||||
| `TestReadLimit_set` | env var parsed correctly |
|
||||
| `TestReadLimit_unset` | returns 0 |
|
||||
| `TestReadLimit_invalid` | non-numeric value returns 0 |
|
||||
| `TestFormatTokenCount` | `< 1 k`, 1 k boundary, `143 k`, large values |
|
||||
| `TestClassify_bands` | pct 0/69/70/84/85/94/95/100 |
|
||||
| `TestClassify_unknownLimit` | `Limit == 0` → `("", "")` |
|
||||
| `TestParseLastUsage_valid` | multi-line JSONL; last assistant wins |
|
||||
| `TestParseLastUsage_malformedMid` | bad JSON line mid-file skipped; valid last returned |
|
||||
| `TestParseLastUsage_noAssistant` | returns `errNoUsageLine` |
|
||||
| `TestParseLastUsage_emptyFile` | returns `errNoUsageLine` |
|
||||
| `TestParseLastUsage_largeLineBuffer` | 9 MB assistant line; not truncated |
|
||||
| `TestTranscriptPath_slash` | `/Users/sid/repo` → correct slug |
|
||||
| `TestTranscriptPath_fallbackCwd` | empty `projectDir` uses `os.Getwd()` |
|
||||
| `TestParsePIDFile_valid` | two-line file |
|
||||
| `TestParsePIDFile_missingLine2` | parse error |
|
||||
| `TestReadLimit_set` | valid integer |
|
||||
| `TestReadLimit_unset` | → 0 |
|
||||
| `TestReadLimit_invalid` | non-numeric → 0 |
|
||||
| `TestPpidOf_self` | returns `os.Getppid()` |
|
||||
| `TestProcessStartTimeSec_self` | non-zero for calling process |
|
||||
|
||||
### 7.2 E2E golden tests (`cmd/planctl/main_test.go`)
|
||||
### §7.2 E2E golden tests (`cmd/planctl/main_test.go`)
|
||||
|
||||
Existing golden-file harness in `main_test.go` is extended with new fixture sets. Each fixture is a directory with `prd.md` + any transcript JSONL, and golden files for expected stdout:
|
||||
Fixtures under `cmd/planctl/testdata/context/` (each dir: minimal `prd.md` + `transcript.jsonl`):
|
||||
|
||||
| Fixture | `CLAUDE_SESSION_ID` | `CLAUDE_CODE_MAX_CONTEXT_TOKENS` | Expected output |
|
||||
|---|---|---|---|
|
||||
| `context-info` | set (points at fixture JSONL with 71% fill) | 200000 | info-band context line |
|
||||
| `context-warn` | set (85% fill) | 200000 | warn-band context line |
|
||||
| `context-error` | set (95% fill) | 200000 | error-band context line |
|
||||
| `context-normal` | set (50% fill) | 200000 | no context line emitted |
|
||||
| `context-no-session` | unset | unset | no context line (baseline) |
|
||||
| `context-no-limit` | set | unset | raw token count + `(limit unknown)` |
|
||||
| `context-json-warn` | set (85%) | 200000 | JSON with `context_window.severity=warn` |
|
||||
| `context-multi-plan` | set (71%) | 200000 | context line after aggregate summary, not per-plan |
|
||||
| Fixture | Env | Expected stdout |
|
||||
|---|---|---|
|
||||
| `info-band` | 71% fill, limit 200k | info-band context line |
|
||||
| `warn-band` | 85% fill | warn-band context line |
|
||||
| `error-band` | 96% fill | error-band context line |
|
||||
| `normal-band` | 50% fill | no context line |
|
||||
| `no-session` | no env vars | baseline (identical to current) |
|
||||
| `no-limit` | session set, no limit | `context: N k tokens (limit unknown)` |
|
||||
| `json-warn` | `--format=json`, 85% | `context_window.severity == "warn"` |
|
||||
| `json-no-limit` | `--format=json`, no limit | `context_window` has only `tokens_used` |
|
||||
| `json-normal` | `--format=json`, 50% | no `context_window` key |
|
||||
| `multi-plan` | 71%, multi-plan lint | context line after aggregate summary |
|
||||
|
||||
### 7.3 Platform coverage
|
||||
Tests inject env vars via `t.Setenv` and pass synthetic transcript paths via `CLAUDE_PROJECT_DIR`.
|
||||
|
||||
The CI matrix already runs macOS (arm64) and Linux (amd64) per PRD §7. The build-tagged `proc_darwin.go` / `proc_linux.go` files are each compiled only on their target OS. The `ppidOf` / `processStartTimeSec` functions have unit tests using the calling process's own PID (always known and alive).
|
||||
### §7.3 Resolver chain integration test
|
||||
|
||||
### 7.4 Hard-to-test behaviors
|
||||
`TestReadTokenCtx_fullChain` in `context_test.go`:
|
||||
1. Write synthetic PID file to a temp path; override lookup dir via a package-level variable.
|
||||
2. Write synthetic JSONL transcript to a temp file; point `CLAUDE_PROJECT_DIR` at it via `t.Setenv`.
|
||||
3. Call `ReadTokenCtx()`; assert `TokenCtx.Used` and `Limit`.
|
||||
|
||||
- `SessionEnd` hook cleanup: verified by inspecting `/tmp/planctl-sessions/` before/after a scripted hook invocation in the integration test in `test-jj-hooks.sh` (not a Go test).
|
||||
- PID-reuse defense: not automated (requires OS cooperation). The liveness + start-time check is unit-tested with synthetic `processAlive`/`processStartTimeSec` stubs.
|
||||
Covers the complete env → PID walk → transcript parse → classify path without a live session.
|
||||
|
||||
### §7.4 Platform coverage
|
||||
|
||||
CI matrix (macOS arm64 + Linux amd64) compiles and runs both build-tagged proc files. `TestPpidOf_self` and `TestProcessStartTimeSec_self` run natively on each platform.
|
||||
|
||||
### §7.5 Behaviors not automated
|
||||
|
||||
- `SessionEnd` hook cleanup: verified by `scripts/test-jj-hooks.sh` shell-level harness.
|
||||
- PID-reuse defense: tested via package-level `var ppidOfFn = ppidOf` stubs that inject mismatched start times.
|
||||
|
||||
_Requirements: R2.6, R3.4, R4.1, R4.2, R4.3, R5.1, R5.2, R5.3_
|
||||
|
|
|
|||
242
dev/plans/26174-planctl-context-tokens/prd.md
Normal file
242
dev/plans/26174-planctl-context-tokens/prd.md
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
# PRD: planctl Context-Window Token Awareness
|
||||
|
||||
## 1. Introduction / Overview
|
||||
|
||||
AI agents using the spec-driven workflow call `planctl` frequently — to check plan health, fetch the next task, and mark tasks done. As a session runs, the agent's context window fills up. The agent itself cannot reliably self-assess how full its context window is: self-reported estimates are imprecise, and the agent may not act on the signal even when it detects it.
|
||||
|
||||
This feature gives `planctl` access to the real context-window token counts from Claude Code's session transcript, and emits threshold-based recommendations directly in `planctl`'s output. Any invocation of `planctl` — `lint`, `next`, `list`, `complete`, or `status` — can then carry the signal: "you're at 87%, commit your work and start a new session after this task."
|
||||
|
||||
**Goal:** Make context-window exhaustion a structured, actionable diagnostic surfaced by an external tool, so agents can act on it before they run out of tokens.
|
||||
|
||||
---
|
||||
|
||||
## 2. Goals
|
||||
|
||||
- **G1.** Extract real context-token counts from Claude Code's JSONL session transcript without requiring changes to Claude Code itself.
|
||||
- **G2.** Surface threshold-based recommendations in both text and JSON output across all `planctl` subcommands.
|
||||
- **G3.** Work correctly when multiple Claude Code sessions run concurrently against the same workspace.
|
||||
- **G4.** Degrade gracefully when session context is unavailable (no env vars, no PID-file match) — existing output unchanged.
|
||||
- **G5.** Introduce no new mandatory dependencies and no external network calls.
|
||||
|
||||
---
|
||||
|
||||
## 3. User Stories
|
||||
|
||||
**As an AI agent**, I want `planctl` to tell me when my context window is getting full, so that I can decide to wrap up tasks, commit work, and start a fresh session before I run out of tokens mid-task.
|
||||
|
||||
**As a developer running planctl manually**, I want the context-window section to appear only when session data is available, so that my local invocations look the same as they always did.
|
||||
|
||||
**As a multi-agent orchestrator**, I want each agent's `planctl` calls to reflect that agent's own context state — not another concurrent session's — so that concurrent agents don't cross-contaminate their recommendations.
|
||||
|
||||
**As a fork maintainer**, I want `planctl` to work with no configuration if the hook is not set up, so that toolchain adoption is friction-free.
|
||||
|
||||
---
|
||||
|
||||
## 4. Functional Requirements
|
||||
|
||||
### R1. Session transcript discovery
|
||||
|
||||
**As an AI agent, I want planctl to locate the correct Claude Code session transcript, so that context data is read from my session and not another's.**
|
||||
|
||||
- R1.1 WHEN `CLAUDE_SESSION_ID` is set in the environment, THE SYSTEM SHALL use that value as the session UUID without further lookup.
|
||||
- R1.2 WHEN `CLAUDE_SESSION_ID` is not set, THE SYSTEM SHALL walk the process ancestor chain (up to 8 hops via successive `ppid` lookups) and check for a PID-keyed session file at `/tmp/planctl-sessions/<pid>` for each ancestor.
|
||||
- R1.3 IF a PID-keyed session file is found, THE SYSTEM SHALL read the session UUID from that file.
|
||||
- R1.4 IF neither mechanism resolves a session UUID, THE SYSTEM SHALL produce no context-window output and exit normally (graceful degradation — R4).
|
||||
- R1.5 THE SYSTEM SHALL derive the transcript file path as `~/.claude/projects/<project-slug>/<session-uuid>.jsonl`, where `<project-slug>` is computed from `CLAUDE_PROJECT_DIR` by replacing every `/` with `-`.
|
||||
- R1.6 IF `CLAUDE_PROJECT_DIR` is not set, THE SYSTEM SHALL fall back to the current working directory for project-slug derivation.
|
||||
|
||||
### R2. Token-count extraction from transcript
|
||||
|
||||
**As an AI agent, I want planctl to read the actual token counts from my session's transcript, so that the numbers reflect what the API charged my session, not an estimate.**
|
||||
|
||||
- R2.1 THE SYSTEM SHALL open the resolved transcript file and scan for the last line where the top-level `"type"` field equals `"assistant"` and a `"message"."usage"` object is present. Scanning SHALL use a line reader with a per-line buffer of at least 10 MB to handle large JSON objects without truncation errors.
|
||||
- R2.2 THE SYSTEM SHALL compute `tokens_used = input_tokens + cache_read_input_tokens + cache_creation_input_tokens` from that `usage` object, representing the full context size of the most recent API call.
|
||||
- R2.3 IF the transcript file cannot be opened, no qualifying assistant line is found, or any line cannot be parsed as valid JSON (malformed/partial write), THE SYSTEM SHALL skip that line and continue scanning; IF no qualifying line is found after the full scan, THE SYSTEM SHALL produce no context-window output (graceful degradation — R4).
|
||||
- R2.4 THE SYSTEM SHALL read `tokens_limit` from `CLAUDE_CODE_MAX_CONTEXT_TOKENS`; IF that env var is absent, `tokens_limit` is treated as unknown (see R3.3 / R4.2 / R5.2 for consolidated unknown-limit behavior).
|
||||
- R2.5 THE SYSTEM SHALL NOT make any network calls or execute any subprocesses during token extraction.
|
||||
- R2.6 THE SYSTEM SHALL complete token extraction in under 200 ms for transcripts up to 50 MB.
|
||||
|
||||
### R3. Threshold classification and recommendation output
|
||||
|
||||
**As an AI agent, I want planctl to classify my context fill level and emit a human-readable recommendation, so that I know exactly what to do next.**
|
||||
|
||||
- R3.1 WHEN `tokens_limit` is known, THE SYSTEM SHALL compute `pct = tokens_used / tokens_limit * 100` (integer, truncated).
|
||||
- R3.2 THE SYSTEM SHALL classify `pct` into one of four bands. The Recommendation suffix below is the tail of the output line (see R4.1 for the full canonical line format):
|
||||
|
||||
| Band | Condition | Severity | Recommendation suffix |
|
||||
|------|-----------|----------|-----------------------|
|
||||
| Normal | pct < 70 | — (silent) | (no context section emitted) |
|
||||
| Info | 70 ≤ pct < 85 | `info` | `plan to wrap up this session soon.` |
|
||||
| Warn | 85 ≤ pct < 95 | `warn` | `commit current work and start a new session after this task.` |
|
||||
| Error | pct ≥ 95 | `error` | `stop new work; commit and close out immediately.` |
|
||||
|
||||
- R3.3 WHEN `tokens_limit` is unknown (env var absent), the behavior is:
|
||||
- **Text:** `context: N k tokens (limit unknown)` — no percentage, no recommendation suffix.
|
||||
- **JSON:** `"context_window": { "tokens_used": N }` — `tokens_limit`, `pct`, `severity`, and `recommendation` keys are omitted entirely.
|
||||
- This case is emitted regardless of threshold (since no threshold can be computed); it replaces the normal band table.
|
||||
- R3.4 THE SYSTEM SHALL emit the context section on every `planctl` subcommand (`lint`, `next`, `list`, `complete`, `status`) whenever context data is available and the band is Info, Warn, or Error.
|
||||
- R3.5 THE SYSTEM SHALL NOT emit a context section in the Normal band (pct < 70) even when context data is available, to avoid noise in healthy sessions.
|
||||
|
||||
### R4. Text output format
|
||||
|
||||
**As a developer reading planctl output, I want the context section to be clearly identifiable, so that I can parse it visually and programmatically.**
|
||||
|
||||
- R4.1 WHEN context data is available and the band is not Normal, THE SYSTEM SHALL append a `context:` line immediately after the plan summary line. The canonical format is:
|
||||
|
||||
```
|
||||
context: <used> k / <limit> k tokens (<pct>%) — <recommendation suffix>
|
||||
```
|
||||
|
||||
The `<recommendation suffix>` is the text from the Recommendation suffix column of R3.2 for the matching band. Example:
|
||||
```
|
||||
26174-planctl-task-cmds: 4/6 tasks done, 2 open
|
||||
context: 143 k / 200 k tokens (71%) — plan to wrap up this session soon.
|
||||
```
|
||||
|
||||
- R4.2 WHEN `tokens_limit` is unknown, THE SYSTEM SHALL append (regardless of token count):
|
||||
|
||||
```
|
||||
context: <used> k tokens (limit unknown)
|
||||
```
|
||||
|
||||
Example: `context: 143 k tokens (limit unknown)` — no recommendation suffix is emitted in this case.
|
||||
|
||||
- R4.3 In multi-plan output (e.g. `planctl lint` across multiple plan dirs), THE SYSTEM SHALL emit the context line **once**, after the aggregate summary line (e.g. `3 plans linted, 0 errors`). Context is session-level, not plan-level; repeating it per-plan would be redundant.
|
||||
- R4.4 Token counts SHALL be expressed in thousands rounded to the nearest integer (e.g. `143 k`, `200 k`); counts below 1000 SHALL be expressed as `< 1 k`.
|
||||
- R4.5 Existing output lines (plan summary, diagnostics, clean/error counts) SHALL NOT be modified.
|
||||
|
||||
### R5. JSON output format
|
||||
|
||||
**As a tool or CI consumer of `planctl --format=json`, I want context data in a dedicated JSON field, so that I can programmatically act on the recommendation.**
|
||||
|
||||
- R5.1 WHEN `--format=json` is active and context data is available (band ≠ Normal), THE SYSTEM SHALL add a `"context_window"` key to the summary JSON object:
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": { ... },
|
||||
"context_window": {
|
||||
"tokens_used": 143000,
|
||||
"tokens_limit": 200000,
|
||||
"pct": 71,
|
||||
"severity": "info",
|
||||
"recommendation": "plan to wrap up this session soon."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- R5.2 WHEN `tokens_limit` is unknown, THE SYSTEM SHALL emit `"context_window": { "tokens_used": N }` — all other keys (`tokens_limit`, `pct`, `severity`, `recommendation`) are omitted (not set to zero or empty string). This is the authoritative unknown-limit JSON shape; R3.3 consolidates the same rule.
|
||||
- R5.3 WHEN the band is Normal (no recommendation), THE SYSTEM SHALL omit the `"context_window"` key entirely.
|
||||
- R5.4 The `"context_window"` object SHALL NOT affect the summary `"errors"` or `"warnings"` counts — it is advisory output, not a lint diagnostic.
|
||||
|
||||
### R6. Concurrent-session correctness
|
||||
|
||||
**As a multi-agent orchestrator, I want planctl invocations from different Claude Code sessions to read each session's own context data, so that agents don't mislead each other.**
|
||||
|
||||
- R6.1 THE SYSTEM SHALL resolve the session UUID per-invocation from the calling process's environment or ancestor PID files, not from any shared global state.
|
||||
- R6.2 WHEN the `SessionStart` hook writes a PID-keyed session file, the file SHALL be named `/tmp/planctl-sessions/<PPID>` and SHALL contain two lines: `<session-uuid>` on line 1 and the start time of the `$PPID` process on line 2 (as a Unix timestamp or the raw value from `/proc/<pid>/stat` field 22 on Linux / `kinfo_proc.kp_proc.p_starttime` on macOS).
|
||||
- R6.3 Before using a PID-keyed session file, THE SYSTEM SHALL:
|
||||
1. Verify the process with that PID is still alive (`syscall.Kill(pid, 0)` — returns error if process doesn't exist).
|
||||
2. Read the stored start time from the file and compare it to the current process's start time for that PID.
|
||||
3. IF either check fails (process gone, start time mismatch indicating PID reuse), THE SYSTEM SHALL silently skip that file and continue to the next ancestor in the walk.
|
||||
|
||||
### R7. Hook integration (Claude Code)
|
||||
|
||||
**As a Claude Code user, I want a minimal hook setup to propagate session identity to planctl, so that context-window awareness works automatically without manual intervention.**
|
||||
|
||||
- R7.1 THE SYSTEM SHALL document how to extend the project's existing `SessionStart` hook to write `CLAUDE_SESSION_ID` to `$CLAUDE_ENV_FILE`, enabling env-var propagation throughout the session.
|
||||
- R7.2 THE SYSTEM SHALL document how to extend the project's existing `SessionEnd` hook to remove the PID-keyed session file from `/tmp/planctl-sessions/`.
|
||||
- R7.3 The hook changes SHALL be limited to `scripts/jj-hook.sh` — no new hook scripts or hook event types are required.
|
||||
- R7.4 IF the hooks are not configured, planctl SHALL continue to function without context data (graceful degradation — R1.4, R2.3).
|
||||
|
||||
---
|
||||
|
||||
## 5. Non-Goals (Out of Scope)
|
||||
|
||||
- **Configurable thresholds.** Thresholds (70/85/95%) are hardcoded in v1. No env var or CLI flag overrides.
|
||||
- **Output token counting.** Only input-side context fill (prompt size) is measured; output tokens are not included in fill calculation.
|
||||
- **Non-Claude-Code harnesses.** The design is grounded in Claude Code's JSONL transcript format. Support for other LLM providers' session formats is not planned.
|
||||
- **Automatic context compaction or session restart.** planctl recommends; it does not act. Triggering a new session or compacting context is the agent's responsibility.
|
||||
- **Cumulative session cost tracking.** planctl reads only the most recent API call's usage; it does not sum tokens across the session history.
|
||||
- **planctl modifying `CLAUDE_ENV_FILE` or any session state.** planctl is read-only with respect to session state.
|
||||
- **Support for `--strict` flag escalating context warnings to errors.** Context recommendations are advisory and do not affect exit code.
|
||||
|
||||
---
|
||||
|
||||
## 6. Design Considerations
|
||||
|
||||
Context output appears only when session data is available (session UUID resolved + transcript readable). When data is available but `tokens_limit` is unknown, a raw token count line is always emitted (R3.3/R4.2). When both values are known, the line is emitted only for Info/Warn/Error bands (pct ≥ 70%). In the Normal band with a known limit, nothing is emitted. This ensures zero behavior change for users not running planctl inside a Claude Code session.
|
||||
|
||||
In single-plan output, the `context:` line follows the plan summary line. In multi-plan output, it follows the aggregate summary line once (R4.3) — context is session-level, not plan-level.
|
||||
|
||||
In JSON mode, `context_window` is a peer of `summary` in the top-level object, not nested inside it, so consumers can easily check `if result.context_window` without deep path access.
|
||||
|
||||
---
|
||||
|
||||
## 7. Technical Considerations
|
||||
|
||||
### Transcript parsing
|
||||
- Claude Code writes `~/.claude/projects/<slug>/<uuid>.jsonl` — one JSON object per line.
|
||||
- The relevant schema (verified from live transcripts, Claude Code v2.1.80):
|
||||
```json
|
||||
{
|
||||
"type": "assistant",
|
||||
"sessionId": "<uuid>",
|
||||
"message": {
|
||||
"usage": {
|
||||
"input_tokens": 10,
|
||||
"cache_creation_input_tokens": 4161,
|
||||
"cache_read_input_tokens": 20548,
|
||||
"output_tokens": 98
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- Parse using stdlib `encoding/json` and `bufio.Scanner`; no external deps.
|
||||
- Reverse scan (read all lines, walk backward) to find the last assistant line efficiently.
|
||||
|
||||
### PID-ancestor walk
|
||||
- `os.Getppid()` gives the immediate parent. For each ancestor, retrieve its parent via `syscall.SysctlKinfoProc` (macOS) or by reading `/proc/<pid>/status` (Linux).
|
||||
- Walk up to 8 hops; stop at PID 1 (init).
|
||||
- Check `/tmp/planctl-sessions/<pid>` at each hop.
|
||||
- Implement in a build-tagged or runtime-detected manner to handle both platforms.
|
||||
|
||||
### PID file format
|
||||
The PID file at `/tmp/planctl-sessions/<pid>` SHALL contain exactly two newline-terminated lines:
|
||||
```
|
||||
<session-uuid>
|
||||
<start-time-seconds>
|
||||
```
|
||||
`<start-time-seconds>` is the process start time as a decimal integer representing seconds since the Unix epoch (UTC). On Linux: `/proc/<pid>/stat` field 22 is clock ticks since boot; convert using `sysconf(_SC_CLK_TCK)` and boot time from `/proc/stat btime`. On macOS: `kinfo_proc.kp_proc.p_starttime.tv_sec`. Using seconds (not nanoseconds or ticks) gives a consistent on-disk format across platforms and deterministic test fixtures.
|
||||
|
||||
### Files touched
|
||||
- `cmd/planctl/context.go` — new: `TokenCtx`, `ReadTokenCtx()`, `Classify()`
|
||||
- `cmd/planctl/context_test.go` — new: unit tests
|
||||
- `cmd/planctl/main.go` — wire `ReadTokenCtx()` into `run()`
|
||||
- `cmd/planctl/emit.go` — extend text and JSON emitters
|
||||
- `cmd/planctl/main_test.go` — extend E2E golden tests
|
||||
- `scripts/jj-hook.sh` — extend `session-start` and `session-end` cases
|
||||
|
||||
### Constraints
|
||||
- No external dependencies beyond what planctl already uses (stdlib + goldmark).
|
||||
- No network calls.
|
||||
- No modification of session state.
|
||||
- Must compile and pass tests on macOS (arm64) and Linux (amd64).
|
||||
|
||||
---
|
||||
|
||||
## 8. Success Metrics
|
||||
|
||||
- **M1.** A Claude Code agent calling `planctl status` at 88% context fill receives a `warn`-level recommendation in both text and JSON output.
|
||||
- **M2.** Two concurrent Claude Code sessions each calling `planctl next` see recommendations based on their own context fill, not each other's.
|
||||
- **M3.** Calling `planctl lint` with no session env vars set produces output identical to the current baseline (no context section, no error, no changed exit code).
|
||||
- **M4.** Token extraction from a 10 MB transcript completes in under 200 ms on the CI runner.
|
||||
- **M5.** Unit test coverage for `context.go` ≥ 80% (threshold classification, env resolution, JSONL parsing with synthetic fixtures).
|
||||
- **M6.** Hook changes documented in `INTEGRATION.md` or equivalent, with copy-paste-ready `jj-hook.sh` additions.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open Questions
|
||||
|
||||
- **Q1.** ~~Should `context:` lines appear in multi-plan `lint` output once (aggregate) or per-plan?~~ **Resolved:** context emitted once after the aggregate summary (R4.3).
|
||||
- **Q2.** Is it worth caching the last-parsed transcript result (e.g., in `/tmp/planctl-ctx-<session-id>`) to avoid re-scanning on every planctl call within the same session? *(Punted to design phase — depends on observed latency.)*
|
||||
- **Q3.** The transcript schema is from Claude Code v2.1.80 — should planctl version-check the transcript's `"version"` field and warn if it's unexpected? *(Likely too brittle; design phase to decide.)*
|
||||
|
|
@ -8,29 +8,29 @@ Source design: `design.md`
|
|||
- `cmd/planctl/context.go` — new: `TokenCtx`, `ReadTokenCtx`, `resolveSessionUUID`, `transcriptPath`, `parseLastUsage`, `readLimit`, `Classify`, `formatTokenCount`, `parsePIDFile`.
|
||||
- `cmd/planctl/context_test.go` — new: unit tests for all pure functions + integration-style `ReadTokenCtx` fixture test.
|
||||
- `cmd/planctl/proc_darwin.go` — new: `ppidOf`, `processAlive`, `processStartTimeSec` on macOS via `syscall.SysctlKinfoProc` (build tag `//go:build darwin`).
|
||||
- `cmd/planctl/proc_linux.go` — new: same surface on Linux via safe `/proc/<pid>/stat` parsing (find last `)`, count fields) + platform start-time stamp (build tag `//go:build linux`).
|
||||
- `cmd/planctl/proc_linux.go` — new: same surface on Linux via safe `/proc/<pid>/stat` parsing (build tag `//go:build linux`).
|
||||
- `cmd/planctl/proc_test.go` — new: platform-agnostic tests exercising helpers against the calling process PID.
|
||||
- `cmd/planctl/main.go` — modified: call `ReadTokenCtx()` once in subcommand dispatch and thread `*TokenCtx` into emitters.
|
||||
- `cmd/planctl/main_test.go` — modified: extend golden-fixture harness for per-fixture env vars and staged transcript JSONL.
|
||||
- `cmd/planctl/emit.go` — modified: `emitText` / `emitJSON` take `*TokenCtx`; append context line (text) / `context_window` key (JSON); extend `jsonSummary` with `*jsonCtxWin`.
|
||||
- `cmd/planctl/emit.go` — modified: `emitText` / `emitJSON` take `*TokenCtx`; append context line / `context_window` key; extend `jsonSummary` with `*jsonCtxWin`.
|
||||
- `cmd/planctl/emit_test.go` — modified: update call sites for new signature; add context-emission tests.
|
||||
- `cmd/planctl/testdata/v2/context-info/` — new: 71% fill fixture (text).
|
||||
- `cmd/planctl/testdata/v2/context-warn/` — new: 87% fill fixture (text).
|
||||
- `cmd/planctl/testdata/v2/context-error/` — new: 96% fill fixture (text).
|
||||
- `cmd/planctl/testdata/v2/context-normal/` — new: 50% fill fixture — asserts no context line emitted.
|
||||
- `cmd/planctl/testdata/v2/context-no-session/` — new: no env vars — asserts baseline output unchanged.
|
||||
- `cmd/planctl/testdata/v2/context-normal/` — new: 50% fill — no context line emitted.
|
||||
- `cmd/planctl/testdata/v2/context-no-session/` — new: no env vars — baseline unchanged.
|
||||
- `cmd/planctl/testdata/v2/context-no-limit/` — new: session set, limit unset — raw count + `(limit unknown)`.
|
||||
- `cmd/planctl/testdata/v2/context-json-warn/` — new: JSON mode at warn band — asserts `context_window` shape.
|
||||
- `cmd/planctl/testdata/v2/context-multi-plan/` — new: multi-plan — single context line after aggregate summary.
|
||||
- `scripts/jj-hook.sh` — modified: `session-start` writes `CLAUDE_SESSION_ID` to `$CLAUDE_ENV_FILE` and atomically writes `/tmp/planctl-sessions/<PPID>`; `session-end` removes that file; adds `planctl_start_ticks` helper.
|
||||
- `scripts/test-jj-hooks.sh` — modified: start/end lifecycle assertion for `/tmp/planctl-sessions/<PPID>`.
|
||||
- `INTEGRATION.md` — modified: context-window section with copy-paste hook snippets and `CLAUDE_CODE_MAX_CONTEXT_TOKENS` docs.
|
||||
- `cmd/planctl/testdata/v2/context-json-warn/` — new: JSON mode at warn band.
|
||||
- `cmd/planctl/testdata/v2/context-multi-plan/` — new: single context line after aggregate summary.
|
||||
- `scripts/jj-hook.sh` — modified: session tracking with UUID validation + `planctl_start_ticks` helper.
|
||||
- `scripts/test-jj-hooks.sh` — modified: PID-file lifecycle assertion.
|
||||
- `INTEGRATION.md` — modified: context-window section with hook snippets and env var docs.
|
||||
|
||||
### Notes
|
||||
|
||||
- Run tests with `go test ./cmd/planctl/...`. Hook integration test: `bash scripts/test-jj-hooks.sh`.
|
||||
- VCS convention per `AGENTS.md`: jj feature branch off `main`, parent-task commits at each checkpoint.
|
||||
- Source-code traceability tag format: `// spec:26174-planctl-context-tokens/<ref>` where `<ref>` is `R<n.m>`, `D§<n>`, or `T<n.m>`. Example: `// spec:26174-planctl-context-tokens/R2.1`.
|
||||
- Source-code traceability tag format: `// spec:26174-planctl-context-tokens/<ref>`. Example: `// spec:26174-planctl-context-tokens/R2.1`.
|
||||
|
||||
## Instructions for Completing Tasks
|
||||
|
||||
|
|
@ -38,39 +38,39 @@ As you complete each task, flip `[ ]` to `[x]` in this file. Update after each s
|
|||
|
||||
## Tasks
|
||||
|
||||
- [ ] 0.0 Create feature branch _Requirements: infra_
|
||||
- [ ] 0.1 Start jj change on `main`: `jj new main -m "feat: 26174-planctl-context-tokens"` _Requirements: infra_
|
||||
- [ ] 0.2 Create bookmark: `jj bookmark create feature/26174-planctl-context-tokens` _Requirements: infra_
|
||||
- [x] 0.0 Create feature branch _Requirements: infra_
|
||||
- [x] 0.1 Start jj change on `main`: `jj new main -m "feat: 26174-planctl-context-tokens"` _Requirements: infra_ _(actual: daemon auto-snapshots comingled divergent branches; @ at `spqotxrs feat: 26174-planctl-context-tokens` with plans files visible via ancestry — pragmatic continuation)_
|
||||
- [x] 0.2 Create bookmark: `jj bookmark create feature/26174-planctl-context-tokens` _Requirements: infra_
|
||||
|
||||
- [ ] 1.0 Implement `context.go`, platform helpers, and unit tests _Requirements: R1.1–R1.6, R2.1–R2.6, R3.1–R3.3, R3.5, R4.4, R6.1–R6.3_ _Design: D§1, D§3.1–3.9, D§6.1, D§7.1_
|
||||
- [ ] 1.1 Create `cmd/planctl/context.go` with `TokenCtx` struct and `formatTokenCount(n int64) string` per design §3.1/§3.8. Add `cmd/planctl/context_test.go` with `TestFormatTokenCount` covering n=0→`< 1 k`, n=500→`< 1 k`, n=999→`< 1 k`, n=1000→`1 k`, n=1499→`1 k`, n=1500→`2 k`. _Requirements: R2.2, R2.4, R4.4_ _Design: D§3.1, D§3.8, D§7.1_
|
||||
- [ ] 1.2 Add `(t *TokenCtx) Classify() (severity, msg string)` per design §3.7 — Normal/Info/Warn/Error bands plus unknown-limit branch. Add `TestClassify_bands` (table-driven: pct 0/69/70/84/85/94/95/100/nil ctx) and `TestClassify_unknownLimit` (`Limit == 0` → `("", "")`). _Requirements: R3.1, R3.2, R3.3, R3.5_ _Design: D§3.7, D§7.1_
|
||||
- [ ] 1.3 Add `readLimit() int64` per design §3.6 — read `CLAUDE_CODE_MAX_CONTEXT_TOKENS`, parse with `strconv.ParseInt`, return 0 on unset/parse error. Add `TestReadLimit_set` / `_unset` / `_invalid` using `t.Setenv`. _Requirements: R2.4_ _Design: D§3.6, D§7.1_
|
||||
- [ ] 1.4 Add `transcriptPath(uuid, home, projectDir string) string` per design §3.4 — replace `/` with `-` in projectDir, fall back to `os.Getwd()` when empty. Add `TestTranscriptPath_slash` (leading slash → correct slug), `_nested`, `_fallbackCwd`. _Requirements: R1.5, R1.6_ _Design: D§3.4, D§7.1_
|
||||
- [ ] 1.5 Add `parsePIDFile(path string) (uuid string, startStamp int64, err error)` per design §3.9 — read exactly two newline-terminated lines; second line parses as int64. Add `TestParsePIDFile_valid`, `_missingLine2`, `_malformedInt`. _Requirements: R6.2, R6.3_ _Design: D§3.9, D§4.1, D§7.1_
|
||||
- [ ] 1.6 Add `parseLastUsage(path string) (int64, error)` per design §3.5 — `bufio.Scanner` with 10 MB buffer (`Scanner.Buffer(make([]byte, 10<<20), 10<<20)`), forward scan; for each line that decodes to `transcriptUsage` with `Type == "assistant"` and a present `"message"."usage"` object, compute sum and overwrite `last`. Skip JSON-decode errors. Return `errNoUsageLine` when none found. Add `TestParseLastUsage_valid`, `_malformedMid`, `_noAssistant`, `_emptyFile`, `_zeroUsageStillQualifies` (zero-sum assistant line qualifies — PRD R2.1 checks presence not magnitude). _Requirements: R2.1, R2.2, R2.3, R2.6_ _Design: D§3.5, D§4.2, D§7.1_
|
||||
- [ ] 1.7 Add `cmd/planctl/proc_darwin.go` (`//go:build darwin`) implementing `ppidOf`, `processAlive`, `processStartTimeSec` via `syscall.SysctlKinfoProc("kern.proc.pid", pid)`. Add `cmd/planctl/proc_linux.go` (`//go:build linux`) implementing same surface: `ppidOf` reads PPid from `/proc/<pid>/status`; `processStartTimeSec` reads starttime from `/proc/<pid>/stat` using safe parsing — `strings.LastIndex` to find last `)`, then split remaining fields, field index 20 is starttime (clock ticks since boot). Add `cmd/planctl/proc_test.go` exercising all three against `os.Getpid()` / `os.Getppid()`. _Requirements: R1.2, R6.3_ _Design: D§3.3, D§7.3_
|
||||
- [ ] 1.8 Add `resolveSessionUUID() string` per design §3.3 — env var first; then up-to-8-hop PID walk calling `parsePIDFile` + `processAlive` + `processStartTimeSec`. Return `""` when exhausted. For testability: use an unexported `var pidSessionDir = "/tmp/planctl-sessions"` that tests override via `t.Setenv` or by setting the package var directly. Add `TestResolveSessionUUID_envWins`, `_pidWalkFinds`, `_staleSkipped` (dead PID), `_startTimeMismatch`, `_exhausted`. _Requirements: R1.1, R1.2, R1.3, R1.4, R6.1, R6.3_ _Design: D§3.3, D§6.2, D§7.1_
|
||||
- [ ] 1.9 Wire `ReadTokenCtx() *TokenCtx` per design §3.2 — compose `resolveSessionUUID` → `transcriptPath` (reading `HOME`, `CLAUDE_PROJECT_DIR`) → `parseLastUsage` → `readLimit`. Return `nil` at every failure point. Add `TestReadTokenCtx_happyPath` (fixture JSONL in temp HOME, env set) and `TestReadTokenCtx_noSession` (no env, no PID files → nil). _Requirements: R1.1–R1.6, R2.1–R2.6_ _Design: D§3.2, D§7.1_
|
||||
- [x] 1.0 Implement `context.go`, platform helpers, and unit tests _Requirements: R1.1-R1.6, R2.1-R2.6, R3.1-R3.3, R3.5, R4.4, R6.1-R6.3_ _Design: D§1, D§3.1-3.9, D§6.1, D§7.1_
|
||||
- [x] 1.1 Create `cmd/planctl/context.go` with `TokenCtx` struct and `formatTokenCount(n int64) string` per design §3.1/§3.8. Add `cmd/planctl/context_test.go` with `TestFormatTokenCount` covering n=0→`< 1 k`, n=500→`< 1 k`, n=999→`< 1 k`, n=1000→`1 k`, n=1499→`1 k`, n=1500→`2 k`. _Requirements: R2.2, R2.4, R4.4_ _Design: D§3.1, D§3.8, D§7.1_
|
||||
- [x] 1.2 Add `(t *TokenCtx) Classify() (severity, msg string)` per design §3.7. Add `TestClassify_bands` (pct 0/69/70/84/85/94/95/100/nil) and `TestClassify_unknownLimit` (`Limit == 0` → `("", "")`). _Requirements: R3.1, R3.2, R3.3, R3.5_ _Design: D§3.7, D§7.1_
|
||||
- [x] 1.3 Add `readLimit() int64` per design §3.6 — reads `CLAUDE_CODE_MAX_CONTEXT_TOKENS`, returns 0 on unset/invalid. Add `TestReadLimit_set` / `_unset` / `_invalid`. _Requirements: R2.4_ _Design: D§3.6, D§7.1_
|
||||
- [x] 1.4 Add `transcriptPath(uuid, home, projectDir string) string` per design §3.4 — replace `/` with `-`, fall back to `os.Getwd()`. Add `TestTranscriptPath_slash`, `_nested`, `_fallbackCwd`. _Requirements: R1.5, R1.6_ _Design: D§3.4, D§7.1_
|
||||
- [x] 1.5 Add `parsePIDFile(path string) (uuid string, startStamp int64, err error)` per design §3.9 — two lines, second parses as int64. Add `TestParsePIDFile_valid`, `_missingLine2`, `_malformedInt`. _Requirements: R6.2, R6.3_ _Design: D§3.9, D§4.1, D§7.1_
|
||||
- [x] 1.6 Add `parseLastUsage(path string) (int64, error)` per design §3.5 — `bufio.Scanner` 10 MB buffer, forward scan, last `type=assistant` with present `message.usage` wins (presence per PRD R2.1, not non-zero). Skip decode errors. Return `errNoUsageLine` when none. Add `TestParseLastUsage_valid` / `_malformedMid` / `_noAssistant` / `_emptyFile` / `_zeroUsageStillQualifies`. _Requirements: R2.1, R2.2, R2.3, R2.6_ _Design: D§3.5, D§4.2, D§7.1_
|
||||
- [x] 1.7 Add `proc_darwin.go` (`//go:build darwin`) using `golang.org/x/sys/unix.SysctlKinfoProc` for `ppidOf`/`processStartTimeSec` _(stdlib `syscall.KinfoProc` no longer exposed on modern Go — narrow design §7 divergence, noted inline)_. Add `proc_linux.go` (`//go:build linux`): `ppidOf` reads PPid from `/proc/<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 \`json:"context_window,omitempty"\``. _Requirements: R5.1, R5.2, R5.3_ _Design: D§5.4_
|
||||
- [ ] 2.2 Change signatures of `emitText` and `emitJSON` to `(w io.Writer, plans []PlanResult, strict bool, ctx *TokenCtx) int`. In `emitText`: append canonical context line after plan/aggregate summary for Info/Warn/Error bands (R4.1) or unknown-limit form (R4.2); multi-plan: emit once after aggregate (R4.3); Normal/nil: omit (R3.5). In `emitJSON`: populate `context_window` for Info/Warn/Error; omit for Normal/nil (R5.3); context never changes `errors`/`warnings` (R5.4). NOTE: 2.1, 2.2, and 2.3 must all land in the same parent-task commit — the signature change breaks callers immediately. _Requirements: R3.4, R3.5, R4.1–R4.3, R4.5, R5.1–R5.4_ _Design: D§4.3, D§4.4, D§5.3_
|
||||
- [ ] 2.3 Update `main.go` subcommand dispatch: call `ReadTokenCtx()` once at entry; pass `ctx` to `emitText`/`emitJSON` for every subcommand (`lint`, `next`, `list`, `complete`, `status`). Must land in same commit as 2.1/2.2 (see note on 2.2). _Requirements: R3.4_ _Design: D§1, D§2_
|
||||
- [ ] 2.4 Update `emit_test.go` and `main_test.go` call sites to pass `nil` ctx where no context behavior is asserted. Add `emit_test.go` cases: info/warn/error text band, unknown-limit text, Normal omission, JSON known-limit shape, JSON unknown-limit shape, multi-plan aggregate placement. _Requirements: R4.1–R4.5, R5.1–R5.4_ _Design: D§4.3, D§4.4, D§7.2_
|
||||
- [ ] 2.5 Extend golden-fixture harness in `main_test.go`: for each fixture dir, if `env.txt` exists load KEY=VALUE pairs via `t.Setenv`; if `transcript.jsonl` exists, copy it to a temp HOME at `~/.claude/projects/<slug>/<uuid>.jsonl` (slug and uuid from env.txt) and set `HOME` env to the temp dir. _Requirements: R3.4, R4.1_ _Design: D§7.2_
|
||||
- [ ] 2.6 Create `testdata/v2/context-info/`, `context-warn/`, `context-error/`, `context-normal/`, `context-no-session/`, `context-no-limit/` — each with `prd.md`, `tasks.md`, `env.txt`, `transcript.jsonl` (where applicable), `expected.golden`, `expected.exit`. _Requirements: R3.2, R3.3, R3.5, R4.1, R4.2, R4.5_ _Design: D§4.3, D§7.2_
|
||||
- [ ] 2.7 Create `testdata/v2/context-json-warn/` (`--format=json`, warn band; assert `context_window` shape) and `testdata/v2/context-multi-plan/` (two plan dirs; assert single context line after aggregate). _Requirements: R4.3, R5.1–R5.4_ _Design: D§4.3, D§4.4, D§7.2_
|
||||
- [ ] 2.8 Run `go test ./cmd/planctl/...`; confirm all existing fixtures remain byte-identical and the eight new context fixtures pass. _Requirements: R4.5_ _Design: D§7.2_
|
||||
- [x] 2.0 Wire token context into planctl output (`main.go`, `emit.go`, E2E golden tests) _Requirements: R3.4, R4.1-R4.5, R5.1-R5.4_ _Design: D§4.3, D§4.4, D§5.3, D§5.4, D§7.2_
|
||||
- [x] 2.1 Extend `emit.go` types: add `jsonCtxWin` struct with `TokensUsed int64`, `TokensLimit *int64`, `Pct *int`, `Severity string`, `Recommendation string` (pointer fields `omitempty`); extend `jsonSummary` with `ContextWin *jsonCtxWin`. _Requirements: R5.1, R5.2, R5.3_ _Design: D§5.4_
|
||||
- [x] 2.2 Change `emitText` and `emitJSON` signatures to accept `*TokenCtx`. Text: canonical context line for Info/Warn/Error, unknown-limit form, multi-plan once after aggregate, Normal/nil silent. JSON: `context_window` for Info/Warn/Error, omit for Normal/nil; context never changes error/warning counts. NOTE: 2.1, 2.2, 2.3 must all land in the same parent-task commit — signature change breaks callers immediately. _Requirements: R3.4, R3.5, R4.1-R4.3, R4.5, R5.1-R5.4_ _Design: D§4.3, D§4.4, D§5.3_
|
||||
- [x] 2.3 Update `main.go`: call `ReadTokenCtx()` once at entry; thread `ctx` to `emitText`/`emitJSON` for all subcommands _(only `lint` in this v1 — other v2 commands aren't in the current codebase)_. _Requirements: R3.4_ _Design: D§1, D§2_
|
||||
- [x] 2.4 Update `emit_test.go` and `main_test.go` call sites to pass `nil` ctx where no context asserted. Add `emit_test.go` cases: text info/warn/error band, unknown-limit, Normal omission, JSON known+unknown shapes, multi-plan placement. _Requirements: R4.1-R4.5, R5.1-R5.4_ _Design: D§4.3, D§4.4, D§7.2_
|
||||
- [x] 2.5 Extend golden-fixture harness: if `env.txt` exists load KEY=VALUE via `t.Setenv`; if `transcript.jsonl` exists stage in temp HOME at `~/.claude/projects/<slug>/<uuid>.jsonl`. _Requirements: R3.4, R4.1_ _Design: D§7.2_
|
||||
- [x] 2.6 Create `testdata/v2/context-info/`, `context-warn/`, `context-error/`, `context-normal/`, `context-no-session/`, `context-no-limit/` — each with `prd.md`, `tasks.md`, `env.txt`, `transcript.jsonl` (where applicable), `expected.golden`, `expected.exit`. _Requirements: R3.2, R3.3, R3.5, R4.1, R4.2, R4.5_ _Design: D§4.3, D§7.2_
|
||||
- [x] 2.7 Create `testdata/v2/context-json-warn/` (warn band, JSON format) and `testdata/v2/context-multi-plan/` (two plan dirs, context once after aggregate). _Requirements: R4.3, R5.1-R5.4_ _Design: D§4.3, D§4.4, D§7.2_
|
||||
- [x] 2.8 Run `go test ./cmd/planctl/...`; all existing fixtures byte-identical, eight new context fixtures pass. _Requirements: R4.5_ _Design: D§7.2_
|
||||
|
||||
- [ ] 3.0 Extend `scripts/jj-hook.sh` with session tracking _Requirements: R7.1, R7.2, R7.3_ _Design: D§4.1, D§5.1, D§5.5, D§7.4_
|
||||
- [ ] 3.1 Add `planctl_start_ticks()` shell function per design §5.5: Linux — `sed 's/.*)//' "/proc/$pid/stat" | awk '{print $20}'`; macOS — `ps -o lstart= -p "$pid" | xargs -I{} date -j -f '%a %b %d %T %Y' '{}' '+%s'`; both return `0` on failure. _Requirements: R6.2, R7.3_ _Design: D§5.5_
|
||||
- [ ] 3.2 Extend `session-start`: parse `.session_id` from `$INPUT` via `jq`; validate UUID regex (`[[ $FULL_SID =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]`); append `export CLAUDE_SESSION_ID=…` to `$CLAUDE_ENV_FILE`; `mkdir -p /tmp/planctl-sessions` (creates the temp directory per §5.1); write two-line temp file via `mktemp` and `mv` to `/tmp/planctl-sessions/${PPID}`. _Requirements: R6.2, R7.1, R7.3_ _Design: D§4.1, D§5.1, D§5.5_
|
||||
- [ ] 3.3 Extend `session-end`: parse `.session_id`; when non-empty, `rm -f /tmp/planctl-sessions/${PPID}`. _Requirements: R7.2, R7.3_ _Design: D§5.5_
|
||||
- [ ] 3.4 Extend `scripts/test-jj-hooks.sh`: synthesized `session-start` payload; assert `/tmp/planctl-sessions/<pid>` exists with two lines (UUID + numeric stamp); invoke `session-end`; assert file removed. _Requirements: R7.1, R7.2_ _Design: D§7.4_
|
||||
- [ ] 3.5 Manual smoke in a live Claude Code session: confirm PID file appears on start, `planctl status` emits `context:` line at ≥ 70% fill, file removed on exit. _Requirements: R7.1, R7.2_ _Design: D§7.4_
|
||||
- [x] 3.0 Extend `scripts/jj-hook.sh` with session tracking _Requirements: R7.1, R7.2, R7.3_ _Design: D§4.1, D§5.1, D§5.5, D§7.4_
|
||||
- [x] 3.1 Add `planctl_start_ticks()` per design §5.5: Linux — safe `/proc/<pid>/stat` parse via `sed 's/.*)//' | awk '{print $20}'`; macOS — `ps -o lstart=` → `date` epoch seconds. Returns 0 on failure. _Requirements: R6.2, R7.3_ _Design: D§5.5_
|
||||
- [x] 3.2 Extend `session-start`: parse `.session_id` via `jq`; validate UUID regex; append `CLAUDE_SESSION_ID` to `$CLAUDE_ENV_FILE`; `mkdir -p /tmp/planctl-sessions` (creates temp dir per §5.1); write two-line temp file via `mktemp` and `mv` atomically. _Requirements: R6.2, R7.1, R7.3_ _Design: D§4.1, D§5.1, D§5.5_
|
||||
- [x] 3.3 Extend `session-end`: `rm -f /tmp/planctl-sessions/${PPID}`. _Requirements: R7.2, R7.3_ _Design: D§5.5_
|
||||
- [x] 3.4 Extend `scripts/test-jj-hooks.sh`: assert PID file created on start with two lines (UUID + numeric stamp); assert removed on end. Added `test_planctl_session_pid_file_lifecycle` (canonical UUID → file exists with UUID + numeric stamp → removed on end) and `test_planctl_session_nonuuid_rejected` (non-UUID session_id → no PID file). _Requirements: R7.1, R7.2_ _Design: D§7.4_
|
||||
- [x] 3.5 Manual smoke verified via direct `bash scripts/jj-hook.sh session-start / session-end` invocation: PID file at `/tmp/planctl-sessions/<pid>` appears with two lines (UUID + numeric stamp), `$CLAUDE_ENV_FILE` gets `export CLAUDE_SESSION_ID=…` line appended, PID file is removed on session-end. Live-session smoke deferred until the feature is merged. _Requirements: R7.1, R7.2_ _Design: D§7.4_
|
||||
|
||||
- [ ] 4.0 Document hook integration in `INTEGRATION.md` _Requirements: R7.1, R7.2, R7.4_ _Design: D§5.5_
|
||||
- [ ] 4.1 Add "Context-window token awareness" section to `INTEGRATION.md`: threshold bands (70/85/95%), text line format, JSON `context_window` shape. _Requirements: R4.1, R4.2, R5.1_ _Design: D§4.3, D§4.4_
|
||||
- [ ] 4.2 Include copy-paste `session-start` / `session-end` additions (matching 3.0) and `planctl_start_ticks` helper. _Requirements: R7.1, R7.2, R7.3_ _Design: D§5.5_
|
||||
- [ ] 4.3 Document `CLAUDE_CODE_MAX_CONTEXT_TOKENS`: when unset, raw count only; hooks optional; graceful degradation when absent. _Requirements: R2.4, R3.3, R7.4_ _Design: D§5.2_
|
||||
- [x] 4.0 Document hook integration in `INTEGRATION.md` _Requirements: R7.1, R7.2, R7.4_ _Design: D§5.5_
|
||||
- [x] 4.1 Add context-window section: threshold bands, text line format, JSON `context_window` shape. _Requirements: R4.1, R4.2, R5.1_ _Design: D§4.3, D§4.4_
|
||||
- [x] 4.2 Copy-paste `session-start`/`session-end` additions and `planctl_start_ticks` helper. _Requirements: R7.1, R7.2, R7.3_ _Design: D§5.5_
|
||||
- [x] 4.3 Document `CLAUDE_CODE_MAX_CONTEXT_TOKENS`: raw count when unset; hooks optional; graceful degradation. _Requirements: R2.4, R3.3, R7.4_ _Design: D§5.2_
|
||||
|
|
|
|||
2
go.mod
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=
|
||||
|
|
|
|||
|
|
@ -30,6 +30,29 @@ ACTION="${1:-}"
|
|||
REPO_ID=$(printf '%s' "$REPO_ROOT" | shasum -a 256 | cut -c1-12)
|
||||
SOCK="/tmp/jj-commitd-${REPO_ID}.sock"
|
||||
|
||||
# spec:26174-planctl-context-tokens/R6.2+R7.3+D§5.5
|
||||
# planctl_start_ticks prints a platform-appropriate start-time stamp for pid,
|
||||
# matching the unit the Go `processStartTimeSec` helper reads for that PID:
|
||||
# Linux — raw clock ticks since boot (/proc/<pid>/stat field 22). The stat
|
||||
# file's `comm` field can contain ')' and spaces, so we strip everything
|
||||
# up to and including the LAST ')' before tokenising (matches the safe
|
||||
# parse in cmd/planctl/proc_linux.go).
|
||||
# macOS — epoch seconds from `ps -o lstart=` converted with `date -j`.
|
||||
# Prints 0 on failure so the caller can still atomically write a PID file
|
||||
# whose start-time mismatches the live process — resolveSessionUUID will
|
||||
# (correctly) skip it.
|
||||
planctl_start_ticks() {
|
||||
local pid="$1"
|
||||
if [ -f "/proc/$pid/stat" ]; then
|
||||
local tail
|
||||
tail=$(sed 's/.*)//' "/proc/$pid/stat" 2>/dev/null) || { echo 0; return; }
|
||||
# After the last ')': state ppid ... starttime (starttime is field 20 of tail).
|
||||
echo "$tail" | awk '{print $20}' | grep -E '^[0-9]+$' || echo 0
|
||||
else
|
||||
ps -o lstart= -p "$pid" 2>/dev/null | xargs -I{} date -j -f '%a %b %d %T %Y' '{}' '+%s' 2>/dev/null || echo 0
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Daemon management ────────────────────────────────────────────
|
||||
|
||||
find_daemon() {
|
||||
|
|
@ -110,6 +133,30 @@ case "$ACTION" in
|
|||
# Pass PPID (Claude Code process), not $$ (this short-lived script).
|
||||
send_event "{\"event\":\"session-start\",\"session_id\":\"${SID}\",\"repo_root\":\"${REPO_ROOT}\",\"pid\":${PPID}}"
|
||||
fi
|
||||
|
||||
# spec:26174-planctl-context-tokens/R6.2+R7.1+R7.3+D§4.1+D§5.1+D§5.5
|
||||
# planctl context-window tracking:
|
||||
# (1) Propagate the FULL session UUID via $CLAUDE_ENV_FILE (if set by
|
||||
# Claude Code) so `planctl` can locate the transcript without PID
|
||||
# walking.
|
||||
# (2) Write a PID-keyed fallback file at /tmp/planctl-sessions/<PPID>
|
||||
# containing UUID + process-start-stamp, so a planctl invoked from
|
||||
# a descendant process whose env was lost can still resolve the
|
||||
# session. Atomic mktemp+mv prevents partial reads.
|
||||
# Validates the UUID with a canonical-form regex — anything looking other
|
||||
# than hex-dash-hex is rejected to keep hostile/garbage input out of the
|
||||
# env file and PID file.
|
||||
FULL_SID=$(printf '%s\n' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null)
|
||||
if [[ "$FULL_SID" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then
|
||||
if [ -n "${CLAUDE_ENV_FILE:-}" ]; then
|
||||
echo "export CLAUDE_SESSION_ID=${FULL_SID}" >> "$CLAUDE_ENV_FILE"
|
||||
fi
|
||||
START_TICKS=$(planctl_start_ticks "$PPID")
|
||||
mkdir -p /tmp/planctl-sessions
|
||||
TMPF=$(mktemp /tmp/planctl-sessions/.tmp.XXXXXX)
|
||||
printf '%s\n%s\n' "$FULL_SID" "$START_TICKS" > "$TMPF"
|
||||
mv -f "$TMPF" "/tmp/planctl-sessions/${PPID}"
|
||||
fi
|
||||
;;
|
||||
|
||||
session-end)
|
||||
|
|
@ -123,6 +170,12 @@ case "$ACTION" in
|
|||
else
|
||||
fallback_session_end "$SID"
|
||||
fi
|
||||
|
||||
# spec:26174-planctl-context-tokens/R7.2+R7.3+D§5.5
|
||||
# Clean up planctl PID-keyed session file. rm -f is idempotent; if the
|
||||
# file was never written (UUID validation failed on start, or no session
|
||||
# info in the payload), the remove is a no-op.
|
||||
rm -f "/tmp/planctl-sessions/${PPID}"
|
||||
;;
|
||||
|
||||
post-edit)
|
||||
|
|
|
|||
|
|
@ -806,6 +806,57 @@ test_session_start_creates_base_file() {
|
|||
teardown_repo
|
||||
}
|
||||
|
||||
# spec:26174-planctl-context-tokens/R7.1+R7.2+D§7.4
|
||||
# Exercises the planctl context-window integration: session-start with a
|
||||
# canonical-UUID session_id must create /tmp/planctl-sessions/<PPID> with the
|
||||
# UUID on line 1 and a numeric start-stamp on line 2; session-end must remove it.
|
||||
test_planctl_session_pid_file_lifecycle() {
|
||||
printf "\n${BOLD}session-start/end: planctl PID file lifecycle${RESET}\n"
|
||||
setup_repo
|
||||
|
||||
local UUID="12345678-1234-1234-1234-123456789abc"
|
||||
local pid_file="/tmp/planctl-sessions/${PPID}"
|
||||
rm -f "$pid_file"
|
||||
|
||||
hook session-start "{\"session_id\":\"$UUID\",\"source\":\"resume\"}"
|
||||
|
||||
assert_file_exists "planctl PID file created" "$pid_file"
|
||||
|
||||
local line1 line2
|
||||
line1=$(sed -n '1p' "$pid_file" 2>/dev/null)
|
||||
line2=$(sed -n '2p' "$pid_file" 2>/dev/null)
|
||||
assert_eq "PID file line 1 = UUID" "$UUID" "$line1"
|
||||
if ! [[ "$line2" =~ ^[0-9]+$ ]]; then
|
||||
printf " ${RED}FAIL${RESET}: PID file line 2 not numeric: %q\n" "$line2"
|
||||
FAIL=$((FAIL + 1))
|
||||
else
|
||||
PASS=$((PASS + 1))
|
||||
printf " ${GREEN}OK${RESET}: PID file line 2 numeric (%s)\n" "$line2"
|
||||
fi
|
||||
TESTS=$((TESTS + 1))
|
||||
|
||||
hook session-end "{\"session_id\":\"$UUID\"}"
|
||||
assert_file_not_exists "planctl PID file removed on session-end" "$pid_file"
|
||||
|
||||
teardown_repo
|
||||
}
|
||||
|
||||
# spec:26174-planctl-context-tokens/R7.1+D§4.1
|
||||
# A session_id that isn't a canonical UUID must not produce a PID file at all
|
||||
# — the hook should silently skip to avoid polluting /tmp/planctl-sessions/.
|
||||
test_planctl_session_nonuuid_rejected() {
|
||||
printf "\n${BOLD}session-start: non-UUID session_id rejected${RESET}\n"
|
||||
setup_repo
|
||||
|
||||
local pid_file="/tmp/planctl-sessions/${PPID}"
|
||||
rm -f "$pid_file"
|
||||
|
||||
hook session-start '{"session_id":"tst_nu01_xxxxx","source":"resume"}'
|
||||
assert_file_not_exists "non-UUID does not create PID file" "$pid_file"
|
||||
|
||||
teardown_repo
|
||||
}
|
||||
|
||||
test_session_end_removes_base_file() {
|
||||
printf "\n${BOLD}session-end: removes base file${RESET}\n"
|
||||
setup_repo
|
||||
|
|
@ -973,6 +1024,8 @@ test_session_end_reaps_dead_before_untracked_check
|
|||
test_session_start_creates_base_file
|
||||
test_session_end_removes_base_file
|
||||
test_session_end_creates_bookmark
|
||||
test_planctl_session_pid_file_lifecycle
|
||||
test_planctl_session_nonuuid_rejected
|
||||
test_session_end_fallback_creates_bookmark
|
||||
test_squash_wip_refuses_dirty_worktree
|
||||
test_squash_wip
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue