@seanmars/tospec 0.14.0 → 0.14.2

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.
@@ -0,0 +1,145 @@
1
+ /**
2
+ * `tospec skill-metrics` — how long each skill actually takes, as a served web
3
+ * page.
4
+ *
5
+ * Read-only, and derived from two independent sources: Claude Code
6
+ * transcripts (core/skill-metrics.ts) and Codex sessions
7
+ * (core/codex-metrics.ts). Nothing is written, so the command is safe to run
8
+ * at any point in a workflow.
9
+ *
10
+ * The two sources carry different confidence — Claude Code's attribution is
11
+ * read directly, Codex's is inferred by a heuristic — and are therefore never
12
+ * summed into one figure anywhere in the report; every row, chart, and
13
+ * statistic carries a `source` and the two stay distinguishable throughout
14
+ * (design.md D4 in the codex-metrics change).
15
+ *
16
+ * The page is the only output: seven columns of minutes per skill had to be
17
+ * compared by reading digits, and a median/max pair discarded the very
18
+ * distribution it summarised. See
19
+ * tospec/decisions/20260730_205609-metrics-web-only-output.md.
20
+ *
21
+ * The server is deliberately short-lived and unmanaged. It binds a fixed base
22
+ * port and advances on collision, so the URL is predictable enough to keep in
23
+ * your head or bookmark while a second run still starts rather than failing.
24
+ * None of the dashboard's registry, pid-file, or `--stop` machinery applies to
25
+ * something you open, read, and close: the walk-up is the whole collision story.
26
+ */
27
+ import * as http from 'node:http';
28
+ import { Command } from 'commander';
29
+ import { type DurationStats, type MetricsSource, type MetricsThresholds, type SkillAggregate, type SkillRun, type TranscriptDirOptions, type TranscriptEntry } from '../core/skill-metrics.js';
30
+ import { type CodexSessionScan, type CodexSessionsDirOptions } from '../core/codex-metrics.js';
31
+ export interface MetricsOptions {
32
+ /** False only when `--no-open` was passed; commander defaults it to true. */
33
+ open?: boolean;
34
+ }
35
+ export interface MetricsServerOptions extends TranscriptDirOptions, CodexSessionsDirOptions {
36
+ port?: number;
37
+ host?: string;
38
+ }
39
+ interface ParsedSession {
40
+ session: string;
41
+ entries: TranscriptEntry[];
42
+ }
43
+ interface TranscriptCache {
44
+ transcriptDir: string;
45
+ /** False when the directory does not exist — no Claude Code history here. */
46
+ available: boolean;
47
+ /** Transcript files found, whether or not any carried skill attribution. */
48
+ sessions: number;
49
+ parsed: ParsedSession[];
50
+ }
51
+ export interface ReportQuery {
52
+ thresholds: MetricsThresholds;
53
+ /** Drop runs that ended before this instant. */
54
+ sinceMs?: number;
55
+ }
56
+ /**
57
+ * `DurationStats` carries median/max/total but no minimum, because the table it
58
+ * was written for never showed one. The distribution chart draws a range, so the
59
+ * minimum is added here rather than in the frontend — a minimum is a statistic,
60
+ * and the frontend holds none.
61
+ */
62
+ export interface RangeStats extends DurationStats {
63
+ minMs: number;
64
+ }
65
+ export interface SkillReport extends Omit<SkillAggregate, 'span' | 'engaged'> {
66
+ span: RangeStats;
67
+ engaged: RangeStats;
68
+ }
69
+ /**
70
+ * Per-source availability, kept as two separate objects rather than one
71
+ * combined figure — the two sources are scanned independently, and one having
72
+ * history says nothing about the other (codex-metrics-source spec, "A project
73
+ * with no matching Codex sessions...").
74
+ */
75
+ export interface MetricsSources {
76
+ claudeCode: MetricsSource;
77
+ codex: MetricsSource;
78
+ }
79
+ export interface MetricsReport {
80
+ sources: MetricsSources;
81
+ thresholds: MetricsThresholds;
82
+ /** One row per skill-and-source pair; the two are never summed together. */
83
+ skills: SkillReport[];
84
+ runs: SkillRun[];
85
+ series: TimeSeries;
86
+ }
87
+ export type Granularity = 'day' | 'week' | 'month';
88
+ export interface TimeBucket {
89
+ /** Local period key: `yyyy-mm-dd` for a day or a week's Monday, `yyyy-mm` for a month. */
90
+ key: string;
91
+ /**
92
+ * Engaged ms per skill-and-source row (`skillRowKey`), never summed across
93
+ * sources. Rows with nothing in this bucket are absent.
94
+ */
95
+ byRow: Record<string, number>;
96
+ }
97
+ export interface TimeSeries {
98
+ granularity: Granularity;
99
+ buckets: TimeBucket[];
100
+ }
101
+ /**
102
+ * Engaged time per period per skill, in local calendar time.
103
+ *
104
+ * Each run is counted whole on the local date it *started*, so a run crossing
105
+ * midnight is not divided — "one run is one entity" is what the distribution and
106
+ * timeline charts also assume. Bucketing by UTC would misplace any run starting
107
+ * before the local day's UTC offset, which for a positive offset is every early
108
+ * morning.
109
+ *
110
+ * Periods with no runs are included as empty buckets: with most days empty in a
111
+ * typical history, dropping them would make three consecutive days and three
112
+ * days a week apart look identical, which is the pattern this series exists to
113
+ * show.
114
+ */
115
+ export declare function buildTimeSeries(runs: SkillRun[]): TimeSeries;
116
+ /**
117
+ * Pure: turns both sources' parsed caches plus a query into the report the
118
+ * page renders. Claude Code and Codex runs are built, filtered, and reported
119
+ * independently at every step — merged only into one sorted `runs` list and
120
+ * one `skills` list, which `aggregateRuns` already keeps apart by
121
+ * `(skill, source)` (design.md D4: never summed).
122
+ */
123
+ export declare function reportFrom(claudeCache: TranscriptCache, codexCache: CodexSessionScan, query: ReportQuery): MetricsReport;
124
+ /**
125
+ * Starts the metrics server. Side-effect free — no logging, no browser, no
126
+ * signal handlers — so tests can drive it directly.
127
+ *
128
+ * Binds `DEFAULT_PORT` and advances on collision, so two metrics runs — or a
129
+ * metrics run beside anything else already holding the port — both start.
130
+ * Tests pass `port: 0` to stay out of the fixed range entirely.
131
+ */
132
+ export declare function startMetricsServer(projectRoot: string, opts?: MetricsServerOptions): Promise<{
133
+ server: http.Server;
134
+ url: string;
135
+ port: number;
136
+ }>;
137
+ /**
138
+ * CLI entry: start the server, print the URL, open a browser, and stay up until
139
+ * terminated. No signal handler — there is no registry entry, pid file, or temp
140
+ * file to clean up, so the default SIGINT behaviour is already correct.
141
+ */
142
+ export declare function runMetrics(projectRoot: string, opts?: MetricsOptions): Promise<http.Server>;
143
+ export declare function registerMetricsCommand(program: Command): void;
144
+ export {};
145
+ //# sourceMappingURL=metrics.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metrics.d.ts","sourceRoot":"","sources":["../../src/commands/metrics.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAGlC,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAWpC,OAAO,EAQL,KAAK,aAAa,EAClB,KAAK,aAAa,EAElB,KAAK,iBAAiB,EACtB,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,oBAAoB,EACzB,KAAK,eAAe,EACrB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAEL,KAAK,gBAAgB,EACrB,KAAK,uBAAuB,EAC7B,MAAM,0BAA0B,CAAC;AAelC,MAAM,WAAW,cAAc;IAC7B,6EAA6E;IAC7E,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,oBAAqB,SAAQ,oBAAoB,EAAE,uBAAuB;IACzF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAqBD,UAAU,aAAa;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,eAAe,EAAE,CAAC;CAC5B;AAED,UAAU,eAAe;IACvB,aAAa,EAAE,MAAM,CAAC;IACtB,6EAA6E;IAC7E,SAAS,EAAE,OAAO,CAAC;IACnB,4EAA4E;IAC5E,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,aAAa,EAAE,CAAC;CACzB;AAiCD,MAAM,WAAW,WAAW;IAC1B,UAAU,EAAE,iBAAiB,CAAC;IAC9B,gDAAgD;IAChD,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;;;GAKG;AACH,MAAM,WAAW,UAAW,SAAQ,aAAa;IAC/C,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,WAAY,SAAQ,IAAI,CAAC,cAAc,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3E,IAAI,EAAE,UAAU,CAAC;IACjB,OAAO,EAAE,UAAU,CAAC;CACrB;AAED;;;;;GAKG;AACH,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,aAAa,CAAC;IAC1B,KAAK,EAAE,aAAa,CAAC;CACtB;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,cAAc,CAAC;IACxB,UAAU,EAAE,iBAAiB,CAAC;IAC9B,4EAA4E;IAC5E,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,IAAI,EAAE,QAAQ,EAAE,CAAC;IACjB,MAAM,EAAE,UAAU,CAAC;CACpB;AAUD,MAAM,MAAM,WAAW,GAAG,KAAK,GAAG,MAAM,GAAG,OAAO,CAAC;AAEnD,MAAM,WAAW,UAAU;IACzB,0FAA0F;IAC1F,GAAG,EAAE,MAAM,CAAC;IACZ;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC/B;AAED,MAAM,WAAW,UAAU;IACzB,WAAW,EAAE,WAAW,CAAC;IACzB,OAAO,EAAE,UAAU,EAAE,CAAC;CACvB;AAqDD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,QAAQ,EAAE,GAAG,UAAU,CAmC5D;AAoDD;;;;;;GAMG;AACH,wBAAgB,UAAU,CACxB,WAAW,EAAE,eAAe,EAC5B,UAAU,EAAE,gBAAgB,EAC5B,KAAK,EAAE,WAAW,GACjB,aAAa,CA0Cf;AAyGD;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CACtC,WAAW,EAAE,MAAM,EACnB,IAAI,GAAE,oBAAyB,GAC9B,OAAO,CAAC;IAAE,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAiB7D;AAED;;;;GAIG;AACH,wBAAsB,UAAU,CAC9B,WAAW,EAAE,MAAM,EACnB,IAAI,GAAE,cAAmB,GACxB,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAStB;AAED,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAmB7D"}
@@ -0,0 +1,380 @@
1
+ /**
2
+ * `tospec skill-metrics` — how long each skill actually takes, as a served web
3
+ * page.
4
+ *
5
+ * Read-only, and derived from two independent sources: Claude Code
6
+ * transcripts (core/skill-metrics.ts) and Codex sessions
7
+ * (core/codex-metrics.ts). Nothing is written, so the command is safe to run
8
+ * at any point in a workflow.
9
+ *
10
+ * The two sources carry different confidence — Claude Code's attribution is
11
+ * read directly, Codex's is inferred by a heuristic — and are therefore never
12
+ * summed into one figure anywhere in the report; every row, chart, and
13
+ * statistic carries a `source` and the two stay distinguishable throughout
14
+ * (design.md D4 in the codex-metrics change).
15
+ *
16
+ * The page is the only output: seven columns of minutes per skill had to be
17
+ * compared by reading digits, and a median/max pair discarded the very
18
+ * distribution it summarised. See
19
+ * tospec/decisions/20260730_205609-metrics-web-only-output.md.
20
+ *
21
+ * The server is deliberately short-lived and unmanaged. It binds a fixed base
22
+ * port and advances on collision, so the URL is predictable enough to keep in
23
+ * your head or bookmark while a second run still starts rather than failing.
24
+ * None of the dashboard's registry, pid-file, or `--stop` machinery applies to
25
+ * something you open, read, and close: the walk-up is the whole collision story.
26
+ */
27
+ import * as http from 'node:http';
28
+ import { promises as fs } from 'node:fs';
29
+ import * as path from 'node:path';
30
+ import { resolveRootForCommand } from '../core/root-selection.js';
31
+ import { isAllowedHostHeader, listenFrom, openBrowser, packageAssetsDir, sendJson, serveAsset, } from '../core/local-server.js';
32
+ import { DEFAULT_IDLE_GAP_MS, DEFAULT_SPLIT_GAP_MS, aggregateRuns, buildRuns, parseTranscript, resolveTranscriptDir, skillRowKey, } from '../core/skill-metrics.js';
33
+ import { scanCodexSessions, } from '../core/codex-metrics.js';
34
+ import { formatLocalDate } from '../utils/timestamp.js';
35
+ import { emitFailure } from './shared-output.js';
36
+ const DEFAULT_HOST = '127.0.0.1';
37
+ /**
38
+ * Well clear of the dashboard's 5620 base, so the two commands' walk-up ranges
39
+ * can never meet each other.
40
+ */
41
+ const DEFAULT_PORT = 26693;
42
+ /** This command's shipped frontend; `serveAsset` is confined to it. */
43
+ const ASSETS_DIR = packageAssetsDir('metrics');
44
+ async function readTranscripts(projectRoot, dirOptions) {
45
+ const transcriptDir = resolveTranscriptDir(projectRoot, dirOptions);
46
+ let fileNames;
47
+ try {
48
+ fileNames = (await fs.readdir(transcriptDir)).filter((f) => f.endsWith('.jsonl'));
49
+ }
50
+ catch (error) {
51
+ if (error.code === 'ENOENT') {
52
+ return { transcriptDir, available: false, sessions: 0, parsed: [] };
53
+ }
54
+ throw error;
55
+ }
56
+ const parsed = [];
57
+ for (const fileName of fileNames) {
58
+ let text;
59
+ try {
60
+ text = await fs.readFile(path.join(transcriptDir, fileName), 'utf8');
61
+ }
62
+ catch {
63
+ // A session file can vanish between readdir and read; skip it.
64
+ continue;
65
+ }
66
+ parsed.push({ session: path.basename(fileName, '.jsonl'), entries: parseTranscript(text) });
67
+ }
68
+ return { transcriptDir, available: true, sessions: fileNames.length, parsed };
69
+ }
70
+ function withMinimum(stats, values) {
71
+ return { ...stats, minMs: values.length === 0 ? 0 : Math.min(...values) };
72
+ }
73
+ /**
74
+ * Above this the bars stop being readable — 120 across a typical chart width is
75
+ * already only a few pixels each — so the unit widens rather than the bars
76
+ * shrinking. Driven by the resulting count rather than by which range option was
77
+ * picked, so adding a range option later cannot reintroduce three-pixel bars.
78
+ */
79
+ const MAX_BUCKETS = 120;
80
+ /** Local midnight of the day containing `ms`. */
81
+ function startOfLocalDay(ms) {
82
+ const d = new Date(ms);
83
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate());
84
+ }
85
+ /** Local Monday of the week containing `date`. */
86
+ function startOfLocalWeek(date) {
87
+ // getDay(): 0 = Sunday, so Sunday is 6 days after its Monday.
88
+ const back = (date.getDay() + 6) % 7;
89
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate() - back);
90
+ }
91
+ function bucketStart(ms, granularity) {
92
+ const day = startOfLocalDay(ms);
93
+ if (granularity === 'week')
94
+ return startOfLocalWeek(day);
95
+ if (granularity === 'month')
96
+ return new Date(day.getFullYear(), day.getMonth(), 1);
97
+ return day;
98
+ }
99
+ function bucketKey(start, granularity) {
100
+ return granularity === 'month' ? formatLocalDate(start).slice(0, 7) : formatLocalDate(start);
101
+ }
102
+ /** The next bucket start after `start`, in local time (DST- and month-length-safe). */
103
+ function nextBucket(start, granularity) {
104
+ const { 0: y, 1: m, 2: d } = [start.getFullYear(), start.getMonth(), start.getDate()];
105
+ if (granularity === 'month')
106
+ return new Date(y, m + 1, 1);
107
+ return new Date(y, m, d + (granularity === 'week' ? 7 : 1));
108
+ }
109
+ function countBuckets(firstMs, lastMs, granularity) {
110
+ let cursor = bucketStart(firstMs, granularity);
111
+ const end = bucketStart(lastMs, granularity).getTime();
112
+ let n = 1;
113
+ while (cursor.getTime() < end) {
114
+ cursor = nextBucket(cursor, granularity);
115
+ n++;
116
+ if (n > 10000)
117
+ break; // defensive: never spin on a corrupt timestamp
118
+ }
119
+ return n;
120
+ }
121
+ /**
122
+ * Engaged time per period per skill, in local calendar time.
123
+ *
124
+ * Each run is counted whole on the local date it *started*, so a run crossing
125
+ * midnight is not divided — "one run is one entity" is what the distribution and
126
+ * timeline charts also assume. Bucketing by UTC would misplace any run starting
127
+ * before the local day's UTC offset, which for a positive offset is every early
128
+ * morning.
129
+ *
130
+ * Periods with no runs are included as empty buckets: with most days empty in a
131
+ * typical history, dropping them would make three consecutive days and three
132
+ * days a week apart look identical, which is the pattern this series exists to
133
+ * show.
134
+ */
135
+ export function buildTimeSeries(runs) {
136
+ if (runs.length === 0)
137
+ return { granularity: 'day', buckets: [] };
138
+ const firstMs = Math.min(...runs.map((r) => r.startMs));
139
+ const lastMs = Math.max(...runs.map((r) => r.startMs));
140
+ let granularity = 'day';
141
+ if (countBuckets(firstMs, lastMs, 'day') > MAX_BUCKETS)
142
+ granularity = 'week';
143
+ if (granularity === 'week' && countBuckets(firstMs, lastMs, 'week') > MAX_BUCKETS) {
144
+ granularity = 'month';
145
+ }
146
+ const totals = new Map();
147
+ for (const run of runs) {
148
+ const key = bucketKey(bucketStart(run.startMs, granularity), granularity);
149
+ const bucket = totals.get(key) ?? {};
150
+ const row = skillRowKey(run.skill, run.source);
151
+ // Keyed by skill-and-source, not skill alone: a day with runs from both
152
+ // sources for the same skill must keep the two figures apart rather than
153
+ // summing them into one bucket entry.
154
+ bucket[row] = (bucket[row] ?? 0) + run.engagedMs;
155
+ totals.set(key, bucket);
156
+ }
157
+ const buckets = [];
158
+ let cursor = bucketStart(firstMs, granularity);
159
+ const end = bucketStart(lastMs, granularity).getTime();
160
+ for (;;) {
161
+ const key = bucketKey(cursor, granularity);
162
+ buckets.push({ key, byRow: totals.get(key) ?? {} });
163
+ if (cursor.getTime() >= end)
164
+ break;
165
+ cursor = nextBucket(cursor, granularity);
166
+ }
167
+ return { granularity, buckets };
168
+ }
169
+ /**
170
+ * Builds runs for one source's parsed sessions and applies the since-filter.
171
+ * Shared by both sources: `TranscriptCache.parsed` and
172
+ * `CodexTranscriptCache.parsed` have the identical `{ session, entries }`
173
+ * shape, so the same assembly works for either — only the `source` tag
174
+ * `buildRuns` stamps onto each run differs.
175
+ */
176
+ function runsFrom(parsed, query, source) {
177
+ const runs = [];
178
+ let attributedSessions = 0;
179
+ for (const { session, entries } of parsed) {
180
+ const sessionRuns = buildRuns(entries, session, { ...query.thresholds, source });
181
+ // Counted before the since-filter: a session that produced runs did carry
182
+ // attribution, whether or not the selected range keeps any of them.
183
+ if (sessionRuns.length > 0)
184
+ attributedSessions++;
185
+ for (const run of sessionRuns) {
186
+ if (query.sinceMs !== undefined && run.endMs < query.sinceMs)
187
+ continue;
188
+ runs.push(run);
189
+ }
190
+ }
191
+ return { runs, attributedSessions };
192
+ }
193
+ function sourceStats(transcriptDir, available, sessions, result) {
194
+ return {
195
+ transcriptDir,
196
+ available,
197
+ sessions,
198
+ attributedSessions: result.attributedSessions,
199
+ firstSeen: result.runs.length > 0 ? new Date(Math.min(...result.runs.map((r) => r.startMs))).toISOString() : null,
200
+ lastSeen: result.runs.length > 0 ? new Date(Math.max(...result.runs.map((r) => r.endMs))).toISOString() : null,
201
+ };
202
+ }
203
+ /**
204
+ * Pure: turns both sources' parsed caches plus a query into the report the
205
+ * page renders. Claude Code and Codex runs are built, filtered, and reported
206
+ * independently at every step — merged only into one sorted `runs` list and
207
+ * one `skills` list, which `aggregateRuns` already keeps apart by
208
+ * `(skill, source)` (design.md D4: never summed).
209
+ */
210
+ export function reportFrom(claudeCache, codexCache, query) {
211
+ const claude = runsFrom(claudeCache.parsed, query, 'claude-code');
212
+ const codex = runsFrom(codexCache.sessions, query, 'codex');
213
+ const runs = [...claude.runs, ...codex.runs].sort((a, b) => a.startMs - b.startMs);
214
+ const byRow = new Map();
215
+ for (const run of runs) {
216
+ const key = skillRowKey(run.skill, run.source);
217
+ const bucket = byRow.get(key);
218
+ if (bucket)
219
+ bucket.push(run);
220
+ else
221
+ byRow.set(key, [run]);
222
+ }
223
+ const skills = aggregateRuns(runs).map((a) => {
224
+ const rowRuns = byRow.get(skillRowKey(a.skill, a.source)) ?? [];
225
+ return {
226
+ ...a,
227
+ span: withMinimum(a.span, rowRuns.map((r) => r.spanMs)),
228
+ engaged: withMinimum(a.engaged, rowRuns.map((r) => r.engagedMs)),
229
+ };
230
+ });
231
+ return {
232
+ sources: {
233
+ claudeCode: sourceStats(claudeCache.transcriptDir, claudeCache.available, claudeCache.sessions, claude),
234
+ codex: sourceStats(codexCache.sessionsDir, codexCache.available, codexCache.sessions.length, codex),
235
+ },
236
+ thresholds: query.thresholds,
237
+ skills,
238
+ series: buildTimeSeries(runs),
239
+ runs,
240
+ };
241
+ }
242
+ /** A malformed query parameter: the client's fault, so 400 rather than 500. */
243
+ class BadQuery extends Error {
244
+ }
245
+ /**
246
+ * Reads a positive-duration parameter.
247
+ *
248
+ * With the CLI flags removed, the page is the only source of these values, so
249
+ * nothing upstream has checked them — this restates the rule the deleted
250
+ * `positiveMinutes` argParser enforced, at the boundary where the values now
251
+ * actually arrive.
252
+ */
253
+ function positiveMs(params, name, fallback) {
254
+ const raw = params.get(name);
255
+ if (raw === null)
256
+ return fallback;
257
+ const value = Number(raw);
258
+ if (!Number.isFinite(value) || value <= 0) {
259
+ throw new BadQuery(`${name} expects a positive number of milliseconds, got '${raw}'.`);
260
+ }
261
+ return value;
262
+ }
263
+ function queryFrom(params) {
264
+ const query = {
265
+ thresholds: {
266
+ idleGapMs: positiveMs(params, 'idleGap', DEFAULT_IDLE_GAP_MS),
267
+ splitGapMs: positiveMs(params, 'splitGap', DEFAULT_SPLIT_GAP_MS),
268
+ },
269
+ };
270
+ const since = params.get('since');
271
+ if (since !== null) {
272
+ const sinceMs = Number(since);
273
+ if (!Number.isFinite(sinceMs)) {
274
+ throw new BadQuery(`since expects a millisecond timestamp, got '${since}'.`);
275
+ }
276
+ // Absent means all-time, which is the default the page opens on. A `since`
277
+ // only ever narrows.
278
+ query.sinceMs = sinceMs;
279
+ }
280
+ return query;
281
+ }
282
+ async function handleRequest(ctx, req, res) {
283
+ try {
284
+ // Host allowlist runs before any route (DNS-rebinding guard).
285
+ if (!isAllowedHostHeader(req.headers.host, ctx.host)) {
286
+ return sendJson(res, 403, { error: 'forbidden host' });
287
+ }
288
+ if (req.method !== 'GET') {
289
+ return sendJson(res, 405, { error: 'method not allowed' });
290
+ }
291
+ const url = new URL(req.url ?? '/', 'http://localhost');
292
+ if (url.pathname === '/')
293
+ return await serveAsset(res, ASSETS_DIR, 'index.html');
294
+ if (url.pathname.startsWith('/assets/')) {
295
+ return await serveAsset(res, ASSETS_DIR, url.pathname.slice('/assets/'.length));
296
+ }
297
+ if (url.pathname === '/api/report') {
298
+ const query = queryFrom(url.searchParams);
299
+ // Still a read, so still a GET — it just declines the cache. Transcripts
300
+ // grow while the page is open, including from the session viewing it, so
301
+ // this is the only way to see anything recorded since startup.
302
+ if (url.searchParams.get('refresh') !== null) {
303
+ ctx.claudeCache = await readTranscripts(ctx.projectRoot, ctx.dirOptions);
304
+ ctx.codexCache = await scanCodexSessions(ctx.projectRoot, ctx.dirOptions);
305
+ }
306
+ return sendJson(res, 200, reportFrom(ctx.claudeCache, ctx.codexCache, query));
307
+ }
308
+ sendJson(res, 404, { error: 'not found' });
309
+ }
310
+ catch (err) {
311
+ if (err instanceof BadQuery) {
312
+ return sendJson(res, 400, { error: err.message });
313
+ }
314
+ // A single handler failure must never crash the server.
315
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
316
+ }
317
+ }
318
+ // -----------------------------------------------------------------------------
319
+ // Lifecycle
320
+ // -----------------------------------------------------------------------------
321
+ /**
322
+ * Starts the metrics server. Side-effect free — no logging, no browser, no
323
+ * signal handlers — so tests can drive it directly.
324
+ *
325
+ * Binds `DEFAULT_PORT` and advances on collision, so two metrics runs — or a
326
+ * metrics run beside anything else already holding the port — both start.
327
+ * Tests pass `port: 0` to stay out of the fixed range entirely.
328
+ */
329
+ export async function startMetricsServer(projectRoot, opts = {}) {
330
+ const host = opts.host ?? DEFAULT_HOST;
331
+ // Read up front rather than on the first request, so the URL is only printed
332
+ // once the report behind it can actually be produced.
333
+ const ctx = {
334
+ host,
335
+ projectRoot,
336
+ dirOptions: opts,
337
+ claudeCache: await readTranscripts(projectRoot, opts),
338
+ codexCache: await scanCodexSessions(projectRoot, opts),
339
+ };
340
+ const server = http.createServer((req, res) => {
341
+ void handleRequest(ctx, req, res);
342
+ });
343
+ const port = await listenFrom(server, opts.port ?? DEFAULT_PORT, host);
344
+ return { server, url: `http://${host}:${port}`, port };
345
+ }
346
+ /**
347
+ * CLI entry: start the server, print the URL, open a browser, and stay up until
348
+ * terminated. No signal handler — there is no registry entry, pid file, or temp
349
+ * file to clean up, so the default SIGINT behaviour is already correct.
350
+ */
351
+ export async function runMetrics(projectRoot, opts = {}) {
352
+ const { server, url } = await startMetricsServer(projectRoot);
353
+ console.log(`Metrics running at ${url} (Ctrl+C to stop)`);
354
+ if (opts.open !== false) {
355
+ openBrowser(url);
356
+ }
357
+ return server;
358
+ }
359
+ export function registerMetricsCommand(program) {
360
+ program
361
+ // Named for what it measures, not for the generic notion of metrics: the
362
+ // report is entirely about skill durations, and the bare `metrics` name
363
+ // invited the reading that it covered project or CLI statistics at large.
364
+ .command('skill-metrics')
365
+ .description('Open a local web report of how long every skill takes')
366
+ .option('--no-open', 'Print the URL without opening a browser')
367
+ .action(async (options) => {
368
+ try {
369
+ const root = await resolveRootForCommand({});
370
+ if (!root) {
371
+ return;
372
+ }
373
+ await runMetrics(root.path, options);
374
+ }
375
+ catch (error) {
376
+ emitFailure(undefined, {}, error, 'metrics_error');
377
+ }
378
+ });
379
+ }
380
+ //# sourceMappingURL=metrics.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metrics.js","sourceRoot":"","sources":["../../src/commands/metrics.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Codex skill duration metrics — a second, less precise source for the same
3
+ * report `skill-metrics.ts` builds from Claude Code transcripts.
4
+ *
5
+ * Codex keeps one session tree shared by every project
6
+ * (`<CODEX_HOME or ~/.codex>/sessions/<yyyy>/<mm>/<dd>/rollout-*.jsonl`),
7
+ * partitioned by date rather than by project, with the working directory
8
+ * recorded once per session (`type: "session_meta"`, `payload.cwd`) rather than
9
+ * encoded into a directory name. Project scoping therefore reads that field
10
+ * instead of resolving a path (see codex-metrics-source spec, "Codex sessions
11
+ * are scoped to a project by their recorded working directory").
12
+ *
13
+ * Codex transcripts carry nothing like Claude Code's `attributionSkill`. Every
14
+ * session's system prompt lists all installed skills identically whether or
15
+ * not any of them were used, so that listing carries no signal. The one event
16
+ * confirmed (against this project's own real `~/.codex` history) to correlate
17
+ * with a skill actually being used is a tool-call command whose text reads a
18
+ * path ending in `<skill-name>/SKILL.md` — the moment the agent pulls in that
19
+ * skill's instructions. Skill attribution here is therefore a heuristic, not a
20
+ * recorded fact, and undercounts by construction: a skill run that never
21
+ * re-reads its own instructions file is invisible to it.
22
+ */
23
+ import { type RunGroupingOptions, type SkillMetrics, type TranscriptEntry } from './skill-metrics.js';
24
+ export interface CodexSessionsDirOptions {
25
+ env?: NodeJS.ProcessEnv;
26
+ homedir?: string;
27
+ }
28
+ /**
29
+ * Locates Codex's session tree. `CODEX_HOME` is honoured the same way
30
+ * `CLAUDE_CONFIG_DIR` is for Claude Code; without it the default is
31
+ * `~/.codex`. This is the directory to search, not a per-project one — Codex
32
+ * has no equivalent of Claude Code's per-project transcript directory.
33
+ */
34
+ export declare function resolveCodexSessionsDir(options?: CodexSessionsDirOptions): string;
35
+ /**
36
+ * Whether a session's recorded working directory names the given project.
37
+ * `null` (no `session_meta` seen, or no `cwd` field) never matches — a session
38
+ * with nothing recorded cannot be attributed to any project.
39
+ */
40
+ export declare function sessionBelongsToProject(cwd: string | null, rootPath: string): boolean;
41
+ export interface CodexSessionResult {
42
+ /** The session's recorded working directory, or null if never seen. */
43
+ cwd: string | null;
44
+ entries: TranscriptEntry[];
45
+ }
46
+ /**
47
+ * Parses one Codex rollout `.jsonl` file into the recorded project directory
48
+ * plus a sorted, timestamped, skill-labelled entry list — the same shape
49
+ * `parseTranscript` produces for Claude Code, so `buildRuns`/`aggregateRuns`
50
+ * apply unchanged (design.md D3). Malformed lines are skipped rather than
51
+ * fatal, for the same reason Claude Code transcripts are: an append-only log
52
+ * that can be torn at the tail while a session is live.
53
+ *
54
+ * Only `function_call`/`custom_tool_call` entries are ever inspected for a
55
+ * `SKILL.md` read. Every other entry — including the one carrying the fixed
56
+ * skill-listing text every session opens with — is folded in unattributed
57
+ * (`skill: null`), exactly as an unattributed Claude Code entry is: it cannot
58
+ * end a run, but it is not evidence a skill became active either.
59
+ */
60
+ export declare function parseCodexSession(text: string): CodexSessionResult;
61
+ export interface CodexProjectSession {
62
+ /** Rollout file stem — the session id runs are labelled with. */
63
+ session: string;
64
+ entries: TranscriptEntry[];
65
+ }
66
+ export interface CodexSessionScan {
67
+ sessionsDir: string;
68
+ /** False when the sessions directory does not exist — Codex has never run here. */
69
+ available: boolean;
70
+ /** Only the sessions whose recorded cwd names this project. */
71
+ sessions: CodexProjectSession[];
72
+ }
73
+ /**
74
+ * Finds and parses every Codex session belonging to one project.
75
+ *
76
+ * Shared by `collectCodexSkillMetrics` and the metrics server, which need
77
+ * exactly this and previously each carried their own copy of the walk — so the
78
+ * probe optimisation could otherwise have been applied to one and not the other.
79
+ */
80
+ export declare function scanCodexSessions(rootPath: string, options?: CodexSessionsDirOptions): Promise<CodexSessionScan>;
81
+ export interface CollectCodexMetricsOptions extends RunGroupingOptions, CodexSessionsDirOptions {
82
+ /** Drop runs that ended before this instant. */
83
+ sinceMs?: number;
84
+ }
85
+ /**
86
+ * Derives skill timings from the `SKILL.md`-read heuristic over this project's
87
+ * Codex sessions — the Codex-flavoured equivalent of `collectSkillMetrics`.
88
+ * `scanCodexSessions` does the finding and scoping.
89
+ *
90
+ * A missing sessions directory is a reportable state (`available: false`), not
91
+ * an error, for the same reason it is for Claude Code: the project may simply
92
+ * never have been opened in Codex.
93
+ */
94
+ export declare function collectCodexSkillMetrics(rootPath: string, options?: CollectCodexMetricsOptions): Promise<SkillMetrics>;
95
+ //# sourceMappingURL=codex-metrics.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"codex-metrics.d.ts","sourceRoot":"","sources":["../../src/core/codex-metrics.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAOH,OAAO,EAQL,KAAK,kBAAkB,EACvB,KAAK,YAAY,EACjB,KAAK,eAAe,EACrB,MAAM,oBAAoB,CAAC;AAQ5B,MAAM,WAAW,uBAAuB;IACtC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,GAAE,uBAA4B,GAAG,MAAM,CAMrF;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAKrF;AAMD,MAAM,WAAW,kBAAkB;IACjC,uEAAuE;IACvE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,OAAO,EAAE,eAAe,EAAE,CAAC;CAC5B;AA8CD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,kBAAkB,CA4ClE;AAsED,MAAM,WAAW,mBAAmB;IAClC,iEAAiE;IACjE,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,eAAe,EAAE,CAAC;CAC5B;AAED,MAAM,WAAW,gBAAgB;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,mFAAmF;IACnF,SAAS,EAAE,OAAO,CAAC;IACnB,+DAA+D;IAC/D,QAAQ,EAAE,mBAAmB,EAAE,CAAC;CACjC;AAED;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CACrC,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE,uBAA4B,GACpC,OAAO,CAAC,gBAAgB,CAAC,CA8B3B;AAMD,MAAM,WAAW,0BAA2B,SAAQ,kBAAkB,EAAE,uBAAuB;IAC7F,gDAAgD;IAChD,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;;GAQG;AACH,wBAAsB,wBAAwB,CAC5C,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE,0BAA+B,GACvC,OAAO,CAAC,YAAY,CAAC,CAqDvB"}