diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5145101..127884e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,12 @@ Please choose versions by [Semantic Versioning](http://semver.org/).
* MINOR version when you add functionality in a backwards-compatible manner, and
* PATCH version when you make backwards-compatible bug fixes.
+## Unreleased
+
+- feat: claude: `ClaudeResult` now carries the CLI session's token counts (input, output, cache-creation, cache-read) and turn count, parsed from the terminal result event's usage summary; absent or partial usage parses as zeros without error
+- feat: metrics: `JobMetrics` gains `RecordUsage(JobUsage)`, backed by two new pre-initialized counters `agent_job_tokens_total` (label `type`: input, output, cache_read, cache_creation) and `agent_job_turns_total`; negative values are skipped instead of panicking
+- feat: metrics: exported `TokenType` newtype with `TokenTypeInput` / `TokenTypeOutput` / `TokenTypeCacheRead` / `TokenTypeCacheCreation` and the `AvailableTokenTypes` collection, so the token-label set is type-checked and pre-initialization iterates the closed set
+
## v0.79.0
- deliverer: `AgentStatusDone` with empty `NextPhase` is now an in-place save (`status: in_progress`, phase preserved) instead of terminating the task (`phase: done`, `status: completed`) — enforces the documented `Result.NextPhase` contract ("Empty means stay in current phase"). Fixes multi-step agents whose Done+ContinueToNext preflight steps marked live tasks completed mid-run (observed: github-update-go-agent planning preflight republish, ~13 min false-completed window). Applies to both the Kafka deliverer and the content generators (`applyStatusFrontmatter`), which previously clobbered phase to `done` unconditionally.
diff --git a/claude/claude-event.go b/claude/claude-event.go
index fae32bb..ccf6c8b 100644
--- a/claude/claude-event.go
+++ b/claude/claude-event.go
@@ -4,13 +4,56 @@
package claude
-import "encoding/json"
+import (
+ "encoding/json"
+ "strconv"
+)
// claudeEvent represents a single event in the Claude CLI stream-json output.
type claudeEvent struct {
- Type string `json:"type"`
- Result string `json:"result"`
- Message claudeMsg `json:"message"`
+ Type string `json:"type"`
+ Result string `json:"result"`
+ Message claudeMsg `json:"message"`
+ Usage json.RawMessage `json:"usage"`
+ NumTurns json.Number `json:"num_turns"`
+}
+
+// resultHolder safely extracts type and result from a JSON line without failing
+// on schema-level errors (e.g. a json.Number field receiving a string).
+type resultHolder struct {
+ Type string `json:"type"`
+ Result string `json:"result"`
+ NumTurns json.Number `json:"num_turns"`
+}
+
+// sessionUsage is the token and turn summary captured from the Claude CLI's
+// terminal result event. The zero value means no usage was reported and is a
+// valid, non-error outcome.
+type sessionUsage struct {
+ inputTokens int64
+ outputTokens int64
+ cacheCreationTokens int64
+ cacheReadTokens int64
+ numTurns int64
+}
+
+// numberToInt64 converts a JSON number to int64, yielding 0 when the value is
+// absent, non-integer, or otherwise unconvertible. Usage accounting is
+// best-effort telemetry: a malformed count must never fail the run.
+// It falls back to ParseFloat to handle decimal-formatted numbers like "100.0".
+func numberToInt64(n json.Number) int64 {
+ if n == "" {
+ return 0
+ }
+ v, err := n.Int64()
+ if err == nil {
+ return v
+ }
+ f, err := strconv.ParseFloat(string(n), 64)
+ if err != nil {
+ return 0
+ }
+ return int64(f)
}
type claudeMsg struct {
diff --git a/claude/claude-result.go b/claude/claude-result.go
index 0613f73..a099036 100644
--- a/claude/claude-result.go
+++ b/claude/claude-result.go
@@ -7,4 +7,15 @@ package claude
// ClaudeResult holds the parsed output from a Claude Code CLI session.
type ClaudeResult struct {
Result string `json:"result"`
+ // InputTokens is the count of fresh (non-cached) input tokens the session consumed.
+ InputTokens int64 `json:"input_tokens,omitempty"`
+ // OutputTokens is the count of output tokens the session produced.
+ OutputTokens int64 `json:"output_tokens,omitempty"`
+ // CacheCreationTokens is the count of input tokens written into the prompt cache.
+ CacheCreationTokens int64 `json:"cache_creation_tokens,omitempty"`
+ // CacheReadTokens is the count of input tokens served from the prompt cache.
+ CacheReadTokens int64 `json:"cache_read_input_tokens,omitempty"`
+ // NumTurns is the number of conversation turns the session took. Zero when the
+ // CLI reported no usage summary.
+ NumTurns int64 `json:"num_turns,omitempty"`
}
diff --git a/claude/claude-runner.go b/claude/claude-runner.go
index 865dd1c..aa6d5a3 100644
--- a/claude/claude-runner.go
+++ b/claude/claude-runner.go
@@ -58,7 +58,7 @@ func (r *claudeRunner) Run(ctx context.Context, prompt string) (*ClaudeResult, e
return nil, errors.Wrap(ctx, err, "start claude CLI")
}
- resultText, tail := scanOutput(ctx, stdoutPipe)
+ resultText, usage, tail := scanOutput(ctx, stdoutPipe)
if err := cmd.Wait(); err != nil {
var tailMsg string
@@ -74,7 +74,14 @@ func (r *claudeRunner) Run(ctx context.Context, prompt string) (*ClaudeResult, e
return nil, errors.New(ctx, "no result event found in claude CLI output")
}
- return &ClaudeResult{Result: resultText}, nil
+ return &ClaudeResult{
+ Result: resultText,
+ InputTokens: usage.inputTokens,
+ OutputTokens: usage.outputTokens,
+ CacheCreationTokens: usage.cacheCreationTokens,
+ CacheReadTokens: usage.cacheReadTokens,
+ NumTurns: usage.numTurns,
+ }, nil
}
func (r *claudeRunner) buildCommand(
@@ -137,19 +144,59 @@ func appendTail(tail []string, line []byte) []string {
return tail
}
-// scanOutput reads stream-json lines from stdout, logs events, and returns the result text and a bounded tail of all non-empty lines.
+// parseUsage extracts token counts from a raw usage JSON block and the num_turns field
+// from the parent event. Each token field is unmarshalled individually from a map so that a
+// decode error on one field does not roll back valid values from other fields.
+// Malformed values degrade to 0 without error, per the best-effort telemetry contract.
+func parseUsage(usageRaw json.RawMessage, numTurns json.Number) sessionUsage {
+ var usage sessionUsage
+ var usageMap map[string]json.RawMessage
+ if err := json.Unmarshal(usageRaw, &usageMap); err != nil {
+ return usage
+ }
+
+ var inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens json.Number
+
+ //nolint:errcheck // Intentional: malformed fields degrade to 0, which is the correct behaviour.
+ if v, ok := usageMap["input_tokens"]; ok {
+ json.Unmarshal(v, &inputTokens)
+ }
+ //nolint:errcheck
+ if v, ok := usageMap["output_tokens"]; ok {
+ json.Unmarshal(v, &outputTokens)
+ }
+ //nolint:errcheck
+ if v, ok := usageMap["cache_creation_input_tokens"]; ok {
+ json.Unmarshal(v, &cacheCreationTokens)
+ }
+ //nolint:errcheck
+ if v, ok := usageMap["cache_read_input_tokens"]; ok {
+ json.Unmarshal(v, &cacheReadTokens)
+ }
+
+ usage.inputTokens = numberToInt64(inputTokens)
+ usage.outputTokens = numberToInt64(outputTokens)
+ usage.cacheCreationTokens = numberToInt64(cacheCreationTokens)
+ usage.cacheReadTokens = numberToInt64(cacheReadTokens)
+ usage.numTurns = numberToInt64(numTurns)
+ return usage
+}
+
+// scanOutput reads stream-json lines from stdout, logs events, and returns the result
+// text, the captured usage summary, and a bounded tail of all non-empty lines.
func scanOutput(
ctx context.Context,
reader interface{ Read([]byte) (int, error) },
-) (string, []string) {
+) (string, sessionUsage, []string) {
var resultText string
+ var usage sessionUsage
var tail []string
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
for scanner.Scan() {
select {
case <-ctx.Done():
- return "", nil
+ return "", sessionUsage{}, nil
default:
}
@@ -158,13 +205,32 @@ func scanOutput(
tail = appendTail(tail, line)
+ // Two-pass unmarshal: first extract type/result safely (never fails on schema issues),
+ // then attempt full unmarshal for usage fields (may fail on e.g. json.Number receiving
+ // a string). The first pass always succeeds for syntactically valid JSON, ensuring
+ // resultText survives schema drift in the usage subtree.
+ var holder resultHolder
+ if err := json.Unmarshal(line, &holder); err != nil {
+ continue
+ }
+
+ if holder.Type == "result" && holder.Result != "" {
+ resultText = holder.Result
+ }
+
+ // Full unmarshal: may fail due to schema-level issues in usage fields.
+ // On failure we still keep the resultText captured above.
var event claudeEvent
if err := json.Unmarshal(line, &event); err != nil {
continue
}
- if event.Type == "result" && event.Result != "" {
- resultText = event.Result
+ // Usage capture is deliberately gated on the presence of a usage object, NOT
+ // on a non-empty result text: a later result event carrying fresh usage but an
+ // empty result string must update the numbers while leaving the previously
+ // captured text intact. Last usage object wins.
+ if event.Type == "result" && len(event.Usage) > 0 {
+ usage = parseUsage(event.Usage, event.NumTurns)
}
for _, c := range event.Message.Content {
@@ -176,7 +242,7 @@ func scanOutput(
}
}
}
- return resultText, tail
+ return resultText, usage, tail
}
// buildSubprocessEnv constructs the env var slice for the Claude CLI subprocess.
diff --git a/claude/claude-runner_test.go b/claude/claude-runner_test.go
index 3911fb3..a1690ea 100644
--- a/claude/claude-runner_test.go
+++ b/claude/claude-runner_test.go
@@ -280,6 +280,185 @@ exit 0
})
})
+var _ = Describe("claudeRunner usage capture", func() {
+ var ctx context.Context
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ })
+
+ // writeShim creates a temp dir, writes a "claude" shell script with the given body,
+ // prepends the dir to PATH, and registers cleanup via DeferCleanup.
+ writeShim := func(body string) {
+ shimDir := GinkgoT().TempDir()
+ shimPath := filepath.Join(shimDir, "claude")
+ script := "#!/bin/sh\n" + body
+ err := os.WriteFile(shimPath, []byte(script), 0755) //nolint:gosec
+ Expect(err).NotTo(HaveOccurred())
+ originalPath := os.Getenv("PATH")
+ DeferCleanup(func() {
+ Expect(os.Setenv("PATH", originalPath)).To(Succeed())
+ })
+ Expect(os.Setenv("PATH", shimDir+":"+originalPath)).To(Succeed())
+ }
+
+ Context("malformed usage numbers must not kill the event (schema-drift guard)", func() {
+ BeforeEach(func() {
+ writeShim(
+ `echo '{"type":"result","result":"kept-text","num_turns":"7","usage":{"input_tokens":100.0,"output_tokens":"bad","cache_read_input_tokens":50}}'
+exit 0`,
+ )
+ })
+
+ It("does not return an error", func() {
+ result, err := claude.NewClaudeRunner(claude.ClaudeRunnerConfig{}).Run(ctx, "test")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(result).NotTo(BeNil())
+ })
+
+ It("keeps the result text from the event", func() {
+ result, err := claude.NewClaudeRunner(claude.ClaudeRunnerConfig{}).Run(ctx, "test")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(result.Result).To(Equal("kept-text"))
+ })
+
+ It("captures the well-formed cache_read_input_tokens field", func() {
+ result, err := claude.NewClaudeRunner(claude.ClaudeRunnerConfig{}).Run(ctx, "test")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(result.CacheReadTokens).To(Equal(int64(50)))
+ })
+
+ It("treats unconvertible output_tokens as zero", func() {
+ result, err := claude.NewClaudeRunner(claude.ClaudeRunnerConfig{}).Run(ctx, "test")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(result.OutputTokens).To(Equal(int64(0)))
+ })
+ })
+
+ Context("full usage (AC1)", func() {
+ BeforeEach(func() {
+ writeShim(
+ `echo '{"type":"result","result":"task-output-text","num_turns":7,"usage":{"input_tokens":100,"output_tokens":200,"cache_creation_input_tokens":300,"cache_read_input_tokens":400}}'
+exit 0`,
+ )
+ })
+
+ It("returns no error", func() {
+ result, err := claude.NewClaudeRunner(claude.ClaudeRunnerConfig{}).Run(ctx, "test")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(result).NotTo(BeNil())
+ })
+
+ It("returns the result text", func() {
+ result, err := claude.NewClaudeRunner(claude.ClaudeRunnerConfig{}).Run(ctx, "test")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(result.Result).To(Equal("task-output-text"))
+ })
+
+ It("captures all five usage fields", func() {
+ result, err := claude.NewClaudeRunner(claude.ClaudeRunnerConfig{}).Run(ctx, "test")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(result.InputTokens).To(Equal(int64(100)))
+ Expect(result.OutputTokens).To(Equal(int64(200)))
+ Expect(result.CacheCreationTokens).To(Equal(int64(300)))
+ Expect(result.CacheReadTokens).To(Equal(int64(400)))
+ Expect(result.NumTurns).To(Equal(int64(7)))
+ })
+ })
+
+ Context("no usage object and no turn count (AC2)", func() {
+ BeforeEach(func() {
+ writeShim(
+ `echo '{"type":"result","result":"plain-output"}'
+exit 0`,
+ )
+ })
+
+ It("returns no error", func() {
+ result, err := claude.NewClaudeRunner(claude.ClaudeRunnerConfig{}).Run(ctx, "test")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(result).NotTo(BeNil())
+ })
+
+ It("returns the result text", func() {
+ result, err := claude.NewClaudeRunner(claude.ClaudeRunnerConfig{}).Run(ctx, "test")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(result.Result).To(Equal("plain-output"))
+ })
+
+ It("all five usage fields are zero", func() {
+ result, err := claude.NewClaudeRunner(claude.ClaudeRunnerConfig{}).Run(ctx, "test")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(result.InputTokens).To(Equal(int64(0)))
+ Expect(result.OutputTokens).To(Equal(int64(0)))
+ Expect(result.CacheCreationTokens).To(Equal(int64(0)))
+ Expect(result.CacheReadTokens).To(Equal(int64(0)))
+ Expect(result.NumTurns).To(Equal(int64(0)))
+ })
+ })
+
+ Context("partial usage fields (AC3)", func() {
+ BeforeEach(func() {
+ writeShim(
+ `echo '{"type":"result","result":"partial-output","usage":{"input_tokens":11,"cache_read_input_tokens":22}}'
+exit 0`,
+ )
+ })
+
+ It("captures only the provided fields", func() {
+ result, err := claude.NewClaudeRunner(claude.ClaudeRunnerConfig{}).Run(ctx, "test")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(result.InputTokens).To(Equal(int64(11)))
+ Expect(result.CacheReadTokens).To(Equal(int64(22)))
+ Expect(result.OutputTokens).To(Equal(int64(0)))
+ Expect(result.CacheCreationTokens).To(Equal(int64(0)))
+ Expect(result.NumTurns).To(Equal(int64(0)))
+ })
+ })
+
+ Context("two result events both carrying usage — last wins (AC4)", func() {
+ BeforeEach(func() {
+ writeShim(
+ `echo '{"type":"result","result":"first-text","num_turns":1,"usage":{"input_tokens":1,"output_tokens":2,"cache_creation_input_tokens":3,"cache_read_input_tokens":4}}'
+echo '{"type":"result","result":"second-text","num_turns":9,"usage":{"input_tokens":10,"output_tokens":20,"cache_creation_input_tokens":30,"cache_read_input_tokens":40}}'
+exit 0`,
+ )
+ })
+
+ It("result text and usage both come from the last event", func() {
+ result, err := claude.NewClaudeRunner(claude.ClaudeRunnerConfig{}).Run(ctx, "test")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(result.Result).To(Equal("second-text"))
+ Expect(result.InputTokens).To(Equal(int64(10)))
+ Expect(result.OutputTokens).To(Equal(int64(20)))
+ Expect(result.CacheCreationTokens).To(Equal(int64(30)))
+ Expect(result.CacheReadTokens).To(Equal(int64(40)))
+ Expect(result.NumTurns).To(Equal(int64(9)))
+ })
+ })
+
+ Context("usage last-wins is independent of result-text last-wins (AC5)", func() {
+ BeforeEach(func() {
+ writeShim(
+ `echo '{"type":"result","result":"kept-text","num_turns":2,"usage":{"input_tokens":5,"output_tokens":6,"cache_creation_input_tokens":7,"cache_read_input_tokens":8}}'
+echo '{"type":"result","result":"","num_turns":4,"usage":{"input_tokens":50,"output_tokens":60,"cache_creation_input_tokens":70,"cache_read_input_tokens":80}}'
+exit 0`,
+ )
+ })
+
+ It("keeps the first event result text and the second event usage", func() {
+ result, err := claude.NewClaudeRunner(claude.ClaudeRunnerConfig{}).Run(ctx, "test")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(result.Result).To(Equal("kept-text"))
+ Expect(result.InputTokens).To(Equal(int64(50)))
+ Expect(result.OutputTokens).To(Equal(int64(60)))
+ Expect(result.CacheCreationTokens).To(Equal(int64(70)))
+ Expect(result.CacheReadTokens).To(Equal(int64(80)))
+ Expect(result.NumTurns).To(Equal(int64(4)))
+ })
+ })
+})
+
var _ = Describe("claudeRunner AllowedTools buildCommand branch", func() {
var ctx context.Context
diff --git a/docs/job-metrics.md b/docs/job-metrics.md
new file mode 100644
index 0000000..e5976de
--- /dev/null
+++ b/docs/job-metrics.md
@@ -0,0 +1,21 @@
+# Job Metrics
+
+`metrics.NewJobMetrics` registers five Prometheus collector families onto a caller-owned registry. Metrics are pushed to the PushGateway under the job name derived from `metrics.BuildJobMetricsName`, so the per-agent breakdown comes from the push job name (e.g. `claude-agent` → `agent_job_claude_agent`), not from a metric label.
+
+## Metrics Reference
+
+| Metric | Type | Labels | Meaning |
+|--------|------|--------|---------|
+| `agent_job_run_total` | Counter | `status` | Total number of agent job runs by terminal status (`done`, `failed`, `needs_input`). |
+| `agent_job_last_run_timestamp_seconds` | Gauge | `status` | Unix timestamp (seconds) of the last agent job run, by terminal status. |
+| `agent_job_duration_seconds` | Histogram | — | Duration of agent job runs in seconds. |
+| `agent_job_tokens_total` | Counter | `type` | Total LLM tokens consumed by agent jobs. Label values: `input` (fresh input), `output` (generated), `cache_read` (served from prompt cache), `cache_creation` (written to prompt cache). |
+| `agent_job_turns_total` | Counter | — | Total number of conversation turns taken by agent jobs. |
+
+## Pre-initialization
+
+Every counter series is pre-initialized to zero at construction (via `.Add(0)`). This ensures `rate()` evaluates to zero rather than no-data for a process that has not yet run a job, so alerts built on these counters fire correctly from the start.
+
+## Not Recorded
+
+The CLI's cost field is deliberately not captured. Under a non-Anthropic base URL the CLI computes a cost estimate at Anthropic list pricing that does not reflect what the provider actually charges, and a wrong number in a cost dashboard is worse than no cost dashboard.
diff --git a/metrics/metrics.go b/metrics/metrics.go
index 2b6b764..3f7afc1 100644
--- a/metrics/metrics.go
+++ b/metrics/metrics.go
@@ -16,6 +16,52 @@ import (
//counterfeiter:generate -o mocks/job-metrics.go --fake-name JobMetrics . JobMetrics
+// TokenType is the value of the type label on agent_job_tokens_total. The set is
+// closed: no caller-supplied or session-supplied value ever becomes a label, so
+// the family's cardinality is fixed at len(AvailableTokenTypes) series.
+type TokenType string
+
+// String returns the label value as a plain string.
+func (t TokenType) String() string {
+ return string(t)
+}
+
+const (
+ // TokenTypeInput counts fresh (non-cached) input tokens.
+ TokenTypeInput TokenType = "input"
+ // TokenTypeOutput counts generated output tokens.
+ TokenTypeOutput TokenType = "output"
+ // TokenTypeCacheRead counts input tokens served from the prompt cache.
+ TokenTypeCacheRead TokenType = "cache_read"
+ // TokenTypeCacheCreation counts input tokens written into the prompt cache.
+ TokenTypeCacheCreation TokenType = "cache_creation"
+)
+
+// AvailableTokenTypes is the closed set of token types. Iterating it is what
+// guarantees every label combination is pre-initialized, so rate() evaluates to
+// zero rather than no-data before the first job runs.
+var AvailableTokenTypes = []TokenType{
+ TokenTypeInput,
+ TokenTypeOutput,
+ TokenTypeCacheRead,
+ TokenTypeCacheCreation,
+}
+
+// JobUsage is the LLM token and turn summary of one finished agent job.
+// The zero value is valid and records nothing but zeros.
+type JobUsage struct {
+ // InputTokens is the count of fresh (non-cached) input tokens the job consumed.
+ InputTokens int64
+ // OutputTokens is the count of output tokens the job produced.
+ OutputTokens int64
+ // CacheReadTokens is the count of input tokens served from the prompt cache.
+ CacheReadTokens int64
+ // CacheCreationTokens is the count of input tokens written into the prompt cache.
+ CacheCreationTokens int64
+ // Turns is the number of conversation turns the job took.
+ Turns int64
+}
+
// JobMetrics records per-job Prometheus metrics at the result-publish boundary.
type JobMetrics interface {
// RecordRun atomically increments the run counter and sets the last-run
@@ -24,9 +70,14 @@ type JobMetrics interface {
RecordRun(status agentlib.AgentStatus)
// RecordDuration observes the run duration histogram.
RecordDuration(d time.Duration)
+ // RecordUsage records the token and turn summary of a finished job: each
+ // token count advances its own type-labelled series and the turn count
+ // advances the turn counter. A negative value is skipped for that counter
+ // only; the other counters in the same call still record.
+ RecordUsage(usage JobUsage)
}
-// NewJobMetrics creates a JobMetrics that registers three collectors onto the
+// NewJobMetrics creates a JobMetrics that registers five collectors onto the
// caller-owned registry. The caller must NOT pass nil for registry.
// Registration failures (e.g. duplicate registration) panic — they are
// programmer errors caught at startup.
@@ -55,16 +106,37 @@ func NewJobMetrics(
Buckets: []float64{0.1, 0.5, 1, 5, 10, 30, 60, 120, 300, 600, 1800},
},
)
- registry.MustRegister(counter, gauge, histogram)
+ tokenCounter := prometheus.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "agent_job_tokens_total",
+ Help: "Total LLM tokens consumed by agent jobs, by token type.",
+ },
+ []string{"type"},
+ )
+ turnCounter := prometheus.NewCounter(
+ prometheus.CounterOpts{
+ Name: "agent_job_turns_total",
+ Help: "Total number of conversation turns taken by agent jobs.",
+ },
+ )
+ registry.MustRegister(counter, gauge, histogram, tokenCounter, turnCounter)
// Pre-initialize counter for all terminal statuses so absent() alerts work
// even before any Job has run.
counter.WithLabelValues(string(agentlib.AgentStatusDone)).Add(0)
counter.WithLabelValues(string(agentlib.AgentStatusFailed)).Add(0)
counter.WithLabelValues(string(agentlib.AgentStatusNeedsInput)).Add(0)
+ // Pre-initialize the token series and the turn counter so rate() evaluates to
+ // zero (not no-data) for a process that has not yet run a job.
+ for _, tokenType := range AvailableTokenTypes {
+ tokenCounter.WithLabelValues(tokenType.String()).Add(0)
+ }
+ turnCounter.Add(0)
return &jobMetrics{
counter: counter,
gauge: gauge,
histogram: histogram,
+ tokenCounter: tokenCounter,
+ turnCounter: turnCounter,
currentDateTime: currentDateTime,
}
}
@@ -73,6 +145,8 @@ type jobMetrics struct {
counter *prometheus.CounterVec
gauge *prometheus.GaugeVec
histogram prometheus.Histogram
+ tokenCounter *prometheus.CounterVec
+ turnCounter prometheus.Counter
currentDateTime libtime.CurrentDateTime
}
@@ -86,6 +160,27 @@ func (m *jobMetrics) RecordDuration(d time.Duration) {
m.histogram.Observe(d.Seconds())
}
+func (m *jobMetrics) RecordUsage(usage JobUsage) {
+ m.addTokens(TokenTypeInput, usage.InputTokens)
+ m.addTokens(TokenTypeOutput, usage.OutputTokens)
+ m.addTokens(TokenTypeCacheRead, usage.CacheReadTokens)
+ m.addTokens(TokenTypeCacheCreation, usage.CacheCreationTokens)
+ if usage.Turns >= 0 {
+ m.turnCounter.Add(float64(usage.Turns))
+ }
+}
+
+// addTokens advances the token counter for one token type. A negative count is
+// skipped: prometheus.Counter.Add panics on a negative delta, and the counts
+// originate from a subprocess's stdout, so a hostile or buggy value must not be
+// able to take the job down.
+func (m *jobMetrics) addTokens(tokenType TokenType, count int64) {
+ if count < 0 {
+ return
+ }
+ m.tokenCounter.WithLabelValues(tokenType.String()).Add(float64(count))
+}
+
// BuildJobMetricsName returns the standardized PushGateway job name for an
// agent job binary. All agent binaries must use this function to ensure the
// job name is consistent across deployments.
diff --git a/metrics/metrics_test.go b/metrics/metrics_test.go
index 64c73f4..ee1cf45 100644
--- a/metrics/metrics_test.go
+++ b/metrics/metrics_test.go
@@ -30,6 +30,17 @@ var _ = Describe("NewJobMetrics", func() {
m = libmetrics.NewJobMetrics(registry, currentDateTime)
})
+ findFamily := func(name string) *dto.MetricFamily {
+ mfs, err := registry.Gather()
+ Expect(err).NotTo(HaveOccurred())
+ for _, mf := range mfs {
+ if mf.GetName() == name {
+ return mf
+ }
+ }
+ return nil
+ }
+
Context("collector registration", func() {
It("registers the expected metric families on the registry", func() {
mfs, err := registry.Gather()
@@ -150,6 +161,216 @@ var _ = Describe("NewJobMetrics", func() {
})
})
+ Context("RecordUsage", func() {
+ Context("collector registration", func() {
+ It("registers agent_job_tokens_total and agent_job_turns_total families", func() {
+ m.RecordRun(agentlib.AgentStatusDone) // prime the gauge family
+ mfs, err := registry.Gather()
+ Expect(err).NotTo(HaveOccurred())
+ names := make([]string, 0, len(mfs))
+ for _, mf := range mfs {
+ names = append(names, mf.GetName())
+ }
+ Expect(names).To(ContainElements(
+ "agent_job_tokens_total",
+ "agent_job_turns_total",
+ ))
+ })
+ })
+
+ Context("counter pre-initialization", func() {
+ It("token counter has four pre-initialized series", func() {
+ tokenMF := findFamily("agent_job_tokens_total")
+ Expect(tokenMF).NotTo(BeNil(), "agent_job_tokens_total metric family not found")
+ Expect(
+ tokenMF.Metric,
+ ).To(HaveLen(4), "expected 4 pre-initialized label combinations")
+
+ labelValues := make([]string, 0, 4)
+ for _, metric := range tokenMF.Metric {
+ for _, lp := range metric.Label {
+ if lp.GetName() == "type" {
+ labelValues = append(labelValues, lp.GetValue())
+ }
+ }
+ Expect(metric.Counter.GetValue()).To(Equal(0.0))
+ }
+ Expect(labelValues).To(ConsistOf("input", "output", "cache_read", "cache_creation"))
+ })
+
+ It("turn counter is pre-initialized at zero", func() {
+ turnMF := findFamily("agent_job_turns_total")
+ Expect(turnMF).NotTo(BeNil(), "agent_job_turns_total metric family not found")
+ Expect(turnMF.Metric).To(HaveLen(1))
+ Expect(turnMF.Metric[0].Counter.GetValue()).To(Equal(0.0))
+ })
+ })
+
+ Context("usage recording", func() {
+ It("records distinct token values per kind and turn count", func() {
+ m.RecordUsage(libmetrics.JobUsage{
+ InputTokens: 11,
+ OutputTokens: 22,
+ CacheReadTokens: 33,
+ CacheCreationTokens: 44,
+ Turns: 5,
+ })
+
+ tokenMF := findFamily("agent_job_tokens_total")
+ Expect(tokenMF).NotTo(BeNil())
+ for _, metric := range tokenMF.Metric {
+ for _, lp := range metric.Label {
+ if lp.GetName() == "type" {
+ switch lp.GetValue() {
+ case "input":
+ Expect(metric.Counter.GetValue()).To(Equal(11.0))
+ case "output":
+ Expect(metric.Counter.GetValue()).To(Equal(22.0))
+ case "cache_read":
+ Expect(metric.Counter.GetValue()).To(Equal(33.0))
+ case "cache_creation":
+ Expect(metric.Counter.GetValue()).To(Equal(44.0))
+ }
+ }
+ }
+ }
+
+ turnMF := findFamily("agent_job_turns_total")
+ Expect(turnMF).NotTo(BeNil())
+ Expect(turnMF.Metric[0].Counter.GetValue()).To(Equal(5.0))
+ })
+
+ It("accumulates across multiple RecordUsage calls", func() {
+ m.RecordUsage(libmetrics.JobUsage{
+ InputTokens: 11,
+ OutputTokens: 22,
+ CacheReadTokens: 33,
+ CacheCreationTokens: 44,
+ Turns: 5,
+ })
+ m.RecordUsage(libmetrics.JobUsage{
+ InputTokens: 11,
+ OutputTokens: 22,
+ CacheReadTokens: 33,
+ CacheCreationTokens: 44,
+ Turns: 5,
+ })
+
+ tokenMF := findFamily("agent_job_tokens_total")
+ Expect(tokenMF).NotTo(BeNil())
+ for _, metric := range tokenMF.Metric {
+ for _, lp := range metric.Label {
+ if lp.GetName() == "type" {
+ switch lp.GetValue() {
+ case "input":
+ Expect(metric.Counter.GetValue()).To(Equal(22.0))
+ case "output":
+ Expect(metric.Counter.GetValue()).To(Equal(44.0))
+ case "cache_read":
+ Expect(metric.Counter.GetValue()).To(Equal(66.0))
+ case "cache_creation":
+ Expect(metric.Counter.GetValue()).To(Equal(88.0))
+ }
+ }
+ }
+ }
+
+ turnMF := findFamily("agent_job_turns_total")
+ Expect(turnMF).NotTo(BeNil())
+ Expect(turnMF.Metric[0].Counter.GetValue()).To(Equal(10.0))
+ })
+
+ It("skips negative input token count without panic", func() {
+ Expect(func() {
+ m.RecordUsage(libmetrics.JobUsage{
+ InputTokens: -5,
+ OutputTokens: 7,
+ CacheReadTokens: 8,
+ CacheCreationTokens: 9,
+ Turns: 3,
+ })
+ }).NotTo(Panic())
+
+ tokenMF := findFamily("agent_job_tokens_total")
+ Expect(tokenMF).NotTo(BeNil())
+ for _, metric := range tokenMF.Metric {
+ for _, lp := range metric.Label {
+ if lp.GetName() == "type" {
+ switch lp.GetValue() {
+ case "input":
+ Expect(metric.Counter.GetValue()).To(Equal(0.0))
+ case "output":
+ Expect(metric.Counter.GetValue()).To(Equal(7.0))
+ case "cache_read":
+ Expect(metric.Counter.GetValue()).To(Equal(8.0))
+ case "cache_creation":
+ Expect(metric.Counter.GetValue()).To(Equal(9.0))
+ }
+ }
+ }
+ }
+
+ turnMF := findFamily("agent_job_turns_total")
+ Expect(turnMF).NotTo(BeNil())
+ Expect(turnMF.Metric[0].Counter.GetValue()).To(Equal(3.0))
+ })
+
+ It("skips negative turns without panic while input records", func() {
+ Expect(func() {
+ m.RecordUsage(libmetrics.JobUsage{
+ InputTokens: 4,
+ Turns: -1,
+ })
+ }).NotTo(Panic())
+
+ tokenMF := findFamily("agent_job_tokens_total")
+ Expect(tokenMF).NotTo(BeNil())
+ for _, metric := range tokenMF.Metric {
+ for _, lp := range metric.Label {
+ if lp.GetName() == "type" && lp.GetValue() == "input" {
+ Expect(metric.Counter.GetValue()).To(Equal(4.0))
+ }
+ }
+ }
+
+ turnMF := findFamily("agent_job_turns_total")
+ Expect(turnMF).NotTo(BeNil())
+ Expect(turnMF.Metric[0].Counter.GetValue()).To(Equal(0.0))
+ })
+ })
+
+ Context("help string quality", func() {
+ It("every registered family has a non-empty help string", func() {
+ m.RecordRun(agentlib.AgentStatusDone) // prime gauge
+ mfs, err := registry.Gather()
+ Expect(err).NotTo(HaveOccurred())
+ for _, mf := range mfs {
+ Expect(mf.GetHelp()).NotTo(BeEmpty())
+ }
+ })
+
+ It("all five help strings are pairwise distinct", func() {
+ m.RecordRun(agentlib.AgentStatusDone) // prime gauge
+ mfs, err := registry.Gather()
+ Expect(err).NotTo(HaveOccurred())
+ helps := make([]string, 0, len(mfs))
+ for _, mf := range mfs {
+ helps = append(helps, mf.GetHelp())
+ }
+ // Deduplicate
+ seen := make(map[string]bool)
+ unique := make([]string, 0, len(helps))
+ for _, h := range helps {
+ if !seen[h] {
+ seen[h] = true
+ unique = append(unique, h)
+ }
+ }
+ Expect(unique).To(HaveLen(len(helps)), "duplicate help strings found")
+ })
+ })
+ })
+
Context("BuildJobMetricsName", func() {
It("returns a stable job name string for claude-agent", func() {
Expect(
diff --git a/metrics/mocks/job-metrics.go b/metrics/mocks/job-metrics.go
index 166426b..de24985 100644
--- a/metrics/mocks/job-metrics.go
+++ b/metrics/mocks/job-metrics.go
@@ -20,6 +20,11 @@ type JobMetrics struct {
recordRunArgsForCall []struct {
arg1 lib.AgentStatus
}
+ RecordUsageStub func(metrics.JobUsage)
+ recordUsageMutex sync.RWMutex
+ recordUsageArgsForCall []struct {
+ arg1 metrics.JobUsage
+ }
invocations map[string][][]interface{}
invocationsMutex sync.RWMutex
}
@@ -88,6 +93,38 @@ func (fake *JobMetrics) RecordRunArgsForCall(i int) lib.AgentStatus {
return argsForCall.arg1
}
+func (fake *JobMetrics) RecordUsage(arg1 metrics.JobUsage) {
+ fake.recordUsageMutex.Lock()
+ fake.recordUsageArgsForCall = append(fake.recordUsageArgsForCall, struct {
+ arg1 metrics.JobUsage
+ }{arg1})
+ stub := fake.RecordUsageStub
+ fake.recordInvocation("RecordUsage", []interface{}{arg1})
+ fake.recordUsageMutex.Unlock()
+ if stub != nil {
+ fake.RecordUsageStub(arg1)
+ }
+}
+
+func (fake *JobMetrics) RecordUsageCallCount() int {
+ fake.recordUsageMutex.RLock()
+ defer fake.recordUsageMutex.RUnlock()
+ return len(fake.recordUsageArgsForCall)
+}
+
+func (fake *JobMetrics) RecordUsageCalls(stub func(metrics.JobUsage)) {
+ fake.recordUsageMutex.Lock()
+ defer fake.recordUsageMutex.Unlock()
+ fake.RecordUsageStub = stub
+}
+
+func (fake *JobMetrics) RecordUsageArgsForCall(i int) metrics.JobUsage {
+ fake.recordUsageMutex.RLock()
+ defer fake.recordUsageMutex.RUnlock()
+ argsForCall := fake.recordUsageArgsForCall[i]
+ return argsForCall.arg1
+}
+
func (fake *JobMetrics) Invocations() map[string][][]interface{} {
fake.invocationsMutex.RLock()
defer fake.invocationsMutex.RUnlock()
diff --git a/prompts/completed/206-spec-046-claude-usage-parsing.md b/prompts/completed/206-spec-046-claude-usage-parsing.md
new file mode 100644
index 0000000..878a3eb
--- /dev/null
+++ b/prompts/completed/206-spec-046-claude-usage-parsing.md
@@ -0,0 +1,405 @@
+---
+status: completed
+spec: [046-job-usage-metrics]
+summary: Added usage token counts (input, output, cache-creation, cache-read) and turn count to ClaudeResult via two-pass unmarshal with map-based field extraction for schema-drift tolerance
+execution_id: agent-usage-metrics-exec-206-spec-046-claude-usage-parsing
+dark-factory-version: v0.192.9
+created: "2026-08-01T22:05:00Z"
+queued: "2026-08-01T22:17:52Z"
+started: "2026-08-01T22:17:58Z"
+completed: "2026-08-01T22:31:54Z"
+branch: dark-factory/job-usage-metrics
+---
+
+
+- Every Claude Code session already ends with a summary of how many tokens it burned and how many conversation turns it took; today that summary is read off the wire and thrown away.
+- After this change the parsed session result carries those five numbers (fresh input tokens, output tokens, cache-creation tokens, cache-read tokens, turn count) so any caller can read them.
+- Sessions where the CLI reports no usage summary, or only some of the fields, still parse exactly as they do today — the missing numbers simply read as zero and nothing errors out.
+- When a session emits more than one final event carrying a usage summary, the newest summary wins.
+- That "newest wins" rule for the numbers is deliberately independent of the existing "newest non-empty text wins" rule for the result text, so a later event with numbers but no text cannot wipe out the text.
+- The dollar-cost figure the CLI reports is deliberately NOT captured — under a non-Anthropic provider it is a fictional number and seeding it into a dashboard would be worse than having none.
+- No behavior visible to existing callers changes: the same result text comes back, the same errors are raised, the Pi harness is untouched.
+- This prompt covers only the parsing half of the spec; publishing the numbers as Prometheus counters is a sibling prompt, and wiring the two together happens in a different repository.
+
+
+
+Capture the Claude CLI's end-of-session usage summary (four token counts plus the turn count) while scanning the stream-json output in the `claude` package, and expose all five values on the `ClaudeResult` returned by `ClaudeRunner.Run`. Absent or partial usage must never be an error. Implements spec 046 Desired Behaviors 1-4 and Acceptance Criteria 1-5.
+
+
+
+Read `/workspace/CLAUDE.md` for project conventions (Ginkgo v2 / Gomega, counterfeiter, external test packages, `github.com/bborbe/errors`).
+
+Read these coding-plugin docs:
+- `/home/node/.claude/plugins/marketplaces/coding/docs/go-testing-guide.md` — Ginkgo v2 / Gomega, external test package, coverage >= 80% for changed code, error paths must be tested.
+- `/home/node/.claude/plugins/marketplaces/coding/docs/go-doc-best-practices.md` — GoDoc comments start with the identifier name, full sentences, describe behavior.
+- `/home/node/.claude/plugins/marketplaces/coding/docs/go-patterns.md` — repo idioms.
+- `/workspace/docs/dod.md` — definition of done for this repo.
+
+Read these files IN FULL before editing:
+- `/workspace/claude/claude-event.go` (25 lines) — the stream-json wire types.
+- `/workspace/claude/claude-result.go` (10 lines) — the type to widen.
+- `/workspace/claude/claude-runner.go` (235 lines) — `Run` at line 46 and `scanOutput` at line 141 are the two functions to change.
+- `/workspace/claude/claude-runner_test.go` (380 lines) — the existing Ginkgo suite and the `writeShim` PATH-shim helper you must reuse.
+
+Load-bearing snippets, verified verbatim against source.
+
+`/workspace/claude/claude-event.go` — the full current file body:
+```go
+// claudeEvent represents a single event in the Claude CLI stream-json output.
+type claudeEvent struct {
+ Type string `json:"type"`
+ Result string `json:"result"`
+ Message claudeMsg `json:"message"`
+}
+
+type claudeMsg struct {
+ Content []claudeContent `json:"content"`
+}
+
+type claudeContent struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ Name string `json:"name"`
+ Input json.RawMessage `json:"input"`
+}
+```
+
+`/workspace/claude/claude-result.go` — the full current type:
+```go
+// ClaudeResult holds the parsed output from a Claude Code CLI session.
+type ClaudeResult struct {
+ Result string `json:"result"`
+}
+```
+
+`/workspace/claude/claude-runner.go` lines 61 and 73-78 — the current call site and return:
+```go
+ resultText, tail := scanOutput(ctx, stdoutPipe)
+...
+ if resultText == "" {
+ return nil, errors.New(ctx, "no result event found in claude CLI output")
+ }
+
+ return &ClaudeResult{Result: resultText}, nil
+```
+
+`/workspace/claude/claude-runner.go` lines 140-180 — the current `scanOutput` in full:
+```go
+// scanOutput reads stream-json lines from stdout, logs events, and returns the result text and a bounded tail of all non-empty lines.
+func scanOutput(
+ ctx context.Context,
+ reader interface{ Read([]byte) (int, error) },
+) (string, []string) {
+ var resultText string
+ var tail []string
+ scanner := bufio.NewScanner(reader)
+ scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
+ for scanner.Scan() {
+ select {
+ case <-ctx.Done():
+ return "", nil
+ default:
+ }
+
+ line := scanner.Bytes()
+ glog.V(4).Infof("[line] %s", line)
+
+ tail = appendTail(tail, line)
+
+ var event claudeEvent
+ if err := json.Unmarshal(line, &event); err != nil {
+ continue
+ }
+
+ if event.Type == "result" && event.Result != "" {
+ resultText = event.Result
+ }
+
+ for _, c := range event.Message.Content {
+ switch c.Type {
+ case "tool_use":
+ logToolUse(c)
+ default:
+ glog.V(2).Infof("type(%s): %s", c.Type, c.Text)
+ }
+ }
+ }
+ return resultText, tail
+}
+```
+
+`/workspace/claude/claude-runner_test.go` lines 29-40 — the `writeShim` helper you must reuse for the new tests (it is redeclared inside each `Describe`; follow that existing duplication pattern rather than hoisting it):
+```go
+ // writeShim creates a temp dir, writes a "claude" shell script with the given body,
+ // prepends the dir to PATH, and registers cleanup via DeferCleanup.
+ writeShim := func(body string) {
+ shimDir := GinkgoT().TempDir()
+ shimPath := filepath.Join(shimDir, "claude")
+ script := "#!/bin/sh\n" + body
+ err := os.WriteFile(shimPath, []byte(script), 0755) //nolint:gosec
+ Expect(err).NotTo(HaveOccurred())
+ originalPath := os.Getenv("PATH")
+ DeferCleanup(func() {
+ Expect(os.Setenv("PATH", originalPath)).To(Succeed())
+ })
+ Expect(os.Setenv("PATH", shimDir+":"+originalPath)).To(Succeed())
+ }
+```
+
+`scanOutput` has exactly one caller: `/workspace/claude/claude-runner.go:61`. Verified with `grep -rn "scanOutput" --include="*.go" /workspace` — the only other hit is `/workspace/pi/pi-runner.go`, which is a SEPARATE unexported function in `package pi`. Do NOT touch `/workspace/pi/`.
+
+`ClaudeResult` is constructed in exactly one non-test place (`claude-runner.go:77`) and read in tests plus `/workspace/healthcheck/healthcheck-claude-step_test.go`. All existing construction sites use keyed literals (`&claude.ClaudeResult{Result: "..."}`), so adding fields does not break them.
+
+
+
+1. **Add the usage wire types to `/workspace/claude/claude-event.go`.** Extend `claudeEvent` with the two new wire fields and add the `claudeUsage` type. The JSON keys are fixed by the CLI and must be exactly as written here:
+
+ ```go
+ // claudeEvent represents a single event in the Claude CLI stream-json output.
+ type claudeEvent struct {
+ Type string `json:"type"`
+ Result string `json:"result"`
+ Message claudeMsg `json:"message"`
+ Usage *claudeUsage `json:"usage"`
+ NumTurns json.Number `json:"num_turns"`
+ }
+
+ // claudeUsage is the token accounting object the Claude CLI attaches to its
+ // terminal result event. Absent or non-integer fields decode as 0.
+ type claudeUsage struct {
+ InputTokens json.Number `json:"input_tokens"`
+ OutputTokens json.Number `json:"output_tokens"`
+ CacheCreationInputTokens json.Number `json:"cache_creation_input_tokens"`
+ CacheReadInputTokens json.Number `json:"cache_read_input_tokens"`
+ }
+ ```
+
+ `Usage` is a POINTER on purpose: a nil pointer is how "this event carried no usage object at all" is distinguished from "this event carried a usage object whose fields are all zero". That distinction is what makes requirement 3's capture gate work.
+
+ 🚨 **The token fields MUST be `json.Number`, not `int64`, and `NumTurns` MUST be `json.Number` too. This is load-bearing, not stylistic.** `scanOutput` discards the entire event on unmarshal error (`if err := json.Unmarshal(line, &event); err != nil { continue }`). With `int64` fields, a token count serialized as `100.0`, `"100"`, or `1.5` fails to decode **the whole result event** — including `Result` — so `resultText` stays empty and `Run` returns `no result event found in claude CLI output`. A successful session would be reported as a failed job. That directly violates this spec's "Absent usage is never an error and never aborts a run" and both schema-drift failure modes. `json.Number` accepts any JSON number or string-encoded number without failing the decode; convert with `.Int64()` and **on conversion error use 0** — never propagate the error. This repo routes the CLI through a non-Anthropic `ANTHROPIC_BASE_URL` shim, so divergent number formatting is a live risk, not a hypothetical.
+
+ Add `"encoding/json"` usage accordingly — the file already imports it.
+
+2. **Add the parse-side aggregate type `sessionUsage` to `/workspace/claude/claude-event.go`**, directly below `claudeUsage`. It carries all five captured values out of the scanner in one value:
+
+ ```go
+ // sessionUsage is the token and turn summary captured from the Claude CLI's
+ // terminal result event. The zero value means no usage was reported and is a
+ // valid, non-error outcome.
+ type sessionUsage struct {
+ inputTokens int64
+ outputTokens int64
+ cacheCreationTokens int64
+ cacheReadTokens int64
+ numTurns int64
+ }
+ ```
+
+3. **Capture usage in `scanOutput` under its own gate, separate from the result-text gate.** In `/workspace/claude/claude-runner.go`:
+ - Change the signature to `func scanOutput(ctx context.Context, reader interface{ Read([]byte) (int, error) }) (string, sessionUsage, []string)`.
+ - Declare `var usage sessionUsage` alongside `var resultText string`.
+ - Change the context-cancellation early return from `return "", nil` to `return "", sessionUsage{}, nil`.
+ - Change the final return to `return resultText, usage, tail`.
+ - Leave the existing result-text capture EXACTLY as it is:
+ ```go
+ if event.Type == "result" && event.Result != "" {
+ resultText = event.Result
+ }
+ ```
+ - Add a SEPARATE, adjacent block immediately after it — do not fold the two conditions together:
+ ```go
+ // Usage capture is deliberately gated on the presence of a usage object, NOT
+ // on a non-empty result text: a later result event carrying fresh usage but an
+ // empty result string must update the numbers while leaving the previously
+ // captured text intact. Last usage object wins.
+ if event.Type == "result" && event.Usage != nil {
+ usage = sessionUsage{
+ inputTokens: numberToInt64(event.Usage.InputTokens),
+ outputTokens: numberToInt64(event.Usage.OutputTokens),
+ cacheCreationTokens: numberToInt64(event.Usage.CacheCreationInputTokens),
+ cacheReadTokens: numberToInt64(event.Usage.CacheReadInputTokens),
+ numTurns: numberToInt64(event.NumTurns),
+ }
+ }
+ ```
+ The turn count is read from the SAME event that carried the winning usage object — the whole five-value summary is replaced atomically, never field by field.
+ - Add the conversion helper to `/workspace/claude/claude-event.go`, directly below `sessionUsage`. It is the single place where a malformed number degrades to 0 — **the error is deliberately swallowed, never returned or logged at a level that would spam**:
+ ```go
+ // numberToInt64 converts a JSON number to int64, yielding 0 when the value is
+ // absent, non-integer, or otherwise unconvertible. Usage accounting is
+ // best-effort telemetry: a malformed count must never fail the run.
+ func numberToInt64(n json.Number) int64 {
+ if n == "" {
+ return 0
+ }
+ v, err := n.Int64()
+ if err != nil {
+ return 0
+ }
+ return v
+ }
+ ```
+ Do NOT wrap this in `github.com/bborbe/errors` — it returns no error by design. This is the one sanctioned deviation from the repo's no-swallowed-errors convention, and the GoDoc above states why.
+ - Update the `scanOutput` GoDoc line to mention that it also returns the captured usage summary, and keep the line under 100 characters (golines `--max-len=100` runs in `make format`). For example:
+ ```go
+ // scanOutput reads stream-json lines from stdout, logs events, and returns the result
+ // text, the captured usage summary, and a bounded tail of all non-empty lines.
+ ```
+
+4. **Widen `ClaudeResult` in `/workspace/claude/claude-result.go`** with the five exported fields. Every new field carries a GoDoc comment. Use `omitempty` on the numeric fields so the marshalled shape of a usage-free result is byte-identical to today:
+
+ ```go
+ // ClaudeResult holds the parsed output from a Claude Code CLI session.
+ type ClaudeResult struct {
+ Result string `json:"result"`
+ // InputTokens is the count of fresh (non-cached) input tokens the session consumed.
+ InputTokens int64 `json:"input_tokens,omitempty"`
+ // OutputTokens is the count of output tokens the session produced.
+ OutputTokens int64 `json:"output_tokens,omitempty"`
+ // CacheCreationTokens is the count of input tokens written into the prompt cache.
+ CacheCreationTokens int64 `json:"cache_creation_tokens,omitempty"`
+ // CacheReadTokens is the count of input tokens served from the prompt cache.
+ CacheReadTokens int64 `json:"cache_read_tokens,omitempty"`
+ // NumTurns is the number of conversation turns the session took. Zero when the
+ // CLI reported no usage summary.
+ NumTurns int64 `json:"num_turns,omitempty"`
+ }
+ ```
+
+5. **Wire the captured values through `Run` in `/workspace/claude/claude-runner.go`.** Replace line 61 and the final return:
+ ```go
+ resultText, usage, tail := scanOutput(ctx, stdoutPipe)
+ ```
+ ```go
+ return &ClaudeResult{
+ Result: resultText,
+ InputTokens: usage.inputTokens,
+ OutputTokens: usage.outputTokens,
+ CacheCreationTokens: usage.cacheCreationTokens,
+ CacheReadTokens: usage.cacheReadTokens,
+ NumTurns: usage.numTurns,
+ }, nil
+ ```
+ Everything else in `Run` is untouched: the `cmd.Wait()` error path with the tail message, and the `resultText == ""` -> `errors.New(ctx, "no result event found in claude CLI output")` guard both stay exactly as they are. Missing usage must NOT produce an error and must NOT change that guard.
+
+6. **Add a `Describe("claudeRunner usage capture", ...)` block to `/workspace/claude/claude-runner_test.go`** (append at the end of the file, `package claude_test`). Redeclare the `writeShim` closure inside it exactly as quoted in `` — that is the established pattern in this file and the only supported way to reach the unexported parser. Do NOT create an in-package `package claude` test file. Cover these six cases, one `Context` each:
+
+ - **Malformed usage numbers must not kill the event (schema-drift guard).** Shim body:
+ ```sh
+ echo '{"type":"result","result":"kept-text","num_turns":"7","usage":{"input_tokens":100.0,"output_tokens":"bad","cache_read_input_tokens":50}}'
+ exit 0
+ ```
+ Assert **no error occurred**, `result.Result == "kept-text"` (the result text survives), `CacheReadTokens == int64(50)` (the well-formed sibling still lands), and the unconvertible `output_tokens` reads `int64(0)`. `input_tokens: 100.0` and `num_turns: "7"` are both valid `json.Number` inputs — assert whatever `.Int64()` yields for each (`100.0` → conversion error → `0`; `"7"` → `7`). **This is the regression guard for the `int64`-vs-`json.Number` decision in requirement 1** — every other fixture here uses clean integers, so without this Context the failure mode is invisible and the whole suite passes while a live bug ships.
+
+ - **Full usage (AC1).** Shim body:
+ ```sh
+ echo '{"type":"result","result":"task-output-text","num_turns":7,"usage":{"input_tokens":100,"output_tokens":200,"cache_creation_input_tokens":300,"cache_read_input_tokens":400}}'
+ exit 0
+ ```
+ Assert no error, `result.Result == "task-output-text"`, and `InputTokens == 100`, `OutputTokens == 200`, `CacheCreationTokens == 300`, `CacheReadTokens == 400`, `NumTurns == 7` (use `int64` literals in the Gomega `Equal` matcher — `Equal(int64(100))`, since Gomega's `Equal` is type-strict).
+
+ - **No usage object and no turn count (AC2).** Shim body:
+ ```sh
+ echo '{"type":"result","result":"plain-output"}'
+ exit 0
+ ```
+ Assert `err` did NOT occur, `result.Result == "plain-output"`, and all five values equal `int64(0)`.
+
+ - **Partial usage fields (AC3).** Shim body:
+ ```sh
+ echo '{"type":"result","result":"partial-output","usage":{"input_tokens":11,"cache_read_input_tokens":22}}'
+ exit 0
+ ```
+ Assert `InputTokens == int64(11)`, `CacheReadTokens == int64(22)`, and `OutputTokens`, `CacheCreationTokens`, `NumTurns` all equal `int64(0)`.
+
+ - **Two result events both carrying usage — last wins (AC4).** Shim body:
+ ```sh
+ echo '{"type":"result","result":"first-text","num_turns":1,"usage":{"input_tokens":1,"output_tokens":2,"cache_creation_input_tokens":3,"cache_read_input_tokens":4}}'
+ echo '{"type":"result","result":"second-text","num_turns":9,"usage":{"input_tokens":10,"output_tokens":20,"cache_creation_input_tokens":30,"cache_read_input_tokens":40}}'
+ exit 0
+ ```
+ Assert `result.Result == "second-text"` and the five values are `10, 20, 30, 40, 9`.
+
+ - **Usage last-wins is independent of result-text last-wins (AC5) — this is the regression guard for requirement 3.** Shim body:
+ ```sh
+ echo '{"type":"result","result":"kept-text","num_turns":2,"usage":{"input_tokens":5,"output_tokens":6,"cache_creation_input_tokens":7,"cache_read_input_tokens":8}}'
+ echo '{"type":"result","result":"","num_turns":4,"usage":{"input_tokens":50,"output_tokens":60,"cache_creation_input_tokens":70,"cache_read_input_tokens":80}}'
+ exit 0
+ ```
+ Assert `result.Result == "kept-text"` (the first event's text survives) AND the five values are `50, 60, 70, 80, 4` (the second event's numbers win).
+
+ Invoke the runner the same way the existing tests do: `claude.NewClaudeRunner(claude.ClaudeRunnerConfig{}).Run(ctx, "test")` with `ctx := context.Background()` set in a `BeforeEach`.
+
+7. **Do not let the CLI's cost field into the tree.** The CLI's terminal result event also carries a dollar-cost key. Do NOT add a struct field for it, do NOT put it in any test fixture, and do NOT name it in any comment or test description. The literal key name must not appear anywhere under `/workspace/claude/` — when you need to refer to it in prose, write "the CLI's cost field". The spec's acceptance check is `grep -rn 'total_cost_usd' claude/ metrics/` returning zero lines.
+
+8. **Add a CHANGELOG entry.** In `/workspace/CHANGELOG.md`, insert an `## Unreleased` section immediately after the SemVer preamble and before the existing `## v0.79.0` heading (there is no `## Unreleased` section today — if a sibling prompt already created one, append to it instead of adding a second):
+ ```markdown
+ ## Unreleased
+
+ - feat: claude: `ClaudeResult` now carries the CLI session's token counts (input, output, cache-creation, cache-read) and turn count, parsed from the terminal result event's usage summary; absent or partial usage parses as zeros without error
+ ```
+
+9. **Run `make generate` is NOT required for this prompt.** The `ClaudeRunner` interface signature is unchanged (`Run(ctx, prompt) (*ClaudeResult, error)`), so `/workspace/mocks/claude-claude-runner.go` stays valid. Note that `make precommit` runs `make generate` anyway, which wipes and regenerates `/workspace/mocks/` — that is expected and must leave no diff beyond regeneration noise.
+
+
+
+- Do NOT parse, store, or record the CLI's cost figure. Under a non-Anthropic base URL the CLI computes it at Anthropic list pricing, so it is a counterfactual number, not money spent. (Spec Non-goal, invariant.)
+- Do NOT touch `/workspace/pi/` — the Pi harness does not run Claude Code and emits no usage summary. Its identically-named unexported `scanOutput` is a different function in a different package. (Spec Non-goal.)
+- Do NOT wire the new values into any consumer or metrics call site. The recording call lives in a sibling prompt and the call sites live in a separate repository. (Spec Non-goal.)
+- Do NOT add a config flag, env var, or opt-out for usage capture. Capture is unconditional. (Spec Non-goal.)
+- Absent usage is never an error and never aborts a run — the `resultText == ""` guard is the ONLY reason `Run` may fail on a zero-exit CLI, exactly as today. (Spec Desired Behavior 3.)
+- Usage capture must NOT be folded into the `event.Result != ""` condition. Two separate `if` blocks. (Spec Desired Behavior 4 — this is called out explicitly because folding them is the natural-looking mistake.)
+- A malformed JSON line is still skipped silently by the existing `json.Unmarshal` `continue` — do not change that. (Spec Failure Modes.)
+- An unknown or renamed usage field must be ignored and read as 0 — that falls out of `encoding/json` defaults; do not add strict decoding (`DisallowUnknownFields`). (Spec Failure Modes.)
+- Error handling stays on `github.com/bborbe/errors` with context wrapping. No `fmt.Errorf`, no bare `return err`. (Spec Constraint.)
+- Every new exported type, field, method, and function carries a GoDoc comment. (Spec Constraint.)
+- Tests are Ginkgo v2 / Gomega in the external `claude_test` package, reaching the unexported parser through the existing `writeShim` PATH shim. Do NOT add a `package claude` in-package test file. (Spec Constraint.)
+- Coverage for the changed code (`scanOutput`, `Run`) must be >= 80%; the five new contexts exercise the full-usage, no-usage, partial-usage, duplicate-usage, and empty-text-second-event branches. Check with `go test -coverprofile=/tmp/cover.out -mod=mod ./claude/... && go tool cover -func=/tmp/cover.out`.
+- All existing tests must still pass unmodified — in particular the `successful CLI exit`, `CLAUDE_CONFIG_DIR env propagation`, and `AllowedTools buildCommand branch` blocks, none of which emit usage.
+- Line length limit is 100 characters (golines runs in `make format`); funlen limit is 80 lines (`scanOutput` stays well under after the addition).
+- Do NOT commit — dark-factory handles git.
+
+
+
+```bash
+# Package tests — AC1 through AC5.
+cd /workspace && go test -mod=mod -race ./claude/... 2>&1 | tail -20
+# Must report ok / PASS.
+```
+
+```bash
+# Coverage for the changed package.
+cd /workspace && go test -coverprofile=/tmp/cover.out -mod=mod ./claude/... && go tool cover -func=/tmp/cover.out | grep -E 'scanOutput|claude-runner.go:.*Run'
+# scanOutput must be >= 80%.
+```
+
+```bash
+# The CLI's cost field must not have entered the tree.
+! grep -rq 'total_cost_usd' /workspace/claude/
+# Must return zero lines (exit 1).
+```
+
+```bash
+# The two capture gates must be separate statements, not one folded condition.
+grep -n 'event.Type == "result"' /workspace/claude/claude-runner.go
+# Must return exactly 2 lines.
+```
+
+```bash
+# Pi harness untouched.
+grep -c 'usage\|num_turns' /workspace/pi/pi-runner.go
+# Must return 0.
+```
+
+```bash
+# Changelog entry present.
+grep -n -A5 '## Unreleased' /workspace/CHANGELOG.md | grep -iE 'token|turn'
+# Must return at least one line.
+```
+
+```bash
+# Final full validation at the repository root.
+cd /workspace && make precommit
+# Must exit 0.
+```
+
diff --git a/prompts/completed/207-spec-046-job-usage-metrics.md b/prompts/completed/207-spec-046-job-usage-metrics.md
new file mode 100644
index 0000000..fd0ee9d
--- /dev/null
+++ b/prompts/completed/207-spec-046-job-usage-metrics.md
@@ -0,0 +1,369 @@
+---
+status: completed
+spec: [046-job-usage-metrics]
+summary: Added RecordUsage(JobUsage) to JobMetrics interface with agent_job_tokens_total and agent_job_turns_total counters, all tests pass at 100% coverage
+execution_id: agent-usage-metrics-exec-207-spec-046-job-usage-metrics
+dark-factory-version: v0.192.9
+created: "2026-08-01T22:05:00Z"
+queued: "2026-08-01T22:34:00Z"
+started: "2026-08-01T22:34:02Z"
+completed: "2026-08-02T12:36:07Z"
+branch: dark-factory/job-usage-metrics
+---
+
+
+- Agent jobs can now report how many LLM tokens and conversation turns they burned, alongside the run count, status, and duration they already report.
+- Tokens are broken out by kind — fresh input, output, cache-read, and cache-creation — so a plan sold in "prompts per window" can be compared against one sold in "requests per window".
+- Turns get their own simple count.
+- Both counters exist and read zero from the moment a process starts, before any job has run, so dashboards and alerts built on them work immediately instead of silently reporting no-data.
+- A nonsense negative number from the subprocess is skipped for that one counter rather than crashing the job, and the other numbers in the same report still land.
+- Everything already published stays exactly as it was — same names, same labels, same meanings.
+- The dollar-cost figure the CLI reports is deliberately not recorded; under a non-Anthropic provider it is a fictional number.
+- A short reference doc now lists every metric this package publishes, including the three that were previously undocumented.
+- This prompt covers only the recording side; the callers that will invoke it live in a separate repository and are explicit follow-up work.
+
+
+
+Add a `RecordUsage` method to the `JobMetrics` interface in `/workspace/metrics`, backed by two new pre-initialized Prometheus counters — `agent_job_tokens_total` (labelled by token `type`) and the unlabelled `agent_job_turns_total` — registered on the caller-owned registry, regenerate the counterfeiter fake, and document the full metric set in `docs/job-metrics.md`. Implements spec 046 Desired Behaviors 5-8 and Acceptance Criteria 6-13, 15.
+
+
+
+Read `/workspace/CLAUDE.md` for project conventions (Ginkgo v2 / Gomega, counterfeiter, external test packages, `github.com/bborbe/errors`).
+
+Read these coding-plugin docs:
+- `/home/node/.claude/plugins/marketplaces/coding/docs/go-prometheus-metrics-guide.md` — in particular the `counter-pre-initialization` rule (`.Add(0)` for every known label combination, because `rate()` returns no-data rather than zero for unseen series and alerts then never fire) and the `help-string-quality` rule (non-empty, non-duplicated, describes the right metric).
+- `/home/node/.claude/plugins/marketplaces/coding/docs/go-testing-guide.md` — Ginkgo v2 / Gomega, counterfeiter mocks, external test package, coverage >= 80%.
+- `/home/node/.claude/plugins/marketplaces/coding/docs/go-doc-best-practices.md` — GoDoc starts with the identifier name, full sentences.
+- `/home/node/.claude/plugins/marketplaces/coding/docs/documentation-guide.md` — style for the new `docs/job-metrics.md`.
+- `/workspace/docs/dod.md` — definition of done for this repo.
+
+Read these files IN FULL before editing:
+- `/workspace/metrics/metrics.go` (96 lines) — the only file to change in this package.
+- `/workspace/metrics/metrics_test.go` (164 lines) — the existing Ginkgo suite and its `registry.Gather()` assertion style.
+- `/workspace/metrics/mocks/job-metrics.go` (113 lines) — the generated fake that must be regenerated.
+- `/workspace/docs/agent-job-interface.md` — an existing docs file; match its heading/table style for the new `docs/job-metrics.md`.
+
+Load-bearing snippets, verified verbatim against source.
+
+`/workspace/metrics/metrics.go` lines 17-70 — the counterfeiter directive, the interface, and the constructor as they exist today:
+```go
+//counterfeiter:generate -o mocks/job-metrics.go --fake-name JobMetrics . JobMetrics
+
+// JobMetrics records per-job Prometheus metrics at the result-publish boundary.
+type JobMetrics interface {
+ // RecordRun atomically increments the run counter and sets the last-run
+ // gauge for the given status label. Both operations use the same label
+ // value; they cannot drift.
+ RecordRun(status agentlib.AgentStatus)
+ // RecordDuration observes the run duration histogram.
+ RecordDuration(d time.Duration)
+}
+
+// NewJobMetrics creates a JobMetrics that registers three collectors onto the
+// caller-owned registry. The caller must NOT pass nil for registry.
+// Registration failures (e.g. duplicate registration) panic — they are
+// programmer errors caught at startup.
+func NewJobMetrics(
+ registry *prometheus.Registry,
+ currentDateTime libtime.CurrentDateTime,
+) JobMetrics {
+ counter := prometheus.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "agent_job_run_total",
+ Help: "Total number of agent job runs by terminal status.",
+ },
+ []string{"status"},
+ )
+ gauge := prometheus.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Name: "agent_job_last_run_timestamp_seconds",
+ Help: "Unix timestamp (seconds) of the last agent job run, by terminal status.",
+ },
+ []string{"status"},
+ )
+ histogram := prometheus.NewHistogram(
+ prometheus.HistogramOpts{
+ Name: "agent_job_duration_seconds",
+ Help: "Duration of agent job runs in seconds.",
+ Buckets: []float64{0.1, 0.5, 1, 5, 10, 30, 60, 120, 300, 600, 1800},
+ },
+ )
+ registry.MustRegister(counter, gauge, histogram)
+ // Pre-initialize counter for all terminal statuses so absent() alerts work
+ // even before any Job has run.
+ counter.WithLabelValues(string(agentlib.AgentStatusDone)).Add(0)
+ counter.WithLabelValues(string(agentlib.AgentStatusFailed)).Add(0)
+ counter.WithLabelValues(string(agentlib.AgentStatusNeedsInput)).Add(0)
+ return &jobMetrics{
+ counter: counter,
+ gauge: gauge,
+ histogram: histogram,
+ currentDateTime: currentDateTime,
+ }
+}
+
+type jobMetrics struct {
+ counter *prometheus.CounterVec
+ gauge *prometheus.GaugeVec
+ histogram prometheus.Histogram
+ currentDateTime libtime.CurrentDateTime
+}
+```
+
+`/workspace/metrics/metrics_test.go` lines 27-31 and 48-60 — the suite setup and the gather-assertion style to follow:
+```go
+ BeforeEach(func() {
+ registry = prometheus.NewRegistry()
+ currentDateTime = libtime.NewCurrentDateTime()
+ m = libmetrics.NewJobMetrics(registry, currentDateTime)
+ })
+```
+```go
+ It("pre-initializes done at zero", func() {
+ mfs, err := registry.Gather()
+ Expect(err).NotTo(HaveOccurred())
+ var counterMF *dto.MetricFamily
+ for _, mf := range mfs {
+ if mf.GetName() == "agent_job_run_total" {
+ counterMF = mf
+ }
+ }
+ Expect(counterMF).NotTo(BeNil(), "agent_job_run_total metric family not found")
+ Expect(counterMF.Metric).To(HaveLen(3), "expected 3 pre-initialized label combinations")
+ })
+```
+The test file already imports `dto "github.com/prometheus/client_model/go"` — use `mf.GetName()`, `mf.GetHelp()`, `metric.Counter.GetValue()`, and `metric.Label` (`lp.GetName()` / `lp.GetValue()`) as it already does.
+
+Prometheus API, grep-verified in `/home/node/go/pkg/mod/github.com/prometheus/client_golang@v1.23.2/prometheus/counter.go` (the version pinned in `/workspace/go.mod`):
+```go
+func NewCounter(opts CounterOpts) Counter // counter.go:87
+func NewCounterVec(opts CounterOpts, labelNames []string) *CounterVec // counter.go:194
+type Counter interface { ...; Inc(); Add(float64) } // counter.go:41,44
+```
+
+`/workspace/metrics/metrics_suite_test.go` carries the `//go:generate go run github.com/maxbrunsfeld/counterfeiter/v6@v6.12.2 -generate` directive that drives the `//counterfeiter:generate` line in `metrics.go`. The fake lands at `/workspace/metrics/mocks/job-metrics.go` (package `mocks`), NOT the repo-root `/workspace/mocks/`.
+
+`/workspace/Makefile` `generate` target: `rm -rf mocks avro; mkdir -p mocks; echo "package mocks" > mocks/mocks.go; go generate -mod=mod ./...`. It wipes the ROOT `mocks/` only; `metrics/mocks/` is regenerated in place.
+
+`grep -rn "JobMetrics" --include="*.go" /workspace` shows no in-repo implementer of the interface other than `jobMetrics` and the generated fake, and no in-repo caller of `NewJobMetrics` outside the tests. Adding an interface method therefore breaks nothing inside this repository.
+
+
+
+1. **Add the token-type label constants to `/workspace/metrics/metrics.go`**, above `NewJobMetrics`. Label values are compile-time constants owned by this package — callers never pass label strings:
+
+ ```go
+ // Token type label values for agent_job_tokens_total. These are compile-time
+ // constants: no caller-supplied or session-supplied value ever becomes a label,
+ // so the family's cardinality is fixed at four series.
+ const (
+ tokenTypeInput = "input"
+ tokenTypeOutput = "output"
+ tokenTypeCacheRead = "cache_read"
+ tokenTypeCacheCreation = "cache_creation"
+ )
+ ```
+
+2. **Add the exported `JobUsage` summary type** to `/workspace/metrics/metrics.go`, above the `JobMetrics` interface. One struct rather than five positional parameters, so call sites cannot silently transpose two token counts:
+
+ ```go
+ // JobUsage is the LLM token and turn summary of one finished agent job.
+ // The zero value is valid and records nothing but zeros.
+ type JobUsage struct {
+ // InputTokens is the count of fresh (non-cached) input tokens the job consumed.
+ InputTokens int64
+ // OutputTokens is the count of output tokens the job produced.
+ OutputTokens int64
+ // CacheReadTokens is the count of input tokens served from the prompt cache.
+ CacheReadTokens int64
+ // CacheCreationTokens is the count of input tokens written into the prompt cache.
+ CacheCreationTokens int64
+ // Turns is the number of conversation turns the job took.
+ Turns int64
+ }
+ ```
+
+3. **Add exactly one method to the `JobMetrics` interface**, keeping the two existing methods and their GoDoc untouched:
+
+ ```go
+ // RecordUsage records the token and turn summary of a finished job: each
+ // token count advances its own type-labelled series and the turn count
+ // advances the turn counter. A negative value is skipped for that counter
+ // only; the other counters in the same call still record.
+ RecordUsage(usage JobUsage)
+ ```
+
+4. **Construct and register the two new collectors in `NewJobMetrics`.** Build them alongside the existing three, register them in the SAME `registry.MustRegister(...)` call, and pre-initialize all five new series to zero next to the existing pre-initialization block:
+
+ ```go
+ tokenCounter := prometheus.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "agent_job_tokens_total",
+ Help: "Total LLM tokens consumed by agent jobs, by token type.",
+ },
+ []string{"type"},
+ )
+ turnCounter := prometheus.NewCounter(
+ prometheus.CounterOpts{
+ Name: "agent_job_turns_total",
+ Help: "Total number of conversation turns taken by agent jobs.",
+ },
+ )
+ registry.MustRegister(counter, gauge, histogram, tokenCounter, turnCounter)
+ ```
+ and, immediately after the three existing `counter.WithLabelValues(...).Add(0)` lines:
+ ```go
+ // Pre-initialize the token series and the turn counter so rate() evaluates to
+ // zero (not no-data) for a process that has not yet run a job.
+ tokenCounter.WithLabelValues(tokenTypeInput).Add(0)
+ tokenCounter.WithLabelValues(tokenTypeOutput).Add(0)
+ tokenCounter.WithLabelValues(tokenTypeCacheRead).Add(0)
+ tokenCounter.WithLabelValues(tokenTypeCacheCreation).Add(0)
+ turnCounter.Add(0)
+ ```
+ Add `tokenCounter *prometheus.CounterVec` and `turnCounter prometheus.Counter` fields to the `jobMetrics` struct and populate them in the returned literal. Update the `NewJobMetrics` GoDoc: it now registers FIVE collectors, not three — keep the rest of that comment (caller-owned registry, MUST NOT be nil, registration failures panic as startup-time programmer errors) verbatim.
+
+ Both names end in `_total` — the Prometheus client panics at registration otherwise, and that panic would hit every consumer at startup.
+
+5. **Implement `RecordUsage` on `*jobMetrics`**, below `RecordDuration`, with a private helper that guards the negative case per counter:
+
+ ```go
+ func (m *jobMetrics) RecordUsage(usage JobUsage) {
+ m.addTokens(tokenTypeInput, usage.InputTokens)
+ m.addTokens(tokenTypeOutput, usage.OutputTokens)
+ m.addTokens(tokenTypeCacheRead, usage.CacheReadTokens)
+ m.addTokens(tokenTypeCacheCreation, usage.CacheCreationTokens)
+ if usage.Turns >= 0 {
+ m.turnCounter.Add(float64(usage.Turns))
+ }
+ }
+
+ // addTokens advances the token counter for one token type. A negative count is
+ // skipped: prometheus.Counter.Add panics on a negative delta, and the counts
+ // originate from a subprocess's stdout, so a hostile or buggy value must not be
+ // able to take the job down.
+ func (m *jobMetrics) addTokens(tokenType string, count int64) {
+ if count < 0 {
+ return
+ }
+ m.tokenCounter.WithLabelValues(tokenType).Add(float64(count))
+ }
+ ```
+ Adding zero is fine and is a no-op on the counter value — do not special-case it.
+
+6. **Regenerate the counterfeiter fake** so `/workspace/metrics/mocks/job-metrics.go` satisfies the widened interface:
+ ```bash
+ cd /workspace && go generate -mod=mod ./metrics/...
+ ```
+ Do not hand-edit the generated file. Confirm with `grep -n 'RecordUsage' /workspace/metrics/mocks/job-metrics.go` (must return at least one line) and that the trailing `var _ metrics.JobMetrics = new(JobMetrics)` assertion still compiles.
+
+7. **Extend `/workspace/metrics/metrics_test.go`** with new `Context` blocks inside the existing `Describe("NewJobMetrics", ...)`, reusing the existing `BeforeEach` (fresh `prometheus.NewRegistry()` per spec). Do NOT modify or delete any existing test. Add a small file-local helper for family lookup to keep each `It` short, e.g.:
+ ```go
+ findFamily := func(name string) *dto.MetricFamily {
+ mfs, err := registry.Gather()
+ Expect(err).NotTo(HaveOccurred())
+ for _, mf := range mfs {
+ if mf.GetName() == name {
+ return mf
+ }
+ }
+ return nil
+ }
+ ```
+ Cover exactly these cases:
+
+ - **AC6 — both families register.** Gather and assert the family names contain `agent_job_tokens_total` and `agent_job_turns_total`.
+ - **AC7a — token pre-initialization.** `findFamily("agent_job_tokens_total").Metric` has `HaveLen(4)`; the collected `type` label values are exactly `input`, `output`, `cache_read`, `cache_creation` (use `ConsistOf`); every `metric.Counter.GetValue()` equals `0.0`.
+ - **AC7b — turn pre-initialization.** `agent_job_turns_total` is present with one metric at value `0.0`.
+ - **AC8 — distinct values per kind.** Call `m.RecordUsage(libmetrics.JobUsage{InputTokens: 11, OutputTokens: 22, CacheReadTokens: 33, CacheCreationTokens: 44, Turns: 5})` and assert the four series equal `11.0`, `22.0`, `33.0`, `44.0` respectively (match on the `type` label value, not on slice index — `Gather()` ordering is not part of the contract) and the turn counter equals `5.0`. Deliberately use four DIFFERENT token values so a transposed field assignment fails the test.
+ - **AC9 — accumulation.** Call `RecordUsage` twice with the same summary and assert every series equals twice its value (`22.0`, `44.0`, `66.0`, `88.0`, turns `10.0`).
+ - **AC10 — negative value is skipped, siblings still record.** Call `m.RecordUsage(libmetrics.JobUsage{InputTokens: -5, OutputTokens: 7, CacheReadTokens: 8, CacheCreationTokens: 9, Turns: 3})` inside `Expect(func() { ... }).NotTo(Panic())`, then assert `input` is still `0.0` while `output` is `7.0`, `cache_read` is `8.0`, `cache_creation` is `9.0`, and turns is `3.0`. Add a second `It` for a negative `Turns` (e.g. `JobUsage{InputTokens: 4, Turns: -1}`): no panic, turns stays `0.0`, `input` is `4.0`.
+ - **AC11 — help-string quality.** 🚨 **Call `m.RecordRun(agentlib.AgentStatusDone)` FIRST, before gathering.** `agent_job_last_run_timestamp_seconds` is a `GaugeVec` with no pre-initialization, so it collects nothing and Prometheus omits the family entirely from `registry.Gather()` until a `RecordRun` call materializes a child. The existing suite already encodes this — `metrics_test.go` asserts `ContainElements("agent_job_run_total", "agent_job_duration_seconds")` and deliberately omits the gauge. Without the priming call, any assertion expecting five families produces a red test. After priming, gather all families; assert every family's `GetHelp()` is non-empty and that the full set of help strings across all five families is pairwise distinct (e.g. collect them into a slice, then assert the deduplicated length equals the slice length).
+ - **AC13 — no regression.** The existing tests already cover `agent_job_run_total` (3 pre-initialized series at `0.0`), the gauge, the histogram, and `BuildJobMetricsName`; they must pass UNMODIFIED. Do not touch them.
+
+8. **Write `/workspace/docs/job-metrics.md`** documenting every metric this package publishes. It must mention at least five distinct `agent_job_` names across at least five lines (the spec checks `grep -c 'agent_job_' docs/job-metrics.md` >= 5). Include:
+ - A one-paragraph intro: these are per-job metrics registered on a caller-owned registry by `metrics.NewJobMetrics` and pushed to the PushGateway under the job name from `metrics.BuildJobMetricsName` (example: `claude-agent` -> `agent_job_claude_agent`), so the per-agent breakdown comes from the push job name, not from a metric label.
+ - A table with columns Metric / Type / Labels / Meaning covering `agent_job_run_total` (counter, `status`), `agent_job_last_run_timestamp_seconds` (gauge, `status`), `agent_job_duration_seconds` (histogram, none), `agent_job_tokens_total` (counter, `type` with the four values), and `agent_job_turns_total` (counter, none).
+ - A short "Pre-initialization" note explaining that every counter series is created at zero at construction so `rate()` evaluates to zero rather than no-data before the first job runs.
+ - A short "Not recorded" note: the Claude CLI's cost figure is deliberately not captured, because under a non-Anthropic base URL the CLI computes it at Anthropic list pricing and the number would be counterfactual. Refer to it as "the CLI's cost field" — do NOT write the literal key name (the spec greps for it under `metrics/`, and keeping the phrasing consistent across docs and code avoids a future copy-paste into either tree).
+ - Keep lines readable; no trailing whitespace.
+
+9. **Add a CHANGELOG entry.** In `/workspace/CHANGELOG.md`, use the `## Unreleased` section immediately after the SemVer preamble and before `## v0.79.0`. If a sibling prompt already created that section, APPEND this bullet to it — do not create a second `## Unreleased` heading:
+ ```markdown
+ - feat: metrics: `JobMetrics` gains `RecordUsage(JobUsage)`, backed by two new pre-initialized counters `agent_job_tokens_total` (label `type`: input, output, cache_read, cache_creation) and `agent_job_turns_total`; negative values are skipped instead of panicking
+ ```
+
+
+
+- Do NOT record the Claude CLI's cost figure, and do NOT add a field for it to `JobUsage`. Under a non-Anthropic base URL that number is computed at Anthropic list pricing and is counterfactual; a wrong number in a cost dashboard is worse than no cost dashboard. The literal key name must not appear anywhere under `/workspace/metrics/` — including comments and test names. Write "the CLI's cost field" instead. (Spec Non-goal, invariant.)
+- Do NOT add an `agent` label to either new metric. The per-agent breakdown already comes free from the PushGateway job name; adding the label would duplicate an existing dimension. (Spec Non-goal, invariant.)
+- Do NOT add a config flag, env var, or opt-out to disable usage recording. Recording is unconditional. (Spec Non-goal.)
+- Do NOT add histograms, summaries, or per-model / per-session labels for tokens. Counters only, exactly two new families, exactly one label on one of them. (Spec Non-goal.)
+- Do NOT wire `RecordUsage` into any consumer binary or call site. The call sites live in a separate repository and are explicit follow-up. (Spec Non-goal.)
+- Do NOT touch `/workspace/claude/` in this prompt — the parser change is a sibling prompt and the two packages share no symbol. `JobUsage` is defined in `metrics` and must NOT import anything from `claude`.
+- Both new counter names MUST end in `_total`; registration panics otherwise and the panic hits every consumer at startup. (Spec Constraint.)
+- Counter pre-initialization with `.Add(0)` is mandatory for all four token label combinations and the turn counter. Without it `rate()` returns no-data instead of zero for unseen series and alerts built on it never fire — neither when healthy nor when broken. (Spec Constraint; `go-prometheus-metrics-guide.md` counter-pre-initialization rule.)
+- Each new family gets its own non-empty help text, distinct from the other new family and from all three pre-existing families. (Spec Constraint; `go-prometheus-metrics-guide.md` help-string-quality rule.)
+- The registry stays caller-owned — do NOT substitute `prometheus.DefaultRegisterer` or any global. Registration failures continue to panic via `MustRegister`; they are startup-time programmer errors. (Spec Constraint.)
+- A negative count must be skipped for that counter only, never passed to `Counter.Add` (which panics), and must not stop the sibling counters in the same call from recording. Negative is the only unrepresentable case reachable from integer wire fields — do NOT add handling for NaN, overflow, or other float pathologies. (Spec Desired Behavior 7, Security.)
+- Everything already published stays byte-identical in name, label set, and semantics: `agent_job_run_total`, `agent_job_last_run_timestamp_seconds`, `agent_job_duration_seconds`, and `BuildJobMetricsName` are untouched. The existing tests must pass unmodified. (Spec Desired Behavior 8.)
+- The counterfeiter fake at `/workspace/metrics/mocks/job-metrics.go` must be regenerated (not hand-edited) so it satisfies the widened interface. (Spec Constraint.)
+- Metric and label naming stays under the existing `agent_job_` family prefix. (Spec Constraint.)
+- Every new exported type, field, and method carries a GoDoc comment. (Spec Constraint.)
+- Error handling stays on `github.com/bborbe/errors` with context wrapping if any error path is introduced (none is expected here — recording is in-memory counter arithmetic and returns nothing). No `fmt.Errorf`, no bare `return err`. (Spec Constraint.)
+- Tests are Ginkgo v2 / Gomega in the external `metrics_test` package, asserting via `registry.Gather()` on a fresh `prometheus.NewRegistry()` per spec. (Spec Constraint.)
+- Coverage for the changed package must be >= 80%; the negative-value tests are what cover the `count < 0` and `Turns < 0` branches.
+- Line length limit is 100 characters (golines runs in `make format`).
+- Do NOT commit — dark-factory handles git.
+
+
+
+```bash
+# Package tests — AC6 through AC13.
+cd /workspace && go test -mod=mod -race ./metrics/... 2>&1 | tail -20
+# Must report ok / PASS.
+```
+
+```bash
+# Coverage for the changed package.
+cd /workspace && go test -coverprofile=/tmp/cover.out -mod=mod ./metrics/... && go tool cover -func=/tmp/cover.out | grep -E 'RecordUsage|addTokens|NewJobMetrics'
+# RecordUsage and addTokens must be >= 80%.
+```
+
+```bash
+# AC14 — the fake implements the new method.
+grep -n 'RecordUsage' /workspace/metrics/mocks/job-metrics.go
+# Must return at least one line.
+```
+
+```bash
+# AC15 — the CLI's cost field must not have entered the tree.
+! grep -rq 'total_cost_usd' /workspace/metrics/
+# Must return zero lines (exit 1).
+```
+
+```bash
+# AC — both new families are declared in the source.
+grep -n 'agent_job_tokens_total\|agent_job_turns_total' /workspace/metrics/metrics.go
+# Must return AT LEAST 2 lines (the two Name: fields). More is expected and fine —
+# requirement 1 mandates a const comment that also names agent_job_tokens_total.
+# Do NOT delete that comment to make a count match.
+```
+
+```bash
+# Docs coverage.
+grep -c 'agent_job_' /workspace/docs/job-metrics.md
+# Must return >= 5.
+```
+
+```bash
+# Changelog entry present.
+grep -n -A6 '## Unreleased' /workspace/CHANGELOG.md | grep -iE 'token|turn'
+# Must return at least one line.
+```
+
+```bash
+# Final full validation at the repository root.
+cd /workspace && make precommit
+# Must exit 0.
+```
+
diff --git a/specs/in-progress/046-job-usage-metrics.md b/specs/in-progress/046-job-usage-metrics.md
new file mode 100644
index 0000000..dea3c98
--- /dev/null
+++ b/specs/in-progress/046-job-usage-metrics.md
@@ -0,0 +1,129 @@
+---
+status: verifying
+tags:
+ - dark-factory
+ - spec
+approved: "2026-08-01T21:52:23Z"
+generating: "2026-08-01T21:52:24Z"
+prompted: "2026-08-01T22:06:19Z"
+verifying: "2026-08-01T22:31:54Z"
+branch: dark-factory/job-usage-metrics
+---
+
+## Summary
+
+- Capture the LLM token counts and turn count that the Claude CLI already reports at the end of every agent session, instead of discarding them.
+- Surface those five numbers on the parsed session result so any caller can read them.
+- Add one recording method to the shared job-metrics interface that feeds two new counters: tokens by kind, and conversation turns.
+- Both counters are pre-initialized at zero so fleet dashboards and alerts work before the first job runs.
+- Deliberately does NOT record the CLI's reported dollar cost — under a non-Anthropic provider that number is fiction.
+
+## Problem
+
+The subscription decision between two LLM plans (MiniMax, sold as requests per rolling 5-hour window; GLM, sold as prompts per 5-hour window with a 2–3x multiplier) is blocked on a single unknown: how much the agent fleet actually consumes. Nobody can compare the plans, size a quota, or predict a throttle, because the fleet's real burn rate has never been measured. The measurement data already arrives — the Claude CLI emits a token and turn summary at the end of every session — and the code throws it away while parsing. A Prometheus PushGateway is already wired up and already receives per-job run, status, and duration metrics from the same code path, so the only thing missing is keeping the numbers and pushing them.
+
+## Goal
+
+After every agent job that runs a Claude Code session, the tokens it consumed (split into fresh input, output, cache-read, and cache-creation) and the number of conversation turns it took are available as Prometheus counters on the existing per-job push job, alongside the run/status/duration metrics already published. Summing those counters across the fleet over a rolling window answers "how many requests and how many tokens per 5 hours" without any new infrastructure.
+
+## Non-goals
+
+- Do NOT record the CLI's `total_cost_usd`. The Claude CLI computes cost at Anthropic list pricing; pointed at a different provider's base URL it reports a counterfactual number, not money actually spent. Seeding a wrong number into a cost dashboard is worse than having no cost dashboard. Tokens and turns are provider-neutral; cost is not. Invariant — if a future consumer needs cost, it must come from the provider's own billing data, and that is a separate spec.
+- Do NOT wire the new recording call into consumer binaries. The call sites live in a separate repository and are an explicit follow-up.
+- Do NOT touch the Pi harness — it does not run Claude Code and emits no usage summary.
+- Do NOT create Grafana dashboards, recording rules, or alerting rules.
+- Do NOT provision or reconfigure any infrastructure. The PushGateway and the per-job push wiring already exist.
+- Do NOT add an `agent` label to the new metrics. Per-agent breakdown already comes free from the PushGateway job name. Invariant — adding it would duplicate an existing dimension; if a future consumer needs a different breakdown, that's a separate spec.
+- Do NOT add a config flag, env var, or opt-out to disable usage recording. Recording is unconditional — an escape hatch on the goal is a regression. If a future deployment genuinely must suppress it, that's a separate spec.
+- Do NOT add histograms, summaries, or per-model/per-session labels for tokens. Counters only.
+
+## Desired Behavior
+
+1. While parsing the CLI's streamed session output, the terminal result event's usage summary is captured: fresh input tokens, output tokens, cache-creation input tokens, cache-read input tokens, and the session's turn count. All five are non-negative integers.
+2. The parsed session result — the `ClaudeResult` type returned by the runner, today carrying only `Result string` — exposes those five values to its caller. Field naming and JSON tags on `ClaudeResult` are agent-decided at impl time, but the wire names read from the CLI output are fixed by the CLI: `usage.input_tokens`, `usage.output_tokens`, `usage.cache_creation_input_tokens`, `usage.cache_read_input_tokens`, and top-level `num_turns`. Widening the unexported `scanOutput` signature to carry the values out is expected.
+3. A session whose terminal result event carries no usage object, or omits individual usage fields, still parses successfully: the missing values read as 0 and the session result text is returned exactly as today. Absent usage is never an error and never aborts a run.
+4. When more than one result event carries a usage object, the last usage object wins. **This is independent of the result text's own last-wins rule.** Today `claude-runner.go` gates result-text capture on `event.Type == "result" && event.Result != ""`; usage capture must NOT be folded into that same condition. A second result event carrying a usage object but an empty `result` string updates the usage values while leaving the previously captured result text intact.
+5. The shared job-metrics interface gains one method, named `RecordUsage`, that records a full usage summary for a finished job in a single call: the four token counts and the turn count. Callers never pass metric label strings; the label values are internal to the metrics package.
+6. Constructing the job metrics registers two additional collectors on the caller-owned registry: a token counter named `agent_job_tokens_total` carrying exactly one label `type` with the four values `input`, `output`, `cache_read`, `cache_creation`, and an unlabeled turn counter named `agent_job_turns_total`. Each has its own non-empty, distinct help text. All four token label combinations and the turn counter are pre-initialized to zero at construction, before any job has run.
+7. Recording a usage summary increases each token counter by its matching count and the turn counter by the turn count. A negative value is skipped for that counter — the process never panics and the other counters in the same call still record. Negative is the only unrepresentable case reachable from integer wire fields; do not add handling for NaN, overflow, or other float pathologies.
+8. Everything already published stays byte-identical in name, label set, and semantics: the run counter, the last-run timestamp gauge, and the duration histogram are untouched, as is the PushGateway job-name helper.
+
+## Constraints
+
+- Error handling follows the repo convention: `github.com/bborbe/errors` with context wrapping. No `fmt.Errorf`, no bare `return err`.
+- Tests follow the conventions already present in the touched packages: Ginkgo v2 / Gomega, Counterfeiter fakes, registry assertions via `registry.Gather()` on a fresh `prometheus.NewRegistry()`.
+- The `claude` package is tested externally (`package claude_test`); the parser is unexported and is reached through the PATH shell shim already established by `writeShim` in `claude/claude-runner_test.go`. Use that existing shim pattern — do not add an in-package `package claude` test file to reach `scanOutput` directly.
+- Every new exported type, field, method, and function carries a GoDoc comment.
+- Both new counter names end in `_total`. Registration panics otherwise, and the panic happens at startup in every consumer.
+- Counter pre-initialization with `.Add(0)` is mandatory, following the pattern already used for the run counter's terminal statuses. Without it `rate()` returns no-data rather than zero for unseen label values, and alerts built on it silently never fire — neither when healthy nor when broken. Reference: `docs/dod.md`, and the counter-pre-initialization and help-string-quality rules in the Go Prometheus metrics guide at `~/Documents/workspaces/coding/docs/go-prometheus-metrics-guide.md`.
+- Adding a method to the job-metrics interface is a breaking change for external implementers. The Counterfeiter fake in the repo must be regenerated so it satisfies the widened interface.
+- The registry is caller-owned and must not be swapped for a global/default registry. Registration failures continue to panic — they are startup-time programmer errors.
+- Metric and label naming stays consistent with the existing family prefix `agent_job_`.
+
+## Failure Modes
+
+| Trigger | Detection | Expected behavior | Recovery | Reversibility | Concurrency |
+|---|---|---|---|---|---|
+| CLI emits a terminal result event with no usage object | Token counters stay flat while the run counter advances | Parse succeeds, all five values are 0, result text returned unchanged | None needed; investigate CLI version if persistent | Reversible (read-only parse) | n/a |
+| CLI renames or drops a usage field (schema drift, e.g. a new CLI version) | The affected token series stops growing while sibling series keep growing | The unknown field is ignored, the affected value reads 0, the run still succeeds | Update the parser for the new field name | Reversible | n/a |
+| A usage value arrives negative or non-numeric | The affected counter does not advance; other counters in the same call do | That single counter increment is skipped; no panic, no error returned | None needed; the sibling values remain trustworthy | Reversible | n/a |
+| A stream line is malformed JSON | Existing behavior: the line is skipped during scanning | Unchanged from today — malformed lines never abort parsing | None needed | Reversible | n/a |
+| Job crashes or is killed before the terminal result event | No usage series for that push job in the window | No usage is recorded for that run; the run/status metrics behave exactly as today | Rerun the job | Irreversible for that run's usage (the numbers are lost) | Mid-run crash records nothing rather than a partial summary |
+| PushGateway unreachable when the finished job pushes | Existing push-error log line; the job's series is missing from the gateway | Unchanged from today — the push failure is non-fatal and the job still reports its result | Gateway restored; next run's push lands | Irreversible for that run's sample | Two jobs pushing under the same job name overwrite each other's grouping — unchanged from today, not introduced here |
+| Provider rate-limits or refuses the session before any turn completes | No terminal result event | Same path as "crashes before the terminal result event": no usage recorded, run status still published | Rerun after the limit resets | Irreversible for that run's usage | n/a |
+| Extremely large token count in a single session | Counter value visible in the gateway | Recorded as-is; a float64 counter represents integers exactly up to 2^53, far above any real session | None needed | n/a | n/a |
+| Duplicate registration of the new collectors on one registry | Panic at construction, before any job work | Fail fast at startup, as today for the existing collectors | Fix the wiring that registered twice | Reversible | Construction is once-per-process |
+| Clock skew across fleet nodes | Timestamps on pushed samples disagree | Counters are monotonic and skew-insensitive; only the existing last-run gauge is time-sensitive and it is untouched | None needed | n/a | n/a |
+
+## Security / Abuse Cases
+
+- The parsed usage numbers come from a subprocess's stdout — a trust boundary. They are consumed as integers only: they never become label values, file paths, log format strings, or map keys, so there is no injection or cardinality-explosion vector. The `type` label values are compile-time constants in the metrics package.
+- Label cardinality is bounded at exactly four token series plus one turn series per push job, fixed at compile time. No user-controlled or session-controlled value ever becomes a label.
+- A hostile or buggy subprocess can only report wrong numbers (too large, negative, zero). Negative values are skipped rather than passed to a counter, which would otherwise panic and take the job down — a denial-of-service path from untrusted stdout.
+- No new network calls, no new files read or written, no new user input surface, no unbounded retry or wait: parsing remains a single forward pass over already-bounded scanner input, and recording is in-memory counter arithmetic.
+- Token counts are not secrets and reveal no prompt content.
+
+## Acceptance Criteria
+
+- [ ] A stream-json fixture whose terminal result event carries `usage` with all four token fields plus `num_turns` parses into a session result exposing exactly those five values — evidence: Ginkgo test in the `claude` package asserting each of the five values equals its fixture value; `make test` exits 0.
+- [ ] A terminal result event with no `usage` object and no `num_turns` parses with all five values 0 and the result text unchanged — evidence: Ginkgo test asserting result text equality and five zero values; test does not expect an error.
+- [ ] A terminal result event with only some usage fields present parses with the present fields set and the absent fields 0 — evidence: Ginkgo test asserting the mixed expectation.
+- [ ] When two result events carry usage, the last one's values are the ones exposed — evidence: Ginkgo test feeding two result lines and asserting the second event's numbers.
+- [ ] Usage last-wins is independent of result-text last-wins: a second result event carrying a usage object but an empty `result` string updates the usage values while the result text stays the first event's non-empty text — evidence: Ginkgo test feeding a first result event with text + usage and a second with `"result": ""` + different usage, asserting the first event's text and the second event's usage numbers.
+- [ ] A freshly constructed job-metrics registers both new families — evidence: test calling `registry.Gather()` and asserting the returned family names contain `agent_job_tokens_total` and `agent_job_turns_total`.
+- [ ] Before any recording call, `agent_job_tokens_total` has exactly 4 series with `type` label values `input`, `output`, `cache_read`, `cache_creation`, each at value `0.0`, and `agent_job_turns_total` is present at `0.0` — evidence: test asserting `HaveLen(4)`, the label-value set, and every value equal to `0.0`.
+- [ ] Recording a usage summary with distinct values per token kind advances each series by exactly its own value and the turn counter by the turn count — evidence: test recording a summary with four different token values and asserting each gathered series value, plus the turn counter value.
+- [ ] Recording twice accumulates — evidence: test recording the same summary twice and asserting each series equals twice its value.
+- [ ] Recording a summary containing a negative value does not panic, leaves that series unchanged, and still advances the other series in the same call — evidence: test asserting no panic and the per-series expectations.
+- [ ] Both new metric families expose non-empty help text, and the two help strings differ from each other and from every pre-existing family's help text — evidence: test gathering all families and asserting help strings are non-empty and pairwise distinct.
+- [ ] The pre-existing families are unchanged: `agent_job_run_total` still exposes 3 pre-initialized status series at `0.0`, `agent_job_last_run_timestamp_seconds` and `agent_job_duration_seconds` still register, and the push-job-name helper still returns `agent_job_claude_agent` for input `claude-agent` — evidence: the existing package tests pass unmodified; `make test` exits 0.
+- [ ] The Counterfeiter fake for the job-metrics interface implements the new recording method — evidence: `grep -n 'RecordUsage' metrics/mocks/job-metrics.go` returns at least one line. (The counterfeiter directive is `-o mocks/job-metrics.go` relative to the `metrics` package, so the fake lives at `metrics/mocks/`, NOT the repo-root `mocks/`.)
+- [ ] The CLI's cost field is never parsed or recorded — evidence: `grep -rn 'total_cost_usd' claude/ metrics/` returns no lines (exit 1). The literal string `total_cost_usd` must not appear anywhere under `claude/` or `metrics/`, **including comments and test names** — when referring to it in prose or GoDoc, write "the CLI's cost field" instead.
+- [ ] `CHANGELOG.md` has a bullet under a `## Unreleased` heading describing the new usage metrics — evidence: `grep -n -A5 '## Unreleased' CHANGELOG.md | grep -iE 'token|turn'` returns at least one line.
+- [ ] The two new metric families are documented — evidence: `docs/job-metrics.md` exists and `grep -c 'agent_job_' docs/job-metrics.md` returns at least 5 (the two new families plus the three pre-existing ones, which are also currently undocumented).
+- [ ] `make precommit` exits 0 at the repository root — evidence: exit code.
+
+**Scenario coverage — no new scenario.** Every behavior above is reachable by unit tests against an in-memory Prometheus registry and a string fixture of CLI output. Nothing here requires a real CLI, a real gateway, or a cluster, and no existing user journey changes.
+
+## Verification
+
+```
+make precommit
+grep -n 'RecordUsage' metrics/mocks/job-metrics.go
+grep -n -A5 '## Unreleased' CHANGELOG.md | grep -iE 'token|turn'
+grep -c 'agent_job_' docs/job-metrics.md
+grep -rn 'total_cost_usd' claude/ metrics/ # expect no lines, exit 1
+```
+
+## Suggested Decomposition
+
+| # | Prompt focus | Covers DBs | Covers ACs | Depends on |
+|---|---|---|---|---|
+| 1 | Parse the terminal result event's usage summary and turn count in the `claude` package; surface the five values on `ClaudeResult`; tests for full / absent / partial / duplicate-event / empty-text-second-event cases | 1, 2, 3, 4 | 1–5, 14 (partial), 16 | — |
+| 2 | Add `RecordUsage` and the two counters to the `metrics` package, pre-initialize them, regenerate the fake, write `docs/job-metrics.md`; tests for registration, pre-init, accumulation, negative input, help-string quality, no regression of existing families | 5, 6, 7, 8 | 6–13, 14 (partial), 15, 16 | — |
+
+Rationale: the two prompts touch disjoint packages and share no symbol, so they are independent and can run in either order or in parallel. Splitting them keeps each prompt inside one package with one test suite. Both must land before the follow-up work in the consumer repository can call the new method, but that wiring is out of scope here.
+
+## Do-Nothing Option
+
+Doing nothing keeps the fleet's burn rate unmeasured, so the plan comparison stays a guess: MiniMax's requests-per-5h and GLM's prompts-per-5h cannot be converted into a common unit without knowing how many sessions and turns the fleet actually produces. The alternative to instrumenting is scraping historical CLI output or job logs after the fact, which is manual, retrospective, and gives no ongoing signal for quota alerting. The measurement data is already present in the parsed stream and the push path already exists, so the cost of capturing it is small and the cost of continuing to discard it is a subscription decision made blind.