@deniscodp/skills 0.0.0-stage → 1.0.0

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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +89 -2
  3. package/bin/deniscodp-skills.mjs +4 -0
  4. package/package.json +23 -4
  5. package/scripts/catalog.mjs +68 -0
  6. package/scripts/cli.mjs +264 -0
  7. package/scripts/install.mjs +184 -0
  8. package/scripts/recommended.mjs +102 -0
  9. package/skills/denisco-handoff-implementation/SKILL.md +254 -0
  10. package/skills/denisco-handoff-implementation/references/blocker-checklist.md +71 -0
  11. package/skills/denisco-handoff-implementation/references/delegated-implementation.md +168 -0
  12. package/skills/denisco-handoff-implementation/references/ownership.md +105 -0
  13. package/skills/denisco-handoff-implementation/references/session-plan.md +195 -0
  14. package/skills/denisco-handoff-implementation/scripts/ownership.mjs +153 -0
  15. package/skills/denisco-implementation-coordinator/SKILL.md +237 -0
  16. package/skills/denisco-implementation-coordinator/agents/openai.yaml +2 -0
  17. package/skills/denisco-implementation-coordinator/references/documents.md +175 -0
  18. package/skills/denisco-implementation-coordinator/references/runtimes.md +105 -0
  19. package/skills/denisco-interactive-spec-definition/SKILL.md +126 -0
  20. package/skills/denisco-interactive-spec-definition/references/blocker-checklist.md +71 -0
  21. package/skills/denisco-interactive-spec-definition/references/handoff-template.md +124 -0
  22. package/skills/denisco-project-context/SKILL.md +60 -0
  23. package/skills/denisco-project-context/references/project-documents.md +70 -0
  24. package/skills/denisco-visual-check/SKILL.md +136 -0
  25. package/skills/denisco-visual-check/references/judge-prompt.md +55 -0
  26. package/skills/denisco-visual-check/references/project-config.md +118 -0
  27. package/skills/denisco-workflow-benchmark/SKILL.md +113 -0
  28. package/skills/denisco-workflow-benchmark/agents/openai.yaml +2 -0
  29. package/skills/denisco-workflow-benchmark/references/measurement.md +166 -0
  30. package/skills/denisco-workflow-benchmark/references/workflow-contract.md +371 -0
  31. package/skills/denisco-workflow-benchmark/scripts/bench.mjs +156 -0
  32. package/skills/denisco-workflow-benchmark/scripts/cases.mjs +46 -0
  33. package/skills/denisco-workflow-benchmark/scripts/configuration.mjs +71 -0
  34. package/skills/denisco-workflow-benchmark/scripts/discover.mjs +88 -0
  35. package/skills/denisco-workflow-benchmark/scripts/eval-worker.mjs +58 -0
  36. package/skills/denisco-workflow-benchmark/scripts/io.mjs +82 -0
  37. package/skills/denisco-workflow-benchmark/scripts/prepare.mjs +69 -0
  38. package/skills/denisco-workflow-benchmark/scripts/runs.mjs +235 -0
  39. package/skills/denisco-workflow-benchmark/scripts/state-prompt.mjs +96 -0
  40. package/skills/denisco-workflow-benchmark/scripts/tracking.mjs +91 -0
  41. package/skills/denisco-workflow-benchmark/scripts/usage.mjs +175 -0
  42. package/skills/denisco-workflow-benchmark/scripts/workflow.mjs +334 -0
@@ -0,0 +1,96 @@
1
+ import fs from 'node:fs';
2
+ import crypto from 'node:crypto';
3
+ import { rejectSymlinkPath } from './io.mjs';
4
+
5
+ const START = '<!-- workflow:next-prompt:start -->';
6
+ const END = '<!-- workflow:next-prompt:end -->';
7
+ const normalize = text => text.replace(/\r\n/g, '\n');
8
+
9
+ function decode(bytes) {
10
+ let encoding = 'utf-8', bom = Buffer.alloc(0);
11
+ if (bytes.subarray(0, 3).equals(Buffer.from([0xef, 0xbb, 0xbf]))) bom = bytes.subarray(0, 3);
12
+ else if (bytes.subarray(0, 2).equals(Buffer.from([0xff, 0xfe]))) { bom = bytes.subarray(0, 2); encoding = 'utf-16le'; }
13
+ else if (bytes.subarray(0, 2).equals(Buffer.from([0xfe, 0xff]))) { bom = bytes.subarray(0, 2); encoding = 'utf-16be'; }
14
+ const text = new TextDecoder(encoding, { fatal: true, ignoreBOM: true }).decode(bytes.subarray(bom.length));
15
+ return { text, encode(value) {
16
+ const body = Buffer.from(value, encoding === 'utf-8' ? 'utf8' : 'utf16le');
17
+ if (encoding === 'utf-16be') body.swap16();
18
+ return Buffer.concat([bom, body]);
19
+ } };
20
+ }
21
+
22
+ function section(text) {
23
+ const starts = [...text.matchAll(/<!-- workflow:next-prompt:start/g)];
24
+ const ends = [...text.matchAll(/<!-- workflow:next-prompt:end/g)];
25
+ if (!starts.length && !ends.length) return null;
26
+ if (starts.length !== 1 || ends.length !== 1) throw new Error('Malformed or duplicate next-prompt markers in STATE; no files changed');
27
+ const start = starts[0].index, end = ends[0].index;
28
+ if (!text.startsWith(START, start) || !text.startsWith(END, end) || end <= start
29
+ || (start && text[start - 1] !== '\n')
30
+ || !['\r', '\n'].includes(text[start + START.length])
31
+ || text[end - 1] !== '\n'
32
+ || (end + END.length < text.length && !['\r', '\n'].includes(text[end + END.length]))) {
33
+ throw new Error('Malformed next-prompt markers in STATE; no files changed');
34
+ }
35
+ return { start, end: end + END.length, text: text.slice(start, end + END.length) };
36
+ }
37
+
38
+ function readState(file) {
39
+ rejectSymlinkPath(file);
40
+ if (!fs.statSync(file).isFile()) throw new Error(`Expected STATE file: ${file}`);
41
+ const bytes = fs.readFileSync(file);
42
+ return { bytes, ...decode(bytes) };
43
+ }
44
+
45
+ function rendered(prompt, contextHash) {
46
+ // A path can contain backticks: use a fence longer than any run in the prompt.
47
+ const fence = '`'.repeat(Math.max(3, ...[...prompt.matchAll(/`+/g)].map(m => m[0].length + 1)));
48
+ return `${START}\n## Next-session prompt\n\n<!-- workflow:next-prompt:context ${contextHash} -->\n${fence}text\n${prompt}${fence}\n${END}`;
49
+ }
50
+
51
+ export function checkStatePrompt({ state, block, prompt, contextHash }) {
52
+ const saved = section(readState(state).text);
53
+ const valid = saved !== null && normalize(saved.text) === rendered(prompt, contextHash);
54
+ return { state, block, valid, status: !saved ? 'missing' : valid ? 'saved' : 'stale-or-modified',
55
+ prompt: valid ? prompt : null };
56
+ }
57
+
58
+ export function updateStatePrompt({ state, block, prompt, contextHash, clear = false, validateContext }) {
59
+ const original = readState(state), saved = section(original.text);
60
+ const eol = original.text.includes('\r\n') ? '\r\n' : '\n';
61
+ const replacement = clear ? '' : rendered(prompt, contextHash).replace(/\n/g, eol);
62
+ let text;
63
+ if (saved) text = original.text.slice(0, saved.start) + replacement + original.text.slice(saved.end);
64
+ else if (clear) text = original.text;
65
+ else {
66
+ const separator = !original.text.length || original.text.endsWith(eol + eol) ? '' : original.text.endsWith(eol) ? eol : eol + eol;
67
+ text = original.text + separator + replacement + eol;
68
+ }
69
+ const updated = original.encode(text), changed = !original.bytes.equals(updated);
70
+ const lock = `${state}.lock`;
71
+ rejectSymlinkPath(lock);
72
+ const fd = fs.openSync(lock, 'wx');
73
+ let temporary;
74
+ try {
75
+ validateContext();
76
+ if (!readState(state).bytes.equals(original.bytes)) throw new Error('STATE changed during prompt update; retry after review');
77
+ if (changed) {
78
+ temporary = `${state}.${crypto.randomUUID()}.tmp`;
79
+ fs.writeFileSync(temporary, updated, { flag: 'wx', mode: fs.statSync(state).mode });
80
+ // Detect a non-cooperating editor while the temporary file was prepared.
81
+ validateContext();
82
+ if (!readState(state).bytes.equals(original.bytes)) throw new Error('STATE changed during prompt update; retry after review');
83
+ fs.renameSync(temporary, state);
84
+ }
85
+ if (!readState(state).bytes.equals(updated)) throw new Error('STATE changed after prompt update; delivery is not confirmed');
86
+ validateContext();
87
+ if (clear) return { state, block: null, changed, valid: true, status: 'cleared', prompt: null };
88
+ const checked = checkStatePrompt({ state, block, prompt, contextHash });
89
+ if (!checked.valid) throw new Error('Saved prompt failed read-back verification');
90
+ return { ...checked, changed };
91
+ } finally {
92
+ if (temporary && fs.existsSync(temporary)) fs.unlinkSync(temporary);
93
+ fs.closeSync(fd);
94
+ fs.unlinkSync(lock);
95
+ }
96
+ }
@@ -0,0 +1,91 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { readJSON, writeNew, mutateRun, rejectSymlinkPath } from './io.mjs';
5
+ import { whoami } from './discover.mjs';
6
+ import { phases, registerSession } from './runs.mjs';
7
+ import { benchPath, readWorkflow } from './workflow.mjs';
8
+ import { snapshotConfiguration } from './configuration.mjs';
9
+
10
+ const canonical = file => process.platform === 'win32' ? path.resolve(file).toLowerCase() : path.resolve(file);
11
+ const nextCall = ticket => ({ executable: process.execPath, args: [benchPath(), 'track', '--ticket', ticket] });
12
+
13
+ function record(ticket, data, status, reason = null, sessionKey = null) {
14
+ mutateRun(data.run, run => {
15
+ run.registrationAttempts ??= [];
16
+ const entry = { ticket, phase: data.phase, status, reason, sessionKey };
17
+ const index = run.registrationAttempts.findIndex(item => item.ticket === ticket);
18
+ if (index < 0) run.registrationAttempts.push(entry);
19
+ else run.registrationAttempts[index] = entry;
20
+ });
21
+ return { status, reason, sessionKey, ticket,
22
+ ...(data.configurationWarning ? { configurationWarning: data.configurationWarning } : {}),
23
+ ...(status === 'pending' ? { nextCall: nextCall(ticket) } : { closeArgs: ['--ticket', ticket, '--session-state', 'active'] }) };
24
+ }
25
+
26
+ // Two tool calls are necessary: the first output must reach the native transcript.
27
+ // Tickets are agent-internal and must never be reused in another conversation.
28
+ export function track({ handoff, provider, phase, root, ticket, from, to, parent, parentEvidence, configurationId, skillsRoot } = {}) {
29
+ if (!ticket) {
30
+ if (!['codex', 'claude'].includes(provider) || !phases.includes(phase)) throw new Error('Provide provider and phase');
31
+ if ((from && !Number.isFinite(Date.parse(from))) || (to && !Number.isFinite(Date.parse(to))) || (from && to && Date.parse(from) >= Date.parse(to))) throw new Error('Invalid tracking time bounds');
32
+ const { data, run, paths } = readWorkflow(handoff, { draft: true });
33
+ if (!run) return { status: 'not-enabled', measurement: data.measurement.status, instruction: 'Resolve setup from the task documents; preserve explicit disabled measurement.' };
34
+ if (configurationId && skillsRoot) throw new Error('Choose --configuration or --skills-root');
35
+ if (configurationId && !run.configurationHistory?.some(c => c.id === configurationId)) throw new Error('Unknown configuration ID');
36
+ let configurationSnapshot, configurationWarning;
37
+ if (!configurationId) {
38
+ try {
39
+ configurationSnapshot = snapshotConfiguration({
40
+ skillsRoot: skillsRoot ?? requestProviderRoot(run.project, provider) ?? path.resolve(path.dirname(benchPath()), '../..'),
41
+ note: 'Captured by track at entry to this conversation. Snapshot of the selected bundle (default: the executing helper bundle); installed files only, not proof of loading or of earlier session configuration. Check alternate skill sources and mixed-version resumes separately.'
42
+ });
43
+ } catch (error) { configurationWarning = `Configuration snapshot unavailable: ${error.message}`; }
44
+ }
45
+ const marker = `wftrack-${crypto.randomUUID()}`;
46
+ ticket = path.join(path.dirname(data.measurement.run), 'registrations', `${marker}.json`);
47
+ const request = { schemaVersion: 1, handoff: paths.handoff, run: data.measurement.run, runId: run.id,
48
+ provider, phase, marker, root: root ? path.resolve(root) : undefined, from, to, parent, parentEvidence, configurationId,
49
+ configurationSnapshot, configurationWarning };
50
+ writeNew(ticket, `${JSON.stringify(request, null, 2)}\n`);
51
+ return { ...record(ticket, request, 'pending', 'Waiting for the first tool output to reach the transcript.'), marker,
52
+ instruction: 'Execute nextCall in a separate tool call in this same conversation. Do not ask the user to fill identifiers or carry this ticket into a continuation prompt.' };
53
+ }
54
+ if ([handoff, provider, phase, root, from, to, parent, parentEvidence, configurationId, skillsRoot].some(value => value !== undefined)) throw new Error('--ticket cannot be combined with setup options');
55
+ ticket = path.resolve(ticket);
56
+ rejectSymlinkPath(ticket);
57
+ const lock = `${ticket}.lock`;
58
+ rejectSymlinkPath(lock);
59
+ const fd = fs.openSync(lock, 'wx');
60
+ try {
61
+ const request = readJSON(ticket);
62
+ if (request.schemaVersion !== 1 || !['codex', 'claude'].includes(request.provider) || !phases.includes(request.phase)) throw new Error('Invalid registration ticket');
63
+ const { data, run } = readWorkflow(request.handoff, { draft: true });
64
+ if (!run || canonical(data.measurement.run) !== canonical(request.run) || run.id !== request.runId) throw new Error('Registration binding changed; no session registered');
65
+ let identity;
66
+ try { identity = whoami({ provider: request.provider, project: run.project, nonce: request.marker, root: request.root }); }
67
+ catch (error) { return record(ticket, request, 'pending', error.message); }
68
+ const key = `${identity.provider}:${identity.sessionId}${identity.agentId ? `:${identity.agentId}` : ''}`;
69
+ const existing = run.sessions.find(session => session.key === key);
70
+ if (existing) {
71
+ if (canonical(existing.log) !== canonical(identity.log) || existing.from !== (request.from ?? null) || existing.to !== (request.to ?? null)
72
+ || existing.parent !== (request.parent ?? null) || (request.configurationId && existing.configurationId !== request.configurationId)) {
73
+ return record(ticket, request, 'pending', 'Existing session has different source, bounds, parent or configuration; review the attribution.');
74
+ }
75
+ // A conversation moving from planning to implementation is still one native session.
76
+ if (!existing.configurationId) request.configurationWarning = 'Existing session has no attributed configuration; a later snapshot cannot establish its earlier configuration. Review historical evidence.';
77
+ return record(ticket, request, 'registered', `Already registered under ${existing.phase}; preserved without double counting.`, key);
78
+ }
79
+ try {
80
+ registerSession(request.run, { provider: identity.provider, sessionId: identity.sessionId, agentId: identity.agentId,
81
+ log: identity.log, phase: request.phase, from: request.from, to: request.to, parent: request.parent,
82
+ parentEvidence: request.parentEvidence, configurationId: request.configurationId, configurationSnapshot: request.configurationSnapshot });
83
+ } catch (error) { return record(ticket, request, 'pending', error.message); }
84
+ return record(ticket, request, 'registered', null, key);
85
+ } finally { fs.closeSync(fd); fs.unlinkSync(lock); }
86
+ }
87
+
88
+ function requestProviderRoot(projectRoot, provider) {
89
+ const candidate = path.join(projectRoot, provider === 'claude' ? '.claude' : '.agents', 'skills');
90
+ return fs.existsSync(candidate) && fs.statSync(candidate).isDirectory() ? candidate : null;
91
+ }
@@ -0,0 +1,175 @@
1
+ import fs from 'node:fs';
2
+ import { hash } from './io.mjs';
3
+
4
+ const validNumber = n => typeof n === 'number' && Number.isFinite(n) && n >= 0;
5
+ const n = value => validNumber(value) ? value : null;
6
+ const keys = ['input', 'cacheRead', 'cacheWrite', 'output', 'reasoning', 'total'];
7
+ const empty = () => Object.fromEntries(keys.map(key => [key, null]));
8
+ function add(target, value) {
9
+ for (const key of keys) target[key] = target[key] === null || value[key] === null ? null : target[key] + value[key];
10
+ }
11
+ function normalizeCodex(u) {
12
+ return { input: n(u.input_tokens), cacheRead: n(u.cached_input_tokens),
13
+ cacheWrite: n(u.cache_write_input_tokens), output: n(u.output_tokens),
14
+ reasoning: n(u.reasoning_output_tokens), total: n(u.total_tokens) };
15
+ }
16
+ function normalizeClaude(u) {
17
+ const parts = [u.input_tokens, u.cache_read_input_tokens, u.cache_creation_input_tokens];
18
+ const input = parts.every(validNumber) ? parts.reduce((a, b) => a + b, 0) : null;
19
+ return { input, cacheRead: n(u.cache_read_input_tokens), cacheWrite: n(u.cache_creation_input_tokens),
20
+ output: n(u.output_tokens), reasoning: n(u.output_tokens_details?.reasoning_tokens),
21
+ total: input !== null && validNumber(u.output_tokens) ? input + u.output_tokens : null };
22
+ }
23
+ function parseLines(text) {
24
+ const warnings = [];
25
+ const lines = text.replace(/^\uFEFF/, '').split(/\r?\n/);
26
+ const records = [];
27
+ for (let i = 0; i < lines.length; i++) {
28
+ if (!lines[i].trim()) continue;
29
+ try { records.push(JSON.parse(lines[i])); }
30
+ catch { warnings.push(i === lines.length - 1 ? 'Incomplete trailing JSON line; reimport after flush.' : `Malformed JSON at line ${i + 1}.`); }
31
+ }
32
+ return { records, warnings };
33
+ }
34
+ function inRange(time, from, to) {
35
+ const value = Date.parse(time);
36
+ return Number.isFinite(value) && (from === null || value >= from) && (to === null || value < to);
37
+ }
38
+ function boundary(value) {
39
+ if (!value) return null;
40
+ const time = Date.parse(value);
41
+ if (!Number.isFinite(time)) throw new Error(`Invalid ISO time: ${value}`);
42
+ return time;
43
+ }
44
+
45
+ function collectSkillInvocations(value, timestamp, out, seen = new Set()) {
46
+ if (!value || typeof value !== 'object' || seen.has(value)) return;
47
+ seen.add(value);
48
+ if (value.name === 'Skill') {
49
+ const skill = value.input?.skill ?? value.arguments?.skill ?? value.input?.name;
50
+ if (typeof skill === 'string') out.push({ skill, timestamp });
51
+ }
52
+ for (const child of Object.values(value)) collectSkillInvocations(child, timestamp, out, seen);
53
+ }
54
+
55
+ // A skill can also be loaded by typing its slash command, which leaves no Skill tool call.
56
+ // The recorded name is the command as typed: it may be a CLI built-in rather than a skill.
57
+ const COMMAND_OPEN = '<command-name>/', COMMAND_CLOSE = '</command-name>';
58
+ const commandName = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/;
59
+ function collectCommandInvocations(value, timestamp, out, seen = new Set()) {
60
+ if (!value || typeof value !== 'object' || seen.has(value)) return;
61
+ seen.add(value);
62
+ for (const child of Object.values(value)) {
63
+ if (typeof child !== 'string') { collectCommandInvocations(child, timestamp, out, seen); continue; }
64
+ for (let at = child.indexOf(COMMAND_OPEN); at !== -1; at = child.indexOf(COMMAND_OPEN, at + COMMAND_OPEN.length)) {
65
+ const from = at + COMMAND_OPEN.length, to = child.indexOf(COMMAND_CLOSE, from);
66
+ const name = to === -1 ? null : child.slice(from, to).trim();
67
+ if (name && commandName.test(name)) out.push({ command: name, timestamp });
68
+ }
69
+ }
70
+ }
71
+
72
+ export function parseUsage(text, provider, options = {}) {
73
+ if (!['codex', 'claude'].includes(provider)) throw new Error('Provider must be codex or claude');
74
+ const { records, warnings } = parseLines(text);
75
+ const from = boundary(options.from), to = boundary(options.to);
76
+ if (from !== null && to !== null && from >= to) throw new Error('from must precede to');
77
+ const ids = new Set(), agents = new Set(), models = new Set(), versions = new Set(), efforts = new Set();
78
+ const times = [];
79
+ const requestKeys = [];
80
+ const skillInvocations = [], commandInvocations = [];
81
+ let usage = empty(), raw = {}, observed = 0;
82
+ let started = false;
83
+ const accumulate = value => { if (!started) { usage = value; started = true; } else add(usage, value); observed++; };
84
+ if (provider === 'codex') {
85
+ let previous = null;
86
+ for (const record of records) {
87
+ collectSkillInvocations(record, record.timestamp ?? null, skillInvocations);
88
+ collectCommandInvocations(record, record.timestamp ?? null, commandInvocations);
89
+ const p = record.payload ?? {};
90
+ if (record.type === 'session_meta') {
91
+ if (p.id ?? p.session_id) ids.add(p.id ?? p.session_id);
92
+ if (p.cli_version) versions.add(p.cli_version);
93
+ }
94
+ if (record.type === 'turn_context' && inRange(record.timestamp, from, to)) {
95
+ if (p.model) models.add(p.model);
96
+ if (p.effort) efforts.add(p.effort);
97
+ }
98
+ if (record.type !== 'event_msg' || p.type !== 'token_count' || !p.info?.total_token_usage) continue;
99
+ const current = normalizeCodex(p.info.total_token_usage);
100
+ const selected = inRange(record.timestamp, from, to);
101
+ if (selected) {
102
+ times.push(record.timestamp);
103
+ raw = Object.fromEntries(Object.entries(p.info.total_token_usage).filter(([, v]) => validNumber(v)));
104
+ if (previous) {
105
+ const delta = Object.fromEntries(keys.map(k => [k, current[k] === null || previous[k] === null ? null : current[k] - previous[k]]));
106
+ if (Object.values(delta).some(v => v !== null && v < 0)) {
107
+ warnings.push('Codex cumulative counter reset/decrease: usage after this point is incomplete; delimit the session.');
108
+ } else if (Object.values(delta).some(v => v > 0)) accumulate(delta);
109
+ } else if (from === null) accumulate(current);
110
+ else warnings.push('No preceding Codex counter at lower bound; first in-range usage omitted.');
111
+ }
112
+ previous = current;
113
+ }
114
+ if (from === null) warnings.push('Full-history import: confirm the log contains no inherited fork prefix or work for another task.');
115
+ } else {
116
+ const messages = new Map();
117
+ for (const record of records) {
118
+ collectSkillInvocations(record, record.timestamp ?? null, skillInvocations);
119
+ collectCommandInvocations(record, record.timestamp ?? null, commandInvocations);
120
+ if (record.sessionId ?? record.session_id) ids.add(record.sessionId ?? record.session_id);
121
+ if (record.agentId) agents.add(record.agentId);
122
+ if (!record.message?.usage) continue;
123
+ const message = record.message;
124
+ if (!message.id) { warnings.push('Claude usage without message ID omitted.'); continue; }
125
+ const selected = inRange(record.timestamp, from, to);
126
+ if (selected) {
127
+ if (record.version) versions.add(record.version);
128
+ if (record.effort) efforts.add(record.effort);
129
+ if (message.model) models.add(message.model);
130
+ times.push(record.timestamp);
131
+ }
132
+ const key = message.id;
133
+ const clean = {};
134
+ for (const field of ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']) {
135
+ if (validNumber(message.usage[field])) clean[field] = message.usage[field];
136
+ }
137
+ if (validNumber(message.usage.output_tokens_details?.reasoning_tokens)) clean.output_tokens_details = { reasoning_tokens: message.usage.output_tokens_details.reasoning_tokens };
138
+ const previous = messages.get(key);
139
+ // Stream fragments can have growing output counts. Preserve the maximal count for a single request.
140
+ if (previous) {
141
+ for (const field of ['input_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']) {
142
+ if (previous[field] !== undefined && clean[field] !== undefined && previous[field] !== clean[field]) warnings.push('Conflicting Claude input usage for the same request/message.');
143
+ }
144
+ for (const field of ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']) {
145
+ if (previous[field] !== undefined) clean[field] = Math.max(previous[field], clean[field] ?? 0);
146
+ }
147
+ clean.output_tokens_details ??= previous.output_tokens_details;
148
+ }
149
+ clean.selected = selected || previous?.selected || false;
150
+ clean.outside = !selected || previous?.outside || false;
151
+ messages.set(key, clean);
152
+ }
153
+ for (const [key, u] of messages) {
154
+ if (!u.selected) continue;
155
+ if (u.outside) { warnings.push('Claude request spans a time boundary; ambiguous request omitted.'); continue; }
156
+ accumulate(normalizeClaude(u));
157
+ requestKeys.push(hash(key));
158
+ for (const [k, v] of Object.entries(u)) if (validNumber(v)) raw[k] = (raw[k] ?? 0) + v;
159
+ }
160
+ }
161
+ if (options.sessionId && !ids.has(options.sessionId)) throw new Error('Native session ID does not match the selected log');
162
+ if (options.agentId && !agents.has(options.agentId)) throw new Error('Native agent ID does not match the selected log');
163
+ if (ids.size > 1) warnings.push('Multiple native session IDs in source; attribution requires explicit review.');
164
+ if (!observed) warnings.push('No attributable usage records. Missing values are not zero.');
165
+ if (usage.input === null || usage.output === null || usage.total === null) warnings.push('Core token counts unavailable or incomplete.');
166
+ const sorted = times.map(Date.parse).filter(Number.isFinite).sort((a, b) => a - b);
167
+ return { schemaVersion: 1, provider, sourceHash: hash(text), sessionIds: [...ids], agentIds: [...agents],
168
+ models: [...models], versions: [...versions], efforts: [...efforts], usage, nativeUsage: raw,
169
+ usageRecords: observed, requestKeys, warnings: [...new Set(warnings)],
170
+ observedDurationMs: sorted.length > 1 ? sorted.at(-1) - sorted[0] : null,
171
+ skillInvocations: skillInvocations.filter((entry, index, all) => index === all.findIndex(other => other.skill === entry.skill && other.timestamp === entry.timestamp)),
172
+ commandInvocations: commandInvocations.filter((entry, index, all) => index === all.findIndex(other => other.command === entry.command && other.timestamp === entry.timestamp)),
173
+ from: options.from ?? null, to: options.to ?? null };
174
+ }
175
+ export const importUsage = (file, provider, options) => parseUsage(fs.readFileSync(file, 'utf8'), provider, options);