feat(planctl): context-window token awareness — wiring (parent 2.0)

* emit.go: emitText/emitJSON take *TokenCtx; jsonSummary extended with *jsonCtxWin (omitempty) for context_window in JSON output
* main.go: ReadTokenCtx() called once at top of run() before subcommand dispatch; threaded through runLint
* main_test.go: golden-fixture harness extended for env.txt + transcript.jsonl staging in temp HOME
* testdata/v2/context-{info,warn,error,normal,no-session,no-limit,json-warn,multi-plan}: 8 new fixtures exercising all bands + shapes

Task 2.0 from dev/plans/26174-planctl-context-tokens/prd.md
This commit is contained in:
sid 2026-04-23 17:51:00 -06:00
parent 935b86b791
commit b02ae7dade
56 changed files with 479 additions and 30 deletions

View file

@ -38,7 +38,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 {
@ -52,9 +56,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.
@ -92,7 +120,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 {
@ -108,14 +141,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.
@ -138,8 +201,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

View file

@ -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,12 +236,172 @@ 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)
}
})
}
}
// 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())
}
}

View file

@ -55,9 +55,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 "list", "next", "complete", "new-plan":
// spec:planctl/R6.1
fmt.Fprintf(stderr, "planctl: subcommand %q is not implemented in v1; planned for a future version\n", subcommand)
@ -78,7 +85,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())
@ -104,9 +111,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

View file

@ -390,6 +390,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()
@ -428,6 +430,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

View file

@ -0,0 +1,3 @@
CLAUDE_SESSION_ID=fixture-uuid
CLAUDE_PROJECT_DIR=/fixture/project
CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000

View file

@ -0,0 +1 @@
0

View 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.

View file

@ -0,0 +1,6 @@
# PRD — context fixture
## 4. Functional Requirements
### R1. Greet
- R1.1 THE SYSTEM SHALL greet the user on startup.

View file

@ -0,0 +1,3 @@
# Tasks — context fixture
- [ ] 1.0 Implement greet _Requirements: R1.1_

View 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}}}

View file

@ -0,0 +1,3 @@
CLAUDE_SESSION_ID=fixture-uuid
CLAUDE_PROJECT_DIR=/fixture/project
CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000

View file

@ -0,0 +1 @@
0

View 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.

View file

@ -0,0 +1,6 @@
# PRD — context fixture
## 4. Functional Requirements
### R1. Greet
- R1.1 THE SYSTEM SHALL greet the user on startup.

View file

@ -0,0 +1,3 @@
# Tasks — context fixture
- [ ] 1.0 Implement greet _Requirements: R1.1_

View 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}}}

View file

@ -0,0 +1 @@
lint --format=json FIXTURE

View file

@ -0,0 +1,3 @@
CLAUDE_SESSION_ID=fixture-uuid
CLAUDE_PROJECT_DIR=/fixture/project
CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000

View file

@ -0,0 +1 @@
0

View 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."}}

View file

@ -0,0 +1,6 @@
# PRD — context-json-warn
## 4. Functional Requirements
### R1. Greet
- R1.1 THE SYSTEM SHALL greet the user on startup.

View file

@ -0,0 +1,3 @@
# Tasks — context-json-warn
- [ ] 1.0 Implement greet _Requirements: R1.1_

View 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}}}

View file

@ -0,0 +1 @@
lint

View file

@ -0,0 +1,6 @@
# PRD — alpha
## 4. Functional Requirements
### R1. Greet
- R1.1 THE SYSTEM SHALL greet the user on startup.

View file

@ -0,0 +1,3 @@
# Tasks — alpha
- [ ] 1.0 Implement greet _Requirements: R1.1_

View file

@ -0,0 +1,6 @@
# PRD — beta
## 4. Functional Requirements
### R1. Greet
- R1.1 THE SYSTEM SHALL greet the user on startup.

View file

@ -0,0 +1,3 @@
# Tasks — beta
- [ ] 1.0 Implement greet _Requirements: R1.1_

View file

@ -0,0 +1,3 @@
CLAUDE_SESSION_ID=fixture-uuid
CLAUDE_PROJECT_DIR=/fixture/project
CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000

View file

@ -0,0 +1 @@
0

View 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.

View 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}}}

View file

@ -0,0 +1,2 @@
CLAUDE_SESSION_ID=fixture-uuid
CLAUDE_PROJECT_DIR=/fixture/project

View file

@ -0,0 +1 @@
0

View file

@ -0,0 +1,2 @@
context-no-limit: clean (1 tasks, 1 requirements, 0 design sections)
context: 143 k tokens (limit unknown)

View file

@ -0,0 +1,6 @@
# PRD — context fixture
## 4. Functional Requirements
### R1. Greet
- R1.1 THE SYSTEM SHALL greet the user on startup.

View file

@ -0,0 +1,3 @@
# Tasks — context fixture
- [ ] 1.0 Implement greet _Requirements: R1.1_

View 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}}}

View file

@ -0,0 +1 @@
0

View file

@ -0,0 +1 @@
context-no-session: clean (1 tasks, 1 requirements, 0 design sections)

View file

@ -0,0 +1,6 @@
# PRD — context fixture
## 4. Functional Requirements
### R1. Greet
- R1.1 THE SYSTEM SHALL greet the user on startup.

View file

@ -0,0 +1,3 @@
# Tasks — context fixture
- [ ] 1.0 Implement greet _Requirements: R1.1_

View file

@ -0,0 +1,3 @@
CLAUDE_SESSION_ID=fixture-uuid
CLAUDE_PROJECT_DIR=/fixture/project
CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000

View file

@ -0,0 +1 @@
0

View file

@ -0,0 +1 @@
context-normal: clean (1 tasks, 1 requirements, 0 design sections)

View file

@ -0,0 +1,6 @@
# PRD — context fixture
## 4. Functional Requirements
### R1. Greet
- R1.1 THE SYSTEM SHALL greet the user on startup.

View file

@ -0,0 +1,3 @@
# Tasks — context fixture
- [ ] 1.0 Implement greet _Requirements: R1.1_

View 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}}}

View file

@ -0,0 +1,3 @@
CLAUDE_SESSION_ID=fixture-uuid
CLAUDE_PROJECT_DIR=/fixture/project
CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000

View file

@ -0,0 +1 @@
0

View 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.

View file

@ -0,0 +1,6 @@
# PRD — context fixture
## 4. Functional Requirements
### R1. Greet
- R1.1 THE SYSTEM SHALL greet the user on startup.

View file

@ -0,0 +1,3 @@
# Tasks — context fixture
- [ ] 1.0 Implement greet _Requirements: R1.1_

View 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}}}

View file

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

View file

@ -53,15 +53,15 @@ As you complete each task, flip `[ ]` to `[x]` in this file. Update after each s
- [x] 1.8 Add `resolveSessionUUID() string` per design §3.3 — env var first; then up-to-8-hop PID walk via injectable-helper inner function `resolveViaPIDWalk`. Use unexported `var pidSessionDir = "/tmp/planctl-sessions"` for testability. Add `TestResolveSessionUUID_envWins` + `TestResolveViaPIDWalk_pidWalkFinds` / `_staleSkipped` / `_startTimeMismatch` / `_exhausted`. _Requirements: R1.1, R1.2, R1.3, R1.4, R6.1, R6.3_ _Design: D§3.3, D§6.2, D§6.3, D§7.1_
- [x] 1.9 Wire `ReadTokenCtx() *TokenCtx` per design §3.2 — compose resolveSessionUUID → transcriptPath → parseLastUsage → readLimit; nil at every failure. Add `TestReadTokenCtx_happyPath` and `_noSession`. No subprocesses invoked (D§6.4). _Requirements: R1.1-R1.6, R2.1-R2.6_ _Design: D§3.2, D§6.4, D§7.1_
- [ ] 2.0 Wire token context into planctl output (`main.go`, `emit.go`, E2E golden tests) _Requirements: R3.4, R4.1-R4.5, R5.1-R5.4_ _Design: D§4.3, D§4.4, D§5.3, D§5.4, D§7.2_
- [ ] 2.1 Extend `emit.go` types: add `jsonCtxWin` struct with `TokensUsed int64`, `TokensLimit *int64`, `Pct *int`, `Severity string`, `Recommendation string` (pointer fields `omitempty`); extend `jsonSummary` with `ContextWin *jsonCtxWin`. _Requirements: R5.1, R5.2, R5.3_ _Design: D§5.4_
- [ ] 2.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_
- [ ] 2.3 Update `main.go`: call `ReadTokenCtx()` once at entry; thread `ctx` to `emitText`/`emitJSON` for all subcommands. Must land with 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 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_
- [ ] 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_
- [ ] 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/` (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_
- [ ] 2.8 Run `go test ./cmd/planctl/...`; all existing fixtures byte-identical, 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()` 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_