diff --git a/cmd/planctl/emit.go b/cmd/planctl/emit.go index 7c6d2f7..23e3be4 100644 --- a/cmd/planctl/emit.go +++ b/cmd/planctl/emit.go @@ -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 diff --git a/cmd/planctl/emit_test.go b/cmd/planctl/emit_test.go index 521967f..4ef03be 100644 --- a/cmd/planctl/emit_test.go +++ b/cmd/planctl/emit_test.go @@ -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()) + } +} diff --git a/cmd/planctl/main.go b/cmd/planctl/main.go index 8368903..9b2f945 100644 --- a/cmd/planctl/main.go +++ b/cmd/planctl/main.go @@ -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 diff --git a/cmd/planctl/main_test.go b/cmd/planctl/main_test.go index f7be8c8..29e1afe 100644 --- a/cmd/planctl/main_test.go +++ b/cmd/planctl/main_test.go @@ -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//.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 diff --git a/cmd/planctl/testdata/v2/context-error/env.txt b/cmd/planctl/testdata/v2/context-error/env.txt new file mode 100644 index 0000000..e7015f5 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-error/env.txt @@ -0,0 +1,3 @@ +CLAUDE_SESSION_ID=fixture-uuid +CLAUDE_PROJECT_DIR=/fixture/project +CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000 diff --git a/cmd/planctl/testdata/v2/context-error/expected.exit b/cmd/planctl/testdata/v2/context-error/expected.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-error/expected.exit @@ -0,0 +1 @@ +0 diff --git a/cmd/planctl/testdata/v2/context-error/expected.golden b/cmd/planctl/testdata/v2/context-error/expected.golden new file mode 100644 index 0000000..9683ab1 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-error/expected.golden @@ -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. diff --git a/cmd/planctl/testdata/v2/context-error/prd.md b/cmd/planctl/testdata/v2/context-error/prd.md new file mode 100644 index 0000000..864d61a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-error/prd.md @@ -0,0 +1,6 @@ +# PRD — context fixture + +## 4. Functional Requirements + +### R1. Greet +- R1.1 THE SYSTEM SHALL greet the user on startup. diff --git a/cmd/planctl/testdata/v2/context-error/tasks.md b/cmd/planctl/testdata/v2/context-error/tasks.md new file mode 100644 index 0000000..3746b46 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-error/tasks.md @@ -0,0 +1,3 @@ +# Tasks — context fixture + +- [ ] 1.0 Implement greet _Requirements: R1.1_ diff --git a/cmd/planctl/testdata/v2/context-error/transcript.jsonl b/cmd/planctl/testdata/v2/context-error/transcript.jsonl new file mode 100644 index 0000000..166fb31 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-error/transcript.jsonl @@ -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}}} diff --git a/cmd/planctl/testdata/v2/context-info/env.txt b/cmd/planctl/testdata/v2/context-info/env.txt new file mode 100644 index 0000000..e7015f5 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-info/env.txt @@ -0,0 +1,3 @@ +CLAUDE_SESSION_ID=fixture-uuid +CLAUDE_PROJECT_DIR=/fixture/project +CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000 diff --git a/cmd/planctl/testdata/v2/context-info/expected.exit b/cmd/planctl/testdata/v2/context-info/expected.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-info/expected.exit @@ -0,0 +1 @@ +0 diff --git a/cmd/planctl/testdata/v2/context-info/expected.golden b/cmd/planctl/testdata/v2/context-info/expected.golden new file mode 100644 index 0000000..0e7f788 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-info/expected.golden @@ -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. diff --git a/cmd/planctl/testdata/v2/context-info/prd.md b/cmd/planctl/testdata/v2/context-info/prd.md new file mode 100644 index 0000000..864d61a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-info/prd.md @@ -0,0 +1,6 @@ +# PRD — context fixture + +## 4. Functional Requirements + +### R1. Greet +- R1.1 THE SYSTEM SHALL greet the user on startup. diff --git a/cmd/planctl/testdata/v2/context-info/tasks.md b/cmd/planctl/testdata/v2/context-info/tasks.md new file mode 100644 index 0000000..3746b46 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-info/tasks.md @@ -0,0 +1,3 @@ +# Tasks — context fixture + +- [ ] 1.0 Implement greet _Requirements: R1.1_ diff --git a/cmd/planctl/testdata/v2/context-info/transcript.jsonl b/cmd/planctl/testdata/v2/context-info/transcript.jsonl new file mode 100644 index 0000000..e173836 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-info/transcript.jsonl @@ -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}}} diff --git a/cmd/planctl/testdata/v2/context-json-warn/args.txt b/cmd/planctl/testdata/v2/context-json-warn/args.txt new file mode 100644 index 0000000..484e429 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-json-warn/args.txt @@ -0,0 +1 @@ +lint --format=json FIXTURE diff --git a/cmd/planctl/testdata/v2/context-json-warn/env.txt b/cmd/planctl/testdata/v2/context-json-warn/env.txt new file mode 100644 index 0000000..e7015f5 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-json-warn/env.txt @@ -0,0 +1,3 @@ +CLAUDE_SESSION_ID=fixture-uuid +CLAUDE_PROJECT_DIR=/fixture/project +CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000 diff --git a/cmd/planctl/testdata/v2/context-json-warn/expected.exit b/cmd/planctl/testdata/v2/context-json-warn/expected.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-json-warn/expected.exit @@ -0,0 +1 @@ +0 diff --git a/cmd/planctl/testdata/v2/context-json-warn/expected.golden b/cmd/planctl/testdata/v2/context-json-warn/expected.golden new file mode 100644 index 0000000..1abadf1 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-json-warn/expected.golden @@ -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."}} diff --git a/cmd/planctl/testdata/v2/context-json-warn/prd.md b/cmd/planctl/testdata/v2/context-json-warn/prd.md new file mode 100644 index 0000000..1a7cd58 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-json-warn/prd.md @@ -0,0 +1,6 @@ +# PRD — context-json-warn + +## 4. Functional Requirements + +### R1. Greet +- R1.1 THE SYSTEM SHALL greet the user on startup. diff --git a/cmd/planctl/testdata/v2/context-json-warn/tasks.md b/cmd/planctl/testdata/v2/context-json-warn/tasks.md new file mode 100644 index 0000000..784468e --- /dev/null +++ b/cmd/planctl/testdata/v2/context-json-warn/tasks.md @@ -0,0 +1,3 @@ +# Tasks — context-json-warn + +- [ ] 1.0 Implement greet _Requirements: R1.1_ diff --git a/cmd/planctl/testdata/v2/context-json-warn/transcript.jsonl b/cmd/planctl/testdata/v2/context-json-warn/transcript.jsonl new file mode 100644 index 0000000..262a37a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-json-warn/transcript.jsonl @@ -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}}} diff --git a/cmd/planctl/testdata/v2/context-multi-plan/args.txt b/cmd/planctl/testdata/v2/context-multi-plan/args.txt new file mode 100644 index 0000000..dfea599 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-multi-plan/args.txt @@ -0,0 +1 @@ +lint diff --git a/cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26170-alpha/prd.md b/cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26170-alpha/prd.md new file mode 100644 index 0000000..3ca5b9b --- /dev/null +++ b/cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26170-alpha/prd.md @@ -0,0 +1,6 @@ +# PRD — alpha + +## 4. Functional Requirements + +### R1. Greet +- R1.1 THE SYSTEM SHALL greet the user on startup. diff --git a/cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26170-alpha/tasks.md b/cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26170-alpha/tasks.md new file mode 100644 index 0000000..2bda188 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26170-alpha/tasks.md @@ -0,0 +1,3 @@ +# Tasks — alpha + +- [ ] 1.0 Implement greet _Requirements: R1.1_ diff --git a/cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26172-beta/prd.md b/cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26172-beta/prd.md new file mode 100644 index 0000000..9d413e3 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26172-beta/prd.md @@ -0,0 +1,6 @@ +# PRD — beta + +## 4. Functional Requirements + +### R1. Greet +- R1.1 THE SYSTEM SHALL greet the user on startup. diff --git a/cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26172-beta/tasks.md b/cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26172-beta/tasks.md new file mode 100644 index 0000000..8a480d1 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-multi-plan/dev/plans/26172-beta/tasks.md @@ -0,0 +1,3 @@ +# Tasks — beta + +- [ ] 1.0 Implement greet _Requirements: R1.1_ diff --git a/cmd/planctl/testdata/v2/context-multi-plan/env.txt b/cmd/planctl/testdata/v2/context-multi-plan/env.txt new file mode 100644 index 0000000..e7015f5 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-multi-plan/env.txt @@ -0,0 +1,3 @@ +CLAUDE_SESSION_ID=fixture-uuid +CLAUDE_PROJECT_DIR=/fixture/project +CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000 diff --git a/cmd/planctl/testdata/v2/context-multi-plan/expected.exit b/cmd/planctl/testdata/v2/context-multi-plan/expected.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-multi-plan/expected.exit @@ -0,0 +1 @@ +0 diff --git a/cmd/planctl/testdata/v2/context-multi-plan/expected.golden b/cmd/planctl/testdata/v2/context-multi-plan/expected.golden new file mode 100644 index 0000000..3ef7b01 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-multi-plan/expected.golden @@ -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. diff --git a/cmd/planctl/testdata/v2/context-multi-plan/transcript.jsonl b/cmd/planctl/testdata/v2/context-multi-plan/transcript.jsonl new file mode 100644 index 0000000..e173836 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-multi-plan/transcript.jsonl @@ -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}}} diff --git a/cmd/planctl/testdata/v2/context-no-limit/env.txt b/cmd/planctl/testdata/v2/context-no-limit/env.txt new file mode 100644 index 0000000..904d9e4 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-no-limit/env.txt @@ -0,0 +1,2 @@ +CLAUDE_SESSION_ID=fixture-uuid +CLAUDE_PROJECT_DIR=/fixture/project diff --git a/cmd/planctl/testdata/v2/context-no-limit/expected.exit b/cmd/planctl/testdata/v2/context-no-limit/expected.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-no-limit/expected.exit @@ -0,0 +1 @@ +0 diff --git a/cmd/planctl/testdata/v2/context-no-limit/expected.golden b/cmd/planctl/testdata/v2/context-no-limit/expected.golden new file mode 100644 index 0000000..c13b071 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-no-limit/expected.golden @@ -0,0 +1,2 @@ +context-no-limit: clean (1 tasks, 1 requirements, 0 design sections) +context: 143 k tokens (limit unknown) diff --git a/cmd/planctl/testdata/v2/context-no-limit/prd.md b/cmd/planctl/testdata/v2/context-no-limit/prd.md new file mode 100644 index 0000000..864d61a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-no-limit/prd.md @@ -0,0 +1,6 @@ +# PRD — context fixture + +## 4. Functional Requirements + +### R1. Greet +- R1.1 THE SYSTEM SHALL greet the user on startup. diff --git a/cmd/planctl/testdata/v2/context-no-limit/tasks.md b/cmd/planctl/testdata/v2/context-no-limit/tasks.md new file mode 100644 index 0000000..3746b46 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-no-limit/tasks.md @@ -0,0 +1,3 @@ +# Tasks — context fixture + +- [ ] 1.0 Implement greet _Requirements: R1.1_ diff --git a/cmd/planctl/testdata/v2/context-no-limit/transcript.jsonl b/cmd/planctl/testdata/v2/context-no-limit/transcript.jsonl new file mode 100644 index 0000000..a98629d --- /dev/null +++ b/cmd/planctl/testdata/v2/context-no-limit/transcript.jsonl @@ -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}}} diff --git a/cmd/planctl/testdata/v2/context-no-session/expected.exit b/cmd/planctl/testdata/v2/context-no-session/expected.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-no-session/expected.exit @@ -0,0 +1 @@ +0 diff --git a/cmd/planctl/testdata/v2/context-no-session/expected.golden b/cmd/planctl/testdata/v2/context-no-session/expected.golden new file mode 100644 index 0000000..721b1d3 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-no-session/expected.golden @@ -0,0 +1 @@ +context-no-session: clean (1 tasks, 1 requirements, 0 design sections) diff --git a/cmd/planctl/testdata/v2/context-no-session/prd.md b/cmd/planctl/testdata/v2/context-no-session/prd.md new file mode 100644 index 0000000..864d61a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-no-session/prd.md @@ -0,0 +1,6 @@ +# PRD — context fixture + +## 4. Functional Requirements + +### R1. Greet +- R1.1 THE SYSTEM SHALL greet the user on startup. diff --git a/cmd/planctl/testdata/v2/context-no-session/tasks.md b/cmd/planctl/testdata/v2/context-no-session/tasks.md new file mode 100644 index 0000000..3746b46 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-no-session/tasks.md @@ -0,0 +1,3 @@ +# Tasks — context fixture + +- [ ] 1.0 Implement greet _Requirements: R1.1_ diff --git a/cmd/planctl/testdata/v2/context-normal/env.txt b/cmd/planctl/testdata/v2/context-normal/env.txt new file mode 100644 index 0000000..e7015f5 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-normal/env.txt @@ -0,0 +1,3 @@ +CLAUDE_SESSION_ID=fixture-uuid +CLAUDE_PROJECT_DIR=/fixture/project +CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000 diff --git a/cmd/planctl/testdata/v2/context-normal/expected.exit b/cmd/planctl/testdata/v2/context-normal/expected.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-normal/expected.exit @@ -0,0 +1 @@ +0 diff --git a/cmd/planctl/testdata/v2/context-normal/expected.golden b/cmd/planctl/testdata/v2/context-normal/expected.golden new file mode 100644 index 0000000..5fb6cc5 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-normal/expected.golden @@ -0,0 +1 @@ +context-normal: clean (1 tasks, 1 requirements, 0 design sections) diff --git a/cmd/planctl/testdata/v2/context-normal/prd.md b/cmd/planctl/testdata/v2/context-normal/prd.md new file mode 100644 index 0000000..864d61a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-normal/prd.md @@ -0,0 +1,6 @@ +# PRD — context fixture + +## 4. Functional Requirements + +### R1. Greet +- R1.1 THE SYSTEM SHALL greet the user on startup. diff --git a/cmd/planctl/testdata/v2/context-normal/tasks.md b/cmd/planctl/testdata/v2/context-normal/tasks.md new file mode 100644 index 0000000..3746b46 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-normal/tasks.md @@ -0,0 +1,3 @@ +# Tasks — context fixture + +- [ ] 1.0 Implement greet _Requirements: R1.1_ diff --git a/cmd/planctl/testdata/v2/context-normal/transcript.jsonl b/cmd/planctl/testdata/v2/context-normal/transcript.jsonl new file mode 100644 index 0000000..7c7e228 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-normal/transcript.jsonl @@ -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}}} diff --git a/cmd/planctl/testdata/v2/context-warn/env.txt b/cmd/planctl/testdata/v2/context-warn/env.txt new file mode 100644 index 0000000..e7015f5 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-warn/env.txt @@ -0,0 +1,3 @@ +CLAUDE_SESSION_ID=fixture-uuid +CLAUDE_PROJECT_DIR=/fixture/project +CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000 diff --git a/cmd/planctl/testdata/v2/context-warn/expected.exit b/cmd/planctl/testdata/v2/context-warn/expected.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-warn/expected.exit @@ -0,0 +1 @@ +0 diff --git a/cmd/planctl/testdata/v2/context-warn/expected.golden b/cmd/planctl/testdata/v2/context-warn/expected.golden new file mode 100644 index 0000000..dce96fc --- /dev/null +++ b/cmd/planctl/testdata/v2/context-warn/expected.golden @@ -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. diff --git a/cmd/planctl/testdata/v2/context-warn/prd.md b/cmd/planctl/testdata/v2/context-warn/prd.md new file mode 100644 index 0000000..864d61a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-warn/prd.md @@ -0,0 +1,6 @@ +# PRD — context fixture + +## 4. Functional Requirements + +### R1. Greet +- R1.1 THE SYSTEM SHALL greet the user on startup. diff --git a/cmd/planctl/testdata/v2/context-warn/tasks.md b/cmd/planctl/testdata/v2/context-warn/tasks.md new file mode 100644 index 0000000..3746b46 --- /dev/null +++ b/cmd/planctl/testdata/v2/context-warn/tasks.md @@ -0,0 +1,3 @@ +# Tasks — context fixture + +- [ ] 1.0 Implement greet _Requirements: R1.1_ diff --git a/cmd/planctl/testdata/v2/context-warn/transcript.jsonl b/cmd/planctl/testdata/v2/context-warn/transcript.jsonl new file mode 100644 index 0000000..262a37a --- /dev/null +++ b/cmd/planctl/testdata/v2/context-warn/transcript.jsonl @@ -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}}} diff --git a/dev/plans/26174-planctl-context-tokens/codex-sessions.md b/dev/plans/26174-planctl-context-tokens/codex-sessions.md index a6f5b5d..c4cedf2 100644 --- a/dev/plans/26174-planctl-context-tokens/codex-sessions.md +++ b/dev/plans/26174-planctl-context-tokens/codex-sessions.md @@ -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 diff --git a/dev/plans/26174-planctl-context-tokens/tasks.md b/dev/plans/26174-planctl-context-tokens/tasks.md index 50fc446..21cb75b 100644 --- a/dev/plans/26174-planctl-context-tokens/tasks.md +++ b/dev/plans/26174-planctl-context-tokens/tasks.md @@ -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//.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//.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//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_