kankaku 0.1.0 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { SegmentRule } from "./domain/segment-rule.ts";
2
+
1
3
  export interface KankakuConfig {
2
4
  /** Directory for the work log, relative to the project cwd unless absolute. */
3
5
  dir: string;
@@ -5,12 +7,68 @@ export interface KankakuConfig {
5
7
  interactiveTools: string[];
6
8
  /** Tool name used to run subagents. */
7
9
  subagentTool: string;
10
+ /** Rules that tag a tool execution's span under a named segment (e.g. `review`). */
11
+ segmentRules: SegmentRule[];
12
+ /** Default billing client for this project, from `KANKAKU_CLIENT`. See `domain/client-label.ts`. */
13
+ client?: string;
8
14
  }
9
15
 
10
16
  const DEFAULT_DIR = ".kankaku";
11
17
  const DEFAULT_INTERACTIVE_TOOLS = ["ask_user_question", "ask_user_choice"];
12
18
  const SUBAGENT_TOOL = "subagent_run";
13
19
 
20
+ /**
21
+ * Default segment rule: with gentle-ai, the review-with-receipts step runs
22
+ * as `gentle-ai review ...` commands through the `bash` tool, so tag that
23
+ * span `review`.
24
+ */
25
+ const DEFAULT_SEGMENT_RULES: SegmentRule[] = [{ tag: "review", tool: "bash", pattern: /\bgentle-ai review\b/ }];
26
+
27
+ /** Tags allowed for a segment rule: letters, digits, `_` and `-`, 1-32 chars. */
28
+ const SAFE_TAG = /^[A-Za-z0-9_-]{1,32}$/;
29
+ /** Property names that behave specially on a plain object; never usable as a tag. */
30
+ const RESERVED_TAGS = new Set(["__proto__", "constructor", "prototype"]);
31
+
32
+ function isSafeTag(tag: string): boolean {
33
+ return SAFE_TAG.test(tag) && !RESERVED_TAGS.has(tag);
34
+ }
35
+
36
+ /**
37
+ * Parse `KANKAKU_SEGMENTS`, a `;`-separated list of `tag=tool:regex`
38
+ * entries (example: `review=bash:gentle-ai review;commit=bash:git commit`).
39
+ * Malformed entries (missing tag, tool or regex, an invalid regex source,
40
+ * or a tag that is not a safe identifier such as `__proto__`) are skipped
41
+ * rather than failing the whole variable.
42
+ */
43
+ function parseSegmentRules(raw: string): SegmentRule[] {
44
+ const rules: SegmentRule[] = [];
45
+
46
+ for (const entry of raw.split(";")) {
47
+ const trimmed = entry.trim();
48
+ if (!trimmed) continue;
49
+
50
+ const eqIndex = trimmed.indexOf("=");
51
+ if (eqIndex <= 0) continue;
52
+
53
+ const tag = trimmed.slice(0, eqIndex).trim();
54
+ const rest = trimmed.slice(eqIndex + 1);
55
+ const colonIndex = rest.indexOf(":");
56
+ if (colonIndex <= 0) continue;
57
+
58
+ const tool = rest.slice(0, colonIndex).trim();
59
+ const regexSource = rest.slice(colonIndex + 1).trim();
60
+ if (!tag || !tool || !regexSource || !isSafeTag(tag)) continue;
61
+
62
+ try {
63
+ rules.push({ tag, tool, pattern: new RegExp(regexSource) });
64
+ } catch {
65
+ continue;
66
+ }
67
+ }
68
+
69
+ return rules;
70
+ }
71
+
14
72
  export function loadConfig(env: NodeJS.ProcessEnv = process.env): KankakuConfig {
15
73
  const dir = env["KANKAKU_DIR"]?.trim() || DEFAULT_DIR;
16
74
  const interactiveToolsRaw = env["KANKAKU_INTERACTIVE_TOOLS"]?.trim();
@@ -21,10 +79,17 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): KankakuConfig
21
79
  .filter((tool) => tool.length > 0)
22
80
  : DEFAULT_INTERACTIVE_TOOLS;
23
81
 
82
+ const segmentsRaw = env["KANKAKU_SEGMENTS"]?.trim();
83
+ const segmentRules = segmentsRaw ? parseSegmentRules(segmentsRaw) : DEFAULT_SEGMENT_RULES;
84
+
85
+ const client = env["KANKAKU_CLIENT"]?.trim() || undefined;
86
+
24
87
  return {
25
88
  dir,
26
89
  interactiveTools,
27
90
  subagentTool: SUBAGENT_TOOL,
91
+ segmentRules,
92
+ ...(client !== undefined ? { client } : {}),
28
93
  };
29
94
  }
30
95
 
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Client (billing target) label resolution: pure, no I/O.
3
+ *
4
+ * A client name identifies who a piece of work is billed to. It can come
5
+ * from three sources, in decreasing precedence: the pi session (set with
6
+ * `/kankaku client <name>`), the `KANKAKU_CLIENT` environment variable, or
7
+ * the project's `.kankaku/config.json`.
8
+ */
9
+
10
+ /** Safe client name: letters, digits, `.`, `_`, `-`, 1-64 chars. */
11
+ const CLIENT_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
12
+
13
+ /** Property names that behave specially on a plain object; never usable as a client name. */
14
+ const RESERVED_NAMES = new Set(["__proto__", "constructor", "prototype"]);
15
+
16
+ export interface ClientSources {
17
+ /** Set for the current pi session via `/kankaku client <name>`. Highest precedence. */
18
+ session?: string;
19
+ /** From the `KANKAKU_CLIENT` environment variable. */
20
+ env?: string;
21
+ /** From the project's `.kankaku/config.json`. Lowest precedence. */
22
+ project?: string;
23
+ }
24
+
25
+ export type ClientSourceName = "session" | "env" | "project";
26
+
27
+ /** `true` when `value` is a non-empty, safe client name. */
28
+ export function isValidClient(value: string): boolean {
29
+ return CLIENT_PATTERN.test(value) && !RESERVED_NAMES.has(value);
30
+ }
31
+
32
+ /** Trim and validate a candidate client name; `undefined` when absent or invalid. */
33
+ function normalize(value: string | undefined): string | undefined {
34
+ if (value === undefined) return undefined;
35
+ const trimmed = value.trim();
36
+ if (trimmed.length === 0) return undefined;
37
+ return isValidClient(trimmed) ? trimmed : undefined;
38
+ }
39
+
40
+ /**
41
+ * Resolve the effective client name from `session`, `env` and `project`
42
+ * sources, in that precedence order. Each candidate is trimmed; an empty
43
+ * string or a value that does not match the safe client-name pattern is
44
+ * treated as absent and resolution falls through to the next source.
45
+ */
46
+ export function resolveClient(sources: ClientSources): string | undefined {
47
+ return normalize(sources.session) ?? normalize(sources.env) ?? normalize(sources.project);
48
+ }
49
+
50
+ /** Which source produced {@link resolveClient}'s result, or `undefined` when none applies. */
51
+ export function resolveClientSource(sources: ClientSources): ClientSourceName | undefined {
52
+ if (normalize(sources.session) !== undefined) return "session";
53
+ if (normalize(sources.env) !== undefined) return "env";
54
+ if (normalize(sources.project) !== undefined) return "project";
55
+ return undefined;
56
+ }
@@ -0,0 +1,8 @@
1
+ /** Local (not UTC) calendar day of an ISO timestamp, as `YYYY-MM-DD`. */
2
+ export function localDay(iso: string): string {
3
+ const date = new Date(iso);
4
+ const year = date.getFullYear();
5
+ const month = String(date.getMonth() + 1).padStart(2, "0");
6
+ const day = String(date.getDate()).padStart(2, "0");
7
+ return `${year}-${month}-${day}`;
8
+ }
@@ -0,0 +1,107 @@
1
+ import { localDay } from "./day.ts";
2
+ import { finiteOrZero } from "./work-record.ts";
3
+ import type { WorkStatus } from "./work-record.ts";
4
+ import type { TaskView } from "./task-view.ts";
5
+
6
+ /** One flat, spreadsheet-friendly row per {@link TaskView}. */
7
+ export interface ExportRow {
8
+ id: string;
9
+ /** Local calendar day (`YYYY-MM-DD`) the task started on. */
10
+ day: string;
11
+ startedAt: string;
12
+ endedAt: string;
13
+ /** Empty string when the task has no resolved client. */
14
+ client: string;
15
+ /** Empty string when the orchestrator record has no session name. */
16
+ sessionName: string;
17
+ /** Empty string when the orchestrator record has no session id. */
18
+ sessionId: string;
19
+ project: string;
20
+ status: WorkStatus;
21
+ /** First 200 chars of the task's prompt, with newlines collapsed to spaces. */
22
+ prompt: string;
23
+ wallMs: number;
24
+ waitingMs: number;
25
+ workMs: number;
26
+ cost: number;
27
+ tokensIn: number;
28
+ tokensOut: number;
29
+ cacheRead: number;
30
+ subagentCount: number;
31
+ /** JSON-encoded `TaskView.segments` map. */
32
+ segments: string;
33
+ /** Empty string when the orchestrator record has no model. */
34
+ model: string;
35
+ }
36
+
37
+ /** First 200 chars of `prompt`, with `\n`/`\r` collapsed to a single space. */
38
+ function truncatePrompt(prompt: string): string {
39
+ return prompt.slice(0, 200).replace(/\r\n|\r|\n/g, " ");
40
+ }
41
+
42
+ /** Build one flat {@link ExportRow} per task, in the same order as `tasks`. */
43
+ export function exportRows(tasks: TaskView[]): ExportRow[] {
44
+ return tasks.map((task) => ({
45
+ id: task.id,
46
+ day: localDay(task.startedAt),
47
+ startedAt: task.startedAt,
48
+ endedAt: task.endedAt,
49
+ client: task.client ?? "",
50
+ sessionName: task.sessionName ?? "",
51
+ sessionId: task.sessionId ?? "",
52
+ project: task.project,
53
+ status: task.status,
54
+ prompt: truncatePrompt(task.prompt),
55
+ wallMs: task.wallMs,
56
+ waitingMs: task.waitingMs,
57
+ workMs: task.workMs,
58
+ cost: finiteOrZero(task.usage.cost),
59
+ tokensIn: finiteOrZero(task.usage.input),
60
+ tokensOut: finiteOrZero(task.usage.output),
61
+ cacheRead: finiteOrZero(task.usage.cacheRead),
62
+ subagentCount: task.subagents.length,
63
+ segments: JSON.stringify(task.segments),
64
+ model: task.orchestrator.model ?? "",
65
+ }));
66
+ }
67
+
68
+ /** Column order for {@link toCsv}'s header row, matching {@link ExportRow}'s field order. */
69
+ const COLUMNS: Array<keyof ExportRow> = [
70
+ "id",
71
+ "day",
72
+ "startedAt",
73
+ "endedAt",
74
+ "client",
75
+ "sessionName",
76
+ "sessionId",
77
+ "project",
78
+ "status",
79
+ "prompt",
80
+ "wallMs",
81
+ "waitingMs",
82
+ "workMs",
83
+ "cost",
84
+ "tokensIn",
85
+ "tokensOut",
86
+ "cacheRead",
87
+ "subagentCount",
88
+ "segments",
89
+ "model",
90
+ ];
91
+
92
+ /** RFC 4180 field quoting: quote a field containing `,`, `"`, or a newline; double any embedded quote. */
93
+ function csvField(value: string | number): string {
94
+ const text = String(value);
95
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
96
+ }
97
+
98
+ /** Render `rows` as RFC 4180 CSV: a header row, one row per record, `\n` line endings. */
99
+ export function toCsv(rows: ExportRow[]): string {
100
+ const lines = [COLUMNS.join(","), ...rows.map((row) => COLUMNS.map((column) => csvField(row[column])).join(","))];
101
+ return lines.join("\n");
102
+ }
103
+
104
+ /** Render `rows` as pretty-printed (2-space indent) JSON. */
105
+ export function toJson(rows: ExportRow[]): string {
106
+ return JSON.stringify(rows, null, 2);
107
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * A rule that tags a tool execution as belonging to a named segment (e.g.
3
+ * `review`) when the tool name matches `tool` and the tool's argument text
4
+ * matches `pattern`.
5
+ */
6
+ export interface SegmentRule {
7
+ tag: string;
8
+ tool: string;
9
+ pattern: RegExp;
10
+ }
@@ -1,5 +1,5 @@
1
1
  import { unionMs } from "./intervals.ts";
2
- import { emptyUsage } from "./work-record.ts";
2
+ import { emptyUsage, finiteOrZero } from "./work-record.ts";
3
3
  import type { UsageTotals, WorkRecord, WorkStatus } from "./work-record.ts";
4
4
 
5
5
  /**
@@ -22,6 +22,17 @@ export interface TaskView {
22
22
  orchestrator: WorkRecord;
23
23
  subagents: WorkRecord[];
24
24
  usage: UsageTotals;
25
+ /** Who this task is billed to, from the orchestrator record only — subagent children do not carry their own. */
26
+ client?: string;
27
+ /** pi's session display name, from the orchestrator record. */
28
+ sessionName?: string;
29
+ /**
30
+ * Per-tag total milliseconds across the orchestrator and every subagent,
31
+ * summed rather than unioned: unlike `wallMs`, segment intervals are not
32
+ * persisted on disk, so once a record settles its per-tag total is all
33
+ * that remains, and there is nothing left to union across records.
34
+ */
35
+ segments: Record<string, number>;
25
36
  }
26
37
 
27
38
  /** One or more tasks grouped by their pi session, with the same union rule. */
@@ -35,20 +46,39 @@ export interface SessionView {
35
46
  workMs: number;
36
47
  tasks: TaskView[];
37
48
  usage: UsageTotals;
49
+ /** Per-tag total milliseconds summed across the session's tasks. See {@link TaskView.segments}. */
50
+ segments: Record<string, number>;
38
51
  }
39
52
 
40
53
  function toMs(iso: string): number {
41
54
  return Date.parse(iso);
42
55
  }
43
56
 
44
- function sumUsage(totals: UsageTotals[]): UsageTotals {
57
+ /** Sum per-tag milliseconds across several segment maps (older records without one count as `{}`). */
58
+ function sumSegments(segmentMaps: Array<Record<string, number> | undefined>): Record<string, number> {
59
+ const result: Record<string, number> = {};
60
+ for (const segments of segmentMaps) {
61
+ for (const [tag, ms] of Object.entries(segments ?? {})) {
62
+ result[tag] = (result[tag] ?? 0) + ms;
63
+ }
64
+ }
65
+ return result;
66
+ }
67
+
68
+ /**
69
+ * Sum several {@link UsageTotals}, tolerating a missing entry (a record
70
+ * without a `usage` field) and missing or non-finite numeric fields on an
71
+ * entry — both treated as zero rather than corrupting the sum with
72
+ * `undefined`/`NaN`.
73
+ */
74
+ export function sumUsage(totals: Array<UsageTotals | undefined>): UsageTotals {
45
75
  const usage = emptyUsage();
46
76
  for (const total of totals) {
47
- usage.input += total.input;
48
- usage.output += total.output;
49
- usage.cacheRead += total.cacheRead;
50
- usage.cacheWrite += total.cacheWrite;
51
- usage.cost += total.cost;
77
+ usage.input += finiteOrZero(total?.input);
78
+ usage.output += finiteOrZero(total?.output);
79
+ usage.cacheRead += finiteOrZero(total?.cacheRead);
80
+ usage.cacheWrite += finiteOrZero(total?.cacheWrite);
81
+ usage.cost += finiteOrZero(total?.cost);
52
82
  }
53
83
  return usage;
54
84
  }
@@ -117,10 +147,13 @@ function buildTaskView(orchestrator: WorkRecord, subagents: WorkRecord[]): TaskV
117
147
  const waitingMs = orchestrator.waitingMs;
118
148
  const workMs = wallMs - waitingMs;
119
149
  const usage = sumUsage([orchestrator.usage, ...subagents.map((child) => child.usage)]);
150
+ const segments = sumSegments([orchestrator.segments, ...subagents.map((child) => child.segments)]);
120
151
 
121
152
  return {
122
153
  id: orchestrator.id,
123
154
  ...(orchestrator.sessionId !== undefined ? { sessionId: orchestrator.sessionId } : {}),
155
+ ...(orchestrator.client !== undefined ? { client: orchestrator.client } : {}),
156
+ ...(orchestrator.sessionName !== undefined ? { sessionName: orchestrator.sessionName } : {}),
124
157
  project: orchestrator.project,
125
158
  prompt: orchestrator.prompt,
126
159
  startedAt: orchestrator.startedAt,
@@ -132,6 +165,7 @@ function buildTaskView(orchestrator: WorkRecord, subagents: WorkRecord[]): TaskV
132
165
  orchestrator,
133
166
  subagents,
134
167
  usage,
168
+ segments,
135
169
  };
136
170
  }
137
171
 
@@ -182,6 +216,7 @@ export function buildSessions(tasks: TaskView[]): SessionView[] {
182
216
  const startedAtMs = Math.min(...sessionTasks.map((task) => toMs(task.startedAt)));
183
217
  const endedAtMs = Math.max(...sessionTasks.map((task) => toMs(task.endedAt)));
184
218
  const usage = sumUsage(sessionTasks.map((task) => task.usage));
219
+ const segments = sumSegments(sessionTasks.map((task) => task.segments));
185
220
 
186
221
  sessions.push({
187
222
  sessionId,
@@ -193,6 +228,7 @@ export function buildSessions(tasks: TaskView[]): SessionView[] {
193
228
  workMs,
194
229
  tasks: sessionTasks,
195
230
  usage,
231
+ segments,
196
232
  });
197
233
  }
198
234
 
@@ -38,6 +38,13 @@ export interface WorkRecordCore {
38
38
  turns: number;
39
39
  tools: Record<string, number>;
40
40
  subagents: SubagentSpan[];
41
+ /**
42
+ * Union milliseconds per tag spent in tool calls matched by a
43
+ * {@link SegmentRule} (e.g. `review`). Optional so older persisted
44
+ * records without this field still satisfy the type; callers reading
45
+ * from disk should treat a missing value as `{}`.
46
+ */
47
+ segments?: Record<string, number>;
41
48
  usage: UsageTotals;
42
49
  status: WorkStatus;
43
50
  }
@@ -52,6 +59,10 @@ export interface WorkRecordMetadata {
52
59
  sessionFile?: string;
53
60
  mode?: string;
54
61
  model?: string;
62
+ /** Who this work is billed to. See {@link resolveClient} in `client-label.ts`. */
63
+ client?: string;
64
+ /** pi's session display name at the time this record settled. */
65
+ sessionName?: string;
55
66
  }
56
67
 
57
68
  export type WorkRecord = WorkRecordCore & WorkRecordMetadata;
@@ -59,3 +70,46 @@ export type WorkRecord = WorkRecordCore & WorkRecordMetadata;
59
70
  export function emptyUsage(): UsageTotals {
60
71
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
61
72
  }
73
+
74
+ /** A finite number, or `0` for `undefined`/`NaN`/`Infinity`/non-numbers. */
75
+ export function finiteOrZero(value: unknown): number {
76
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
77
+ }
78
+
79
+ const ROLES = new Set<WorkRole>(["orchestrator", "subagent"]);
80
+ const STATUSES = new Set<WorkStatus>(["completed", "aborted", "interrupted"]);
81
+
82
+ /**
83
+ * Runtime guard for a {@link WorkRecord} read back from disk. `readAll`
84
+ * skips lines that parse as JSON but fail this check, so a torn write or a
85
+ * record from an incompatible schema does not crash task/session views.
86
+ */
87
+ export function isWorkRecord(value: unknown): value is WorkRecord {
88
+ if (!value || typeof value !== "object") return false;
89
+ const record = value as Record<string, unknown>;
90
+
91
+ return (
92
+ typeof record["schema"] === "number" &&
93
+ typeof record["id"] === "string" &&
94
+ ROLES.has(record["role"] as WorkRole) &&
95
+ typeof record["pid"] === "number" &&
96
+ typeof record["parentPid"] === "number" &&
97
+ typeof record["project"] === "string" &&
98
+ typeof record["prompt"] === "string" &&
99
+ typeof record["startedAt"] === "string" &&
100
+ typeof record["settledAt"] === "string" &&
101
+ Number.isFinite(record["wallMs"]) &&
102
+ Number.isFinite(record["waitingMs"]) &&
103
+ Number.isFinite(record["workMs"]) &&
104
+ typeof record["runs"] === "number" &&
105
+ typeof record["turns"] === "number" &&
106
+ typeof record["tools"] === "object" &&
107
+ record["tools"] !== null &&
108
+ Array.isArray(record["subagents"]) &&
109
+ typeof record["usage"] === "object" &&
110
+ record["usage"] !== null &&
111
+ STATUSES.has(record["status"] as WorkStatus) &&
112
+ (record["client"] === undefined || typeof record["client"] === "string") &&
113
+ (record["sessionName"] === undefined || typeof record["sessionName"] === "string")
114
+ );
115
+ }
@@ -1,13 +1,16 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { Clock } from "../ports/clock.ts";
3
3
  import { clampIntervals, unionMs } from "./intervals.ts";
4
- import { emptyUsage, WORK_RECORD_SCHEMA } from "./work-record.ts";
4
+ import { emptyUsage, finiteOrZero, WORK_RECORD_SCHEMA } from "./work-record.ts";
5
5
  import type { SubagentSpan, UsageTotals, WorkRecordCore, WorkStatus } from "./work-record.ts";
6
+ import type { SegmentRule } from "./segment-rule.ts";
6
7
 
7
8
  export interface WorkTrackerOptions {
8
9
  clock: Clock;
9
10
  interactiveTools: string[];
10
11
  subagentTool: string;
12
+ /** Rules that tag a tool execution's span under a named segment. Defaults to none. */
13
+ segmentRules?: SegmentRule[];
11
14
  }
12
15
 
13
16
  interface Interval {
@@ -23,6 +26,8 @@ interface OpenSubagentSpan {
23
26
  }
24
27
 
25
28
  interface RunState {
29
+ /** Generated once when the run opens, so it stays stable across `peek` and the final `onSettled`/`onShutdown`. */
30
+ id: string;
26
31
  startedAt: number;
27
32
  prompt: string;
28
33
  runs: number;
@@ -35,6 +40,14 @@ interface RunState {
35
40
  openToolWaits: Map<string, Interval>;
36
41
  subagents: SubagentSpan[];
37
42
  openSubagents: Map<string, OpenSubagentSpan>;
43
+ /**
44
+ * Segment spans opened by a matching {@link SegmentRule}, keyed by tag.
45
+ * A `Map` rather than a plain object so a tag such as `__proto__` cannot
46
+ * pollute the object prototype while the run is in progress.
47
+ */
48
+ segmentSpans: Map<string, Interval[]>;
49
+ /** The still-open segment span for a tool call id, if any. */
50
+ openSegments: Map<string, Interval>;
38
51
  }
39
52
 
40
53
  interface RunEndMessage {
@@ -51,17 +64,20 @@ export class WorkTracker {
51
64
  private readonly clock: Clock;
52
65
  private readonly interactiveTools: Set<string>;
53
66
  private readonly subagentTool: string;
67
+ private readonly segmentRules: SegmentRule[];
54
68
  private state: RunState | undefined;
55
69
 
56
70
  constructor(options: WorkTrackerOptions) {
57
71
  this.clock = options.clock;
58
72
  this.interactiveTools = new Set(options.interactiveTools);
59
73
  this.subagentTool = options.subagentTool;
74
+ this.segmentRules = options.segmentRules ?? [];
60
75
  }
61
76
 
62
77
  onRunStart(prompt: string): void {
63
78
  if (!this.state) {
64
79
  this.state = {
80
+ id: randomUUID(),
65
81
  startedAt: this.clock.now(),
66
82
  prompt,
67
83
  runs: 1,
@@ -73,6 +89,8 @@ export class WorkTracker {
73
89
  openToolWaits: new Map(),
74
90
  subagents: [],
75
91
  openSubagents: new Map(),
92
+ segmentSpans: new Map(),
93
+ openSegments: new Map(),
76
94
  };
77
95
  return;
78
96
  }
@@ -83,17 +101,26 @@ export class WorkTracker {
83
101
  if (!this.state) return;
84
102
  this.state.turns++;
85
103
  if (!usage) return;
86
- this.state.usage.input += usage.input ?? 0;
87
- this.state.usage.output += usage.output ?? 0;
88
- this.state.usage.cacheRead += usage.cacheRead ?? 0;
89
- this.state.usage.cacheWrite += usage.cacheWrite ?? 0;
90
- this.state.usage.cost += usage.cost ?? 0;
104
+ this.state.usage.input += finiteOrZero(usage.input);
105
+ this.state.usage.output += finiteOrZero(usage.output);
106
+ this.state.usage.cacheRead += finiteOrZero(usage.cacheRead);
107
+ this.state.usage.cacheWrite += finiteOrZero(usage.cacheWrite);
108
+ this.state.usage.cost += finiteOrZero(usage.cost);
91
109
  }
92
110
 
93
111
  onToolStart(toolCallId: string, toolName: string, args: Record<string, unknown> | undefined): void {
94
112
  if (!this.state) return;
95
113
  this.state.tools[toolName] = (this.state.tools[toolName] ?? 0) + 1;
96
114
 
115
+ const rule = this.segmentRules.find((candidate) => candidate.tool === toolName && candidate.pattern.test(segmentText(args)));
116
+ if (rule) {
117
+ const span: Interval = { start: this.clock.now(), end: undefined };
118
+ const spans = this.state.segmentSpans.get(rule.tag) ?? [];
119
+ spans.push(span);
120
+ this.state.segmentSpans.set(rule.tag, spans);
121
+ this.state.openSegments.set(toolCallId, span);
122
+ }
123
+
97
124
  if (this.interactiveTools.has(toolName)) {
98
125
  const span: Interval = { start: this.clock.now(), end: undefined };
99
126
  this.state.waitingSpans.push(span);
@@ -116,6 +143,12 @@ export class WorkTracker {
116
143
  onToolEnd(toolCallId: string, result: unknown): void {
117
144
  if (!this.state) return;
118
145
 
146
+ const openSegment = this.state.openSegments.get(toolCallId);
147
+ if (openSegment) {
148
+ this.state.openSegments.delete(toolCallId);
149
+ openSegment.end = this.clock.now();
150
+ }
151
+
119
152
  const openSubagent = this.state.openSubagents.get(toolCallId);
120
153
  if (openSubagent) {
121
154
  this.state.openSubagents.delete(toolCallId);
@@ -164,39 +197,59 @@ export class WorkTracker {
164
197
 
165
198
  onSettled(): WorkRecordCore | undefined {
166
199
  if (!this.state) return undefined;
167
- const record = this.finalize(this.state.status);
200
+ const record = this.buildRecord(this.state.status, this.clock.now());
168
201
  this.state = undefined;
169
202
  return record;
170
203
  }
171
204
 
172
205
  onShutdown(): WorkRecordCore | undefined {
173
206
  if (!this.state) return undefined;
174
- const record = this.finalize("interrupted");
207
+ const record = this.buildRecord("interrupted", this.clock.now());
175
208
  this.state = undefined;
176
209
  return record;
177
210
  }
178
211
 
179
- private finalize(status: WorkStatus): WorkRecordCore {
212
+ /**
213
+ * Returns what {@link onSettled}/{@link onShutdown} would produce right
214
+ * now, without mutating any state: open spans are truncated only in the
215
+ * returned snapshot, so the tracker keeps running unaffected and a later
216
+ * `peek` or the eventual settle still sees the spans' true open-ended
217
+ * state. `undefined` when idle. The returned `id` matches the id the
218
+ * eventual settled record will carry, since both are generated once
219
+ * in {@link onRunStart}.
220
+ */
221
+ peek(status: WorkStatus): WorkRecordCore | undefined {
222
+ if (!this.state) return undefined;
223
+ return this.buildRecord(status, this.clock.now());
224
+ }
225
+
226
+ private buildRecord(status: WorkStatus, settledAt: number): WorkRecordCore {
180
227
  const state = this.state;
181
228
  if (!state) {
182
- throw new Error("finalize called without an open run");
229
+ throw new Error("buildRecord called without an open run");
183
230
  }
184
- const settledAt = this.clock.now();
185
231
  const wallMs = settledAt - state.startedAt;
186
232
 
187
- for (const span of state.waitingSpans) {
188
- if (span.end === undefined) {
189
- span.end = settledAt;
190
- }
191
- }
192
-
193
- const closedSpans = state.waitingSpans.map((span) => ({ start: span.start, end: span.end as number }));
233
+ const closedSpans = state.waitingSpans.map((span) => ({ start: span.start, end: span.end ?? settledAt }));
194
234
  const waitingMs = unionMs(clampIntervals(closedSpans, state.startedAt, settledAt));
195
235
  const workMs = wallMs - waitingMs;
196
236
 
237
+ const segmentEntries: Array<[string, number]> = [];
238
+ for (const [tag, spans] of state.segmentSpans) {
239
+ const closedTagSpans = spans.map((span) => ({ start: span.start, end: span.end ?? settledAt }));
240
+ const tagMs = unionMs(clampIntervals(closedTagSpans, state.startedAt, settledAt));
241
+ if (tagMs > 0) {
242
+ segmentEntries.push([tag, tagMs]);
243
+ }
244
+ }
245
+ // Built via Object.fromEntries (never `segments[tag] = ...`) so a tag
246
+ // such as `__proto__` becomes an own data property instead of silently
247
+ // repointing the object's prototype.
248
+ const segments = Object.fromEntries(segmentEntries);
249
+
197
250
  return {
198
251
  schema: WORK_RECORD_SCHEMA,
199
- id: randomUUID(),
252
+ id: state.id,
200
253
  prompt: state.prompt,
201
254
  startedAt: new Date(state.startedAt).toISOString(),
202
255
  settledAt: new Date(settledAt).toISOString(),
@@ -207,12 +260,19 @@ export class WorkTracker {
207
260
  turns: state.turns,
208
261
  tools: state.tools,
209
262
  subagents: state.subagents,
263
+ segments,
210
264
  usage: state.usage,
211
265
  status,
212
266
  };
213
267
  }
214
268
  }
215
269
 
270
+ /** Text to match a {@link SegmentRule} pattern against: the `command` string arg when present, else the whole args object as JSON. */
271
+ function segmentText(args: Record<string, unknown> | undefined): string {
272
+ const command = args?.["command"];
273
+ return typeof command === "string" ? command : JSON.stringify(args ?? {});
274
+ }
275
+
216
276
  function extractTaskId(result: unknown): string | undefined {
217
277
  if (!result || typeof result !== "object") return undefined;
218
278
  const details = (result as { details?: unknown }).details;