diff --git a/.agents/skills/bpmn2agent-cost/SKILL.md b/.agents/skills/bpmn2agent-cost/SKILL.md index 1fa838a..5ec6b18 100644 --- a/.agents/skills/bpmn2agent-cost/SKILL.md +++ b/.agents/skills/bpmn2agent-cost/SKILL.md @@ -18,8 +18,23 @@ report the numbers the script prints and never recompute or round them yourself. - **Run data**, in this order: 1. `.claude/runs//ledger.jsonl`, written by the workflow's cost-ledger hook; survives the cleanup of old transcripts. No session named → the newest. - 2. A session transcript `~/.claude/projects//.jsonl` (subagents sit next to it); - use it when the workflow has no ledger yet or to cross-check one. + 2. A session transcript `~/.claude/projects//.jsonl` (subagents sit next to it, + Workflow-tool agents under `subagents/workflows//`); use it when the workflow has no ledger + yet or to cross-check one. +- **Telemetry (optional, makes it exact):** the transcripts of Workflow-tool agents only hold a + streaming snapshot of the output tokens. Without telemetry the report completes them from + `cost-state` and marks that share as estimated. To get exact numbers, start Claude Code with + telemetry pointed at the bundled sink and pass its file as `--otel`: + + ```bash + node ${CLAUDE_SKILL_DIR}/scripts/otel-sink.mjs otel.jsonl & # OTLP/HTTP JSON receiver on :4318 + CLAUDE_CODE_ENABLE_TELEMETRY=1 OTEL_LOGS_EXPORTER=otlp OTEL_METRICS_EXPORTER=otlp \ + OTEL_EXPORTER_OTLP_PROTOCOL=http/json OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 \ + OTEL_LOGS_EXPORT_INTERVAL=2000 claude + ``` + + Its `api_request` events carry the request id, so every request gets its exact usage, and the + web-search/fetch helper calls that have no transcript show up as *Hilfsaufrufe*. ## 2. Run the report @@ -28,7 +43,7 @@ node ${CLAUDE_SKILL_DIR}/scripts/cost-report.mjs --map \ --ledger .claude/runs//ledger.jsonl # or: --session ``` -Optional: `--session-id ` (ledger with several runs), `--claude-json ` (result of +Optional: `--otel ` (exact output tokens, see above), `--session-id ` (ledger with several runs), `--claude-json ` (result of `claude -p --output-format json`, its `total_cost_usd` is compared), `--out `. It writes `cost.json` and `cost.md` next to the ledger and prints the checks. Exit `1` means a reconciliation check failed: say which one and do not present the cost as reliable. @@ -38,8 +53,11 @@ check failed: say which one and do not present the cost as reliable. - Lead with the total and the top elements; point at `cost.md` for the tables. - **nicht zugeordnet** over 5 % means the label convention or the cost map is off. Name where the requests came from (the report lists them); do not distribute the cost over elements. -- **Hilfsaufrufe** are in the session total but in no transcript (web-search helper model and the - like). A warning that a model's tokens are missing means the session was resumed or cleared. +- **Hilfsaufrufe** are in the session total but in no transcript (web-search/fetch helper calls). A + warning that a model's tokens are missing means that, or that the session was resumed or cleared. +- **geschätzt**: say how much. The total is exact; only the split of the missing output tokens over + the Workflow agents follows text length (on a real run the per-element error summed to under 2 % of + the cost). Offer `--otel` for exact figures; never present the estimated share as measured. - MCP calls (Gemini/NotebookLM and others) are counted but carry no price: they are billed outside Claude. - Prices are those of `prices.json`; the report names its version. A failing "Preis reproduziert diff --git a/.agents/skills/bpmn2agent-cost/scripts/cost-report.mjs b/.agents/skills/bpmn2agent-cost/scripts/cost-report.mjs index 9d903af..5e509d5 100644 --- a/.agents/skills/bpmn2agent-cost/scripts/cost-report.mjs +++ b/.agents/skills/bpmn2agent-cost/scripts/cost-report.mjs @@ -7,6 +7,8 @@ // node cost-report.mjs --map --session [options] // node cost-report.mjs --map --ledger [--session-id ] [options] // Options: +// --otel telemetry dump of the run (OTLP/JSON, one {path, body} per line): exact output tokens +// per request and the helper calls that have no transcript; see SKILL.md // --claude-json result of `claude -p --output-format json`; its total_cost_usd is compared // --prices default: prices.json next to this script // --out default: // or ./cost// @@ -24,13 +26,20 @@ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { costOf, loadPrices, priceKey, readLedger, readSession, sumUsage } from './read-usage.mjs' +import { costOf, loadPrices, priceKey, readLedger, readOtel, readSession, sumUsage } from './read-usage.mjs' const stripPlugin = (s) => (s ?? '').replace(/^[^:]+:/, '') +/** First word of a dispatch label; a composite id "Call_R1/R_4" (inner step of a call activity) falls back to "R_4". */ +export function elementIdOf(description, map) { + const first = (description ?? '').split(/\s+/)[0].replace(/[:,]$/, '') + return [first, first.split('/').at(-1)].find((c) => c && map.elements[c]) ?? null +} + export function attribute(req, map) { - const first = (req.description ?? '').split(/\s+/)[0].replace(/[:,]$/, '') - if (req.source === 'subagent' && map.elements[first]) return { type: 'element', id: first } + if (req.source === 'auxiliary') return { type: 'auxiliary', id: req.description ?? 'Hilfsaufruf' } + const dispatched = req.source === 'subagent' ? elementIdOf(req.description, map) : null + if (dispatched) return { type: 'element', id: dispatched } const agent = stripPlugin(req.agentType) if (req.source === 'subagent' && map.orchestration.agentTypes.includes(agent)) return { type: 'orchestration', id: 'Orchestrierung' } const skill = stripPlugin(req.attributionSkill) @@ -44,18 +53,95 @@ export function attribute(req, map) { return { type: 'unassigned', id: 'nicht zugeordnet' } } -const MODEL_TOKEN_FIELDS = [ - ['input', 'inputTokens'], ['output', 'outputTokens'], ['cacheRead', 'cacheReadInputTokens'], -] +/** cost-state names carry context variants (claude-opus-5-5[1m]); sum them per priced model. */ +export function sumCostState(costState, prices) { + const by = new Map() + for (const [model, v] of Object.entries(costState.modelUsage)) { + const key = priceKey(model, prices) ?? model + const cur = by.get(key) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, webSearch: 0, costUSD: 0 } + cur.input += v.inputTokens; cur.output += v.outputTokens; cur.cacheRead += v.cacheReadInputTokens + cur.cacheWrite += v.cacheCreationInputTokens; cur.webSearch += v.webSearchRequests ?? 0; cur.costUSD += v.costUSD + by.set(key, cur) + } + return by +} + +/** + * The transcripts of Workflow-tool agents hold input and cache tokens exactly, but output_tokens only as a + * streaming snapshot (`final: false`). Two ways to complete them, exact first: + * - otel: the run's own api_request events, joined on request id, give the exact output and also + * the helper calls (web search/fetch) that have no transcript at all; + * - cost-state: when everything else matches token for token, the output still missing per model is known + * exactly in total; it is distributed over the incomplete requests by text length. The total stays + * exact, the split per element is an estimate and reported as such (`estimatedOutput`). + */ +export function completeRequests({ requests, costState, otel, prices }) { + let reqs = requests.map((r) => ({ ...r, usage: { ...r.usage } })) + const info = { otel: null, estimate: null, lowerBound: [] } + if (otel && otel.size) { + const seen = new Set() + let mismatch = 0 + reqs = reqs.map((r) => { + const o = otel.get(r.requestId) + if (!o) return r + seen.add(r.requestId) + if (o.usage.input !== r.usage.input || o.usage.cacheRead !== r.usage.cacheRead || o.usage.cacheWriteTotal !== r.usage.cacheWrite5m + r.usage.cacheWrite1h) mismatch++ + return { ...r, final: true, usage: { ...r.usage, output: o.usage.output } } + }) + const aux = [...otel.values()].filter((o) => !seen.has(o.requestId)).map((o) => ({ + requestId: o.requestId, source: 'auxiliary', agentType: null, description: `Hilfsaufruf ${o.querySource ?? 'unbekannt'}`, + model: o.model, final: true, costUsd: o.costUsd, + usage: { input: o.usage.input, output: o.usage.output, cacheRead: o.usage.cacheRead, cacheWrite5m: o.usage.cacheWriteTotal, cacheWrite1h: 0, webSearch: 0 }, + })) + reqs.push(...aux) + info.otel = { matched: seen.size, auxiliary: aux.length, mismatch, events: otel.size } + return { requests: reqs, info } + } + if (!costState) { + const n = reqs.filter((r) => !r.final).length + if (n) info.lowerBound.push({ model: null, requests: n, reason: 'kein cost-state' }) + return { requests: reqs, info } + } + const csBy = sumCostState(costState, prices) + const groups = new Map() + reqs.forEach((r, i) => groups.set(priceKey(r.model, prices) ?? r.model, [...(groups.get(priceKey(r.model, prices) ?? r.model) ?? []), i])) + let estTokens = 0 + let estReqs = 0 + for (const [key, idxs] of groups) { + const inc = idxs.filter((i) => !reqs[i].final) + if (!inc.length) continue + const cs = csBy.get(key) + const have = sumUsage(idxs.map((i) => reqs[i].usage)) + const exact = cs && cs.input === have.input && cs.cacheRead === have.cacheRead && cs.cacheWrite === have.cacheWrite5m + have.cacheWrite1h + const gap = cs ? cs.output - have.output : 0 + if (!exact || gap <= 0) { info.lowerBound.push({ model: key, requests: inc.length, reason: exact ? 'cost-state hat nicht mehr Ausgabe' : 'Tokens stimmen nicht überein' }); continue } + const w = inc.map((i) => Math.max(reqs[i].chars || 0, 1)) + const W = w.reduce((a, b) => a + b, 0) + inc.forEach((i, k) => { + const e = (gap * w[k]) / W + reqs[i] = { ...reqs[i], usage: { ...reqs[i].usage, output: reqs[i].usage.output + e }, estimatedOutput: e } + }) + estTokens += gap + estReqs += inc.length + } + if (estReqs) info.estimate = { outputTokens: estTokens, requests: estReqs, method: 'Textlänge' } + return { requests: reqs, info } +} -export function buildReport({ requests, costState, map, prices, claudeTotal, sessionId, version, incomplete = 0 }) { +export function buildReport({ requests: rawRequests, costState, otel = null, map, prices, claudeTotal, sessionId, version }) { + const completed = completeRequests({ requests: rawRequests, costState, otel, prices }) + const requests = completed.requests const rows = new Map() + const phaseCost = new Map() // phase -> {requests, costUsd}, per request: an agent's own workflowPhase fills what the map lacks const mcp = new Map() const byModel = new Map() let total = 0 + let estimatedUsd = 0 for (const r of requests) { - const cost = costOf(r.model, r.usage, prices) + const cost = r.costUsd ?? costOf(r.model, r.usage, prices) + const estUsd = r.estimatedOutput ? (r.estimatedOutput * prices.models[priceKey(r.model, prices)].output) / 1e6 : 0 total += cost + estimatedUsd += estUsd const a = attribute(r, map) const key = `${a.type}:${a.id}` if (!rows.has(key)) { @@ -66,17 +152,22 @@ export function buildReport({ requests, costState, map, prices, claudeTotal, ses type: a.type, id: a.id, label: el?.label ?? (a.type === 'lane' ? (map.lanes[a.id] ?? a.id) : a.type === 'skill' ? `Skill ${a.id}` : a.id), elements: a.type === 'skill' ? map.skills[a.id] : a.type === 'element' ? [a.id] : [], - lane, phase: el?.phase ?? null, requests: 0, usage: [], costUsd: 0, + lane, phase: el?.phase ?? null, requests: 0, usage: [], costUsd: 0, estimatedUsd: 0, unassignedFrom: a.type === 'unassigned' ? new Set() : null, }) } const row = rows.get(key) + const phase = (a.type === 'element' ? map.elements[a.id]?.phase : null) ?? r.phase ?? null + row.phase ??= phase + const pc = phaseCost.get(phase ?? '(ohne Phase)') ?? { requests: 0, costUsd: 0 } + phaseCost.set(phase ?? '(ohne Phase)', { requests: pc.requests + 1, costUsd: pc.costUsd + cost }) row.requests++ row.usage.push(r.usage) row.costUsd += cost + row.estimatedUsd += estUsd row.unassignedFrom?.add(`${r.agentType ?? 'main'} | ${r.description ?? ''}`.slice(0, 80)) if (r.attributionMcpServer) mcp.set(r.attributionMcpServer, (mcp.get(r.attributionMcpServer) ?? 0) + 1) - const m = priceKey(r.model, prices) + const m = priceKey(r.model, prices) ?? r.model byModel.set(m, [...(byModel.get(m) ?? []), { usage: r.usage, cost }]) } const outRows = [...rows.values()].map((r) => ({ @@ -91,7 +182,7 @@ export function buildReport({ requests, costState, map, prices, claudeTotal, ses } return [...m.entries()].map(([name, v]) => ({ name, ...v })).sort((a, b) => b.costUsd - a.costUsd) } - const laneName = (r) => r.type === 'orchestration' || r.type === 'unassigned' ? r.id : (r.lane ? (map.lanes[r.lane] ?? r.lane) : '(Lane unbekannt)') + const laneName = (r) => r.type === 'auxiliary' ? 'Hilfsaufrufe' : r.type === 'orchestration' || r.type === 'unassigned' ? r.id : (r.lane ? (map.lanes[r.lane] ?? r.lane) : '(Lane unbekannt)') const models = [...byModel.entries()].map(([model, list]) => ({ model, requests: list.length, usage: sumUsage(list.map((x) => x.usage)), costUsd: list.reduce((s, x) => s + x.cost, 0), })).sort((a, b) => b.costUsd - a.costUsd) @@ -110,24 +201,16 @@ export function buildReport({ requests, costState, map, prices, claudeTotal, ses } else { aux = { costUsd: 0, models: [] } // cost-state names carry context variants (claude-opus-5-5[1m]); sum them per priced model - const csBy = new Map() - for (const [model, v] of Object.entries(costState.modelUsage)) { - const key = priceKey(model, prices) ?? model - const cur = csBy.get(key) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, webSearch: 0, costUSD: 0, names: [] } - cur.input += v.inputTokens; cur.output += v.outputTokens; cur.cacheRead += v.cacheReadInputTokens - cur.cacheWrite += v.cacheCreationInputTokens; cur.webSearch += v.webSearchRequests ?? 0; cur.costUSD += v.costUSD - cur.names.push(model) - csBy.set(key, cur) - } + const csBy = sumCostState(costState, prices) for (const [key, cs] of csBy) { const mine = models.find((x) => x.model === key) const have = mine?.usage ?? sumUsage([]) const diff = { - input: cs.input - have.input, output: cs.output - have.output, cacheRead: cs.cacheRead - have.cacheRead, + input: cs.input - have.input, output: Math.round(cs.output - have.output), cacheRead: cs.cacheRead - have.cacheRead, cacheWrite: cs.cacheWrite - (have.cacheWrite5m + have.cacheWrite1h), } const lagging = Object.entries(diff).filter(([, d]) => d < 0).map(([k]) => k) - const tokenExact = Object.values(diff).every((d) => d === 0) && cs.webSearch === have.webSearch + const tokenExact = Object.values(diff).every((d) => d === 0) && (cs.webSearch === have.webSearch || have.webSearch === 0) if (lagging.length) { checks.push({ level: 'warn', @@ -155,7 +238,7 @@ export function buildReport({ requests, costState, map, prices, claudeTotal, ses level: 'warn', text: `${(share * 100).toFixed(1)} % der Kosten (${aux.costUsd.toFixed(4)} USD) stehen nur in cost-state. ` + (partial.length - ? `Für ${partial.map((x) => x.model).join(', ')} fehlen Tokens im Transkript: die Session wurde vermutlich fortgesetzt oder geleert.` + ? `Für ${partial.map((x) => x.model).join(', ')} fehlen Tokens im Transkript: Hilfsaufrufe ohne Transkript (WebSearch/WebFetch) oder eine fortgesetzte bzw. geleerte Session.` : 'Das sind Hilfsaufrufe (z. B. WebSearch-Hilfsmodell), die kein Transkript haben.'), }) } @@ -167,7 +250,21 @@ export function buildReport({ requests, costState, map, prices, claudeTotal, ses checks.push({ level: 'fail', text: `total_cost_usd aus claude -p (${claudeTotal.toFixed(4)} USD) weicht um ${d.toFixed(4)} USD von der Summe aus Transkripten und Hilfsaufrufen (${grand.toFixed(4)} USD) ab.` }) } } - if (incomplete) checks.push({ level: 'warn', text: `${incomplete} Anfrage(n) ohne stop_reason (noch nicht abgeschlossen) wurden nicht mitgezählt.` }) + const { otel: otelInfo, estimate, lowerBound } = completed.info + if (otelInfo) { + checks.push({ level: 'info', text: `OpenTelemetry: ${otelInfo.matched} Anfragen exakt aus api_request-Ereignissen, ${otelInfo.auxiliary} Hilfsaufrufe ohne Transkript.` }) + if (otelInfo.mismatch) checks.push({ level: 'warn', text: `${otelInfo.mismatch} Anfrage(n): Eingabe-/Cache-Tokens aus Transkript und OpenTelemetry weichen ab.` }) + } + if (estimate && total > 0) { + checks.push({ + level: 'warn', + text: `${((estimatedUsd / total) * 100).toFixed(1)} % der Kosten (${estimatedUsd.toFixed(4)} USD) sind geschätzt: bei ${estimate.requests} Anfragen steht im Transkript nur ein Zwischenstand der Ausgabe-Tokens (so schreibt Claude Code die Agents des Workflow-Tools). ` + + `Die Gesamtsumme ist exakt (cost-state), die Verteilung der fehlenden ${Math.round(estimate.outputTokens).toLocaleString('en-US')} Ausgabe-Tokens folgt der ${estimate.method}. Exakt wird es mit --otel (Telemetrie des Laufs).`, + }) + } + for (const lb of lowerBound) { + checks.push({ level: 'warn', text: `${lb.requests} Anfrage(n)${lb.model ? ` (${lb.model})` : ''} ohne endgültige Ausgabe-Tokens (${lb.reason}): die Ausgabe ist nur eine Untergrenze.` }) + } const unassigned = outRows.find((r) => r.type === 'unassigned') if (unassigned && total > 0 && unassigned.costUsd / total > 0.05) { checks.push({ level: 'warn', text: `${((unassigned.costUsd / total) * 100).toFixed(1)} % der Kosten sind keinem Element zugeordnet: Label-Konvention oder Kostenkarte prüfen.` }) @@ -175,8 +272,9 @@ export function buildReport({ requests, costState, map, prices, claudeTotal, ses return { workflow: map.workflow, sessionId, claudeCodeVersion: version, pricesVersion: prices.version, - totals: { transcriptUsd: total, auxiliaryUsd: aux?.costUsd ?? null, sessionUsd: grand, requests: requests.length }, - byUnit: outRows, byLane: rollup(laneName), byPhase: rollup((r) => r.phase ?? '(ohne Phase)'), byModel: models, + totals: { transcriptUsd: total, auxiliaryUsd: aux?.costUsd ?? null, sessionUsd: grand, requests: requests.length, estimatedUsd }, + estimate: estimate ? { ...estimate, usd: estimatedUsd } : null, otel: otelInfo, + byUnit: outRows, byLane: rollup(laneName), byPhase: [...phaseCost.entries()].map(([name, v]) => ({ name, ...v })).sort((a, b) => b.costUsd - a.costUsd), byModel: models, auxiliary: aux, mcpCalls: Object.fromEntries(mcp), checks, ok: !checks.some((c) => c.level === 'fail'), } @@ -190,6 +288,7 @@ export function renderMarkdown(r) { L.push(`# Laufkosten ${r.workflow}`, '') L.push(`Session \`${r.sessionId}\` · Preise \`prices.json\` ${r.pricesVersion} · Claude Code ${r.claudeCodeVersion ?? 'unbekannt'}`, '') L.push(`**Gesamt ${usd(r.totals.sessionUsd)} USD** (Transkripte ${usd(r.totals.transcriptUsd)} USD${r.totals.auxiliaryUsd ? `, Hilfsaufrufe/nicht im Transkript ${usd(r.totals.auxiliaryUsd)} USD` : ''}), ${r.totals.requests} API-Anfragen.`, '') + if (r.estimate) L.push(`Davon **geschätzt ${usd(r.totals.estimatedUsd)} USD** (${((r.totals.estimatedUsd / (r.totals.transcriptUsd || 1)) * 100).toFixed(1)} %): Ausgabe-Tokens von ${r.estimate.requests} Anfragen, siehe Abgleich. Mit \`--otel\` wird daraus ein exakter Wert.`, '') L.push('## Abgleich', '') if (!r.checks.length) L.push('Alle Prüfungen bestanden: Tokens je Modell stimmen mit cost-state überein, der Preis reproduziert cost-state.', '') for (const c of r.checks) L.push(`- ${c.level === 'fail' ? '**FEHLER**' : 'Hinweis'}: ${c.text}`) @@ -197,12 +296,12 @@ export function renderMarkdown(r) { const table = (head, rows) => [head.join(' | ').replace(/^/, '| ').replace(/$/, ' |'), head.map(() => '---').join(' | ').replace(/^/, '| ').replace(/$/, ' |'), ...rows.map((x) => `| ${x.join(' | ')} |`), ''] L.push('## Je Lane', '', ...table(['Lane', 'Anfragen', 'USD', 'Anteil'], r.byLane.map((x) => [x.name, x.requests, usd(x.costUsd), `${((x.costUsd / (r.totals.transcriptUsd || 1)) * 100).toFixed(1)} %`]))) L.push('## Je Phase', '', ...table(['Phase', 'Anfragen', 'USD'], r.byPhase.map((x) => [x.name, x.requests, usd(x.costUsd)]))) - L.push('## Je Element', '', ...table(['Element', 'Label', 'Lane', 'Anfragen', 'Input', 'Output', 'Cache-Read', 'Cache-Write', 'USD'], - r.byUnit.map((x) => [x.type === 'element' ? `\`${x.id}\`` : x.type, x.label.replace(/\|/g, '/'), x.lane ?? '', x.requests, tok(x.usage.input), tok(x.usage.output), tok(x.usage.cacheRead), tok(x.usage.cacheWrite5m + x.usage.cacheWrite1h), usd(x.costUsd)]))) + L.push('## Je Element', '', ...table(['Element', 'Label', 'Lane', 'Anfragen', 'Input', 'Output', 'Cache-Read', 'Cache-Write', 'USD', 'davon geschätzt'], + r.byUnit.map((x) => [x.type === 'element' ? `\`${x.id}\`` : x.type, x.label.replace(/\|/g, '/'), x.lane ?? '', x.requests, tok(x.usage.input), tok(Math.round(x.usage.output)), tok(x.usage.cacheRead), tok(x.usage.cacheWrite5m + x.usage.cacheWrite1h), usd(x.costUsd), x.estimatedUsd ? usd(x.estimatedUsd) : '']))) const un = r.byUnit.find((x) => x.type === 'unassigned') if (un?.unassignedFrom?.length) L.push('Nicht zugeordnet kommt von:', '', ...un.unassignedFrom.map((s) => `- ${s}`), '') L.push('## Je Modell', '', ...table(['Modell', 'Anfragen', 'Input', 'Output', 'Cache-Read', 'Cache-Write 5 min', 'Cache-Write 1 h', 'USD'], - r.byModel.map((x) => [x.model, x.requests, tok(x.usage.input), tok(x.usage.output), tok(x.usage.cacheRead), tok(x.usage.cacheWrite5m), tok(x.usage.cacheWrite1h), usd(x.costUsd)]))) + r.byModel.map((x) => [x.model, x.requests, tok(x.usage.input), tok(Math.round(x.usage.output)), tok(x.usage.cacheRead), tok(x.usage.cacheWrite5m), tok(x.usage.cacheWrite1h), usd(x.costUsd)]))) if (Object.keys(r.mcpCalls).length) L.push('## MCP-Aufrufe (ohne Preis, außerhalb von Claude)', '', ...Object.entries(r.mcpCalls).map(([k, v]) => `- ${k}: ${v}`), '') return L.join('\n') } @@ -226,10 +325,10 @@ function main() { } const map = JSON.parse(fs.readFileSync(o.map, 'utf8')) const prices = loadPrices(o.prices) - let requests, costState, sessionId, version = null, incomplete = 0, defaultOut + let requests, costState, sessionId, version = null, defaultOut if (o.session) { const s = readSession(o.session) - ;({ requests, costState, sessionId, version, incomplete } = s) + ;({ requests, costState, sessionId, version } = s) defaultOut = path.join('cost', sessionId) } else { const { requests: all, costStates } = readLedger(o.ledger) @@ -240,15 +339,16 @@ function main() { version = costStates.get(sessionId)?.version ?? null defaultOut = path.join(path.dirname(o.ledger), sessionId) } + const otel = o.otel ? readOtel(o.otel, sessionId) : null const claudeTotal = o['claude-json'] ? JSON.parse(fs.readFileSync(o['claude-json'], 'utf8')).total_cost_usd : null - const report = buildReport({ requests, costState, map, prices, claudeTotal, sessionId, version, incomplete }) + const report = buildReport({ requests, costState, otel, map, prices, claudeTotal, sessionId, version }) const out = o.out ?? defaultOut fs.mkdirSync(out, { recursive: true }) fs.writeFileSync(path.join(out, 'cost.json'), JSON.stringify(report, null, 2) + '\n') fs.writeFileSync(path.join(out, 'cost.md'), renderMarkdown(report)) if (o.json) console.log(JSON.stringify(report, null, 2)) console.log(`session ${sessionId}: ${usd(report.totals.sessionUsd)} USD, ${report.byUnit.length} cost units -> ${out}/cost.md`) - for (const c of report.checks) console.log(`${c.level === 'fail' ? 'FAIL' : 'warn'}: ${c.text}`) + for (const c of report.checks) console.log(`${c.level === 'fail' ? 'FAIL' : c.level === 'info' ? 'info' : 'warn'}: ${c.text}`) console.log(report.ok ? 'reconciliation ok' : 'RECONCILIATION FAILED') process.exit(report.ok ? 0 : 1) } diff --git a/.agents/skills/bpmn2agent-cost/scripts/fixture.mjs b/.agents/skills/bpmn2agent-cost/scripts/fixture.mjs index 0e4889e..e5d2cf0 100644 --- a/.agents/skills/bpmn2agent-cost/scripts/fixture.mjs +++ b/.agents/skills/bpmn2agent-cost/scripts/fixture.mjs @@ -34,8 +34,14 @@ const u = (c, o = {}) => ({ }) /** One API request = a thinking line with a streaming snapshot, then the final line. */ -function request(id, model, extra = {}, c = OPUS_USAGE) { +function request(id, model, extra = {}, c = OPUS_USAGE, final = true) { const base = { type: 'assistant', requestId: id, ...extra } + // an agent of the Workflow tool: every line keeps the streaming snapshot, none gets the final output + if (!final) { + return [ + { ...base, message: { model, content: [{ type: 'text', text: 'x'.repeat(40) }], stop_reason: null, usage: u(c, { output: 3 }) } }, + ] + } return [ { ...base, message: { model, content: [{ type: 'thinking' }], stop_reason: null, usage: u(c, { output: 2 }) } }, { ...base, message: { model, content: [{ type: 'text' }], stop_reason: 'end_turn', usage: u(c) } }, @@ -52,7 +58,7 @@ const write = (file, lines) => { * 3 subagent requests (element K2 by description, lane QA by agent type, unassigned) and a cost-state * that also holds a haiku auxiliary call which no transcript has. */ -export function makeFixture(dir, { dropSubagent = false, sessionId = 'sess-1' } = {}) { +export function makeFixture(dir, { dropSubagent = false, sessionId = 'sess-1', snapshotWorkflowAgents = false } = {}) { const S = sessionId const OPUS = 'claude-opus-5-5' write(path.join(dir, `${S}.jsonl`), [ @@ -78,11 +84,13 @@ export function makeFixture(dir, { dropSubagent = false, sessionId = 'sess-1' } const subs = [ ['a1', { agentType: 'wf-qa', description: 'K2 Story prüfen' }], ['a2', { agentType: 'wf-qa', description: 'Nachfrage klären' }], - ['a3', { agentType: 'general-purpose', description: 'irgendwas' }], + // an agent of the Workflow tool: one level deeper, with the phase in its meta + ['a3', { agentType: 'general-purpose', description: 'irgendwas', workflowPhase: 'Eingang' }, 'workflows/wf_1'], ] - for (const [id, meta] of dropSubagent ? [] : subs) { - write(path.join(dir, S, 'subagents', `agent-${id}.jsonl`), request(`req_${id}`, OPUS)) - fs.writeFileSync(path.join(dir, S, 'subagents', `agent-${id}.meta.json`), JSON.stringify({ ...meta, spawnDepth: 1 })) + for (const [id, meta, sub = ''] of dropSubagent ? [] : subs) { + const d = path.join(dir, S, 'subagents', sub) + write(path.join(d, `agent-${id}.jsonl`), request(`req_${id}`, OPUS, {}, OPUS_USAGE, !(snapshotWorkflowAgents && sub))) + fs.writeFileSync(path.join(d, `agent-${id}.meta.json`), JSON.stringify({ ...meta, spawnDepth: 1 })) } return { dir, transcript: path.join(dir, `${S}.jsonl`), map: MAP } } diff --git a/.agents/skills/bpmn2agent-cost/scripts/otel-sink.mjs b/.agents/skills/bpmn2agent-cost/scripts/otel-sink.mjs new file mode 100644 index 0000000..09e6f39 --- /dev/null +++ b/.agents/skills/bpmn2agent-cost/scripts/otel-sink.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node +// Minimal OTLP/HTTP-JSON receiver for bpmn2agent-cost: appends every request body as one {path, body} +// line, which `cost-report.mjs --otel ` reads for exact per-request usage. No dependencies. +// Usage: node otel-sink.mjs [port=4318] (stop it with Ctrl-C / SIGTERM) +import fs from 'node:fs' +import http from 'node:http' +const [out, port = '4318'] = process.argv.slice(2) +if (!out) { console.error('usage: otel-sink.mjs [port]'); process.exit(2) } +const server = http.createServer((req, res) => { + const chunks = [] + req.on('data', (c) => chunks.push(c)) + req.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8') + let body = null + try { body = JSON.parse(text) } catch { body = { _raw: text.slice(0, 2000) } } + fs.appendFileSync(out, JSON.stringify({ path: req.url, body }) + '\n') + res.writeHead(200, { 'content-type': 'application/json' }) + res.end('{}') + }) +}) +server.listen(Number(port), '127.0.0.1', () => console.log(`otel sink on 127.0.0.1:${port} -> ${out}`)) +process.on('SIGTERM', () => server.close(() => process.exit(0))) +process.on('SIGINT', () => server.close(() => process.exit(0))) diff --git a/.agents/skills/bpmn2agent-cost/scripts/read-usage.mjs b/.agents/skills/bpmn2agent-cost/scripts/read-usage.mjs index 832d5a9..82e8d55 100644 --- a/.agents/skills/bpmn2agent-cost/scripts/read-usage.mjs +++ b/.agents/skills/bpmn2agent-cost/scripts/read-usage.mjs @@ -7,10 +7,14 @@ // Layout it understands (internal Claude Code format, see docs/plans/laufkosten/spike.md): // /.jsonl main thread, also holds the one `cost-state` entry // //subagents/agent-.jsonl (+ .meta.json with agentType and description) +// //subagents/workflows//agent-.jsonl agents of the Workflow tool; +// their meta.json also carries `workflowPhase`. Any depth below subagents/ is scanned. // // One API request spans several lines (one per content block). Only the last one carries the final -// output_tokens, so each requestId keeps the line with the highest output_tokens. A request whose -// final line has no stop_reason is incomplete: it is counted and reported, never silently summed. +// output_tokens, so each requestId keeps the line with the highest output_tokens. Input and cache +// tokens are exact on every line. A request whose best line has no stop_reason is `final: false`: its +// output_tokens is only a streaming snapshot (the agents of the Workflow tool never get the final +// value written back). It is kept, flagged, and cost-report decides how to complete it. // A line that looks like a request but lacks requestId/usage/model is a format change: fail loudly. import fs from 'node:fs' @@ -57,6 +61,18 @@ function toUsage(u, where) { } } +/** Every agent-.jsonl below dir, at any depth (Workflow tool agents sit in workflows//). */ +function agentTranscripts(dir) { + if (!fs.existsSync(dir)) return [] + const out = [] + for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const p = path.join(dir, e.name) + if (e.isDirectory()) out.push(...agentTranscripts(p)) + else if (/^agent-.*\.jsonl$/.test(e.name)) out.push(p) + } + return out +} + function readThread(file, meta, requests, stats) { for (const { line, entry } of jsonLines(file)) { if (entry.type !== 'assistant') continue @@ -68,6 +84,8 @@ function readThread(file, meta, requests, stats) { throw new Error(`${where}: assistant entry without requestId/usage/model (transcript format changed?)`) } const usage = toUsage(msg.usage, where) + const chars = (stats.chars ??= new Map()) + chars.set(entry.requestId, (chars.get(entry.requestId) ?? 0) + JSON.stringify(msg.content ?? '').length) const cur = requests.get(entry.requestId) if (!cur || usage.output >= cur.usage.output) { requests.set(entry.requestId, { @@ -77,6 +95,7 @@ function readThread(file, meta, requests, stats) { agentType: meta.agentType, description: meta.description, parentAgentId: meta.parentAgentId, + phase: meta.phase ?? null, attributionSkill: entry.attributionSkill ?? null, attributionMcpServer: entry.attributionMcpServer ?? null, attributionMcpTool: entry.attributionMcpTool ?? null, @@ -106,24 +125,23 @@ export function readSession(transcript) { const subDir = path.join(path.dirname(transcript), sessionId, 'subagents') let files = 1 - if (fs.existsSync(subDir)) { - for (const f of fs.readdirSync(subDir).filter((n) => /^agent-.*\.jsonl$/.test(n)).sort()) { - const agentId = f.slice('agent-'.length, -'.jsonl'.length) - const metaFile = path.join(subDir, `agent-${agentId}.meta.json`) - const m = fs.existsSync(metaFile) ? JSON.parse(fs.readFileSync(metaFile, 'utf8')) : {} - readThread(path.join(subDir, f), { - source: 'subagent', agentId, agentType: m.agentType ?? null, description: m.description ?? null, - parentAgentId: m.parentAgentId ?? null, - }, requests, stats) - files++ - } + for (const file of agentTranscripts(subDir)) { + const f = path.basename(file) + const agentId = f.slice('agent-'.length, -'.jsonl'.length) + const metaFile = path.join(path.dirname(file), `agent-${agentId}.meta.json`) + const m = fs.existsSync(metaFile) ? JSON.parse(fs.readFileSync(metaFile, 'utf8')) : {} + readThread(file, { + source: 'subagent', agentId, agentType: m.agentType ?? null, description: m.description ?? null, + parentAgentId: m.parentAgentId ?? null, phase: m.workflowPhase ?? null, + }, requests, stats) + files++ } - const all = [...requests.values()] + const all = [...requests.values()].map((r) => ({ ...r, final: !!r.stopReason, chars: stats.chars?.get(r.requestId) ?? 0 })) return { sessionId, version, - requests: all.filter((r) => r.stopReason), - incomplete: all.filter((r) => !r.stopReason).length, + requests: all, + incomplete: all.filter((r) => !r.final).length, costState: costState && { totalCostUSD: costState.totalCostUSD, hasUnknownModelCost: !!costState.hasUnknownModelCost, @@ -134,13 +152,37 @@ export function readSession(transcript) { } } +/** + * Reads the `api_request` log events of an OTLP/JSON telemetry dump (one {path, body} per line, as the + * pilot sink writes it) into Map. Only events of + * `sessionId` count. `cost_usd` is Claude Code's own estimate, so it is kept as a cross-check. + */ +export function readOtel(file, sessionId) { + const out = new Map() + for (const { entry } of jsonLines(file)) { + for (const rl of entry.body?.resourceLogs ?? []) for (const sl of rl.scopeLogs ?? []) for (const lr of sl.logRecords ?? []) { + const a = Object.fromEntries((lr.attributes ?? []).map((x) => [x.key, Object.values(x.value)[0]])) + if (a['event.name'] !== 'api_request' || !a.request_id || (sessionId && a['session.id'] !== sessionId)) continue + out.set(a.request_id, { + requestId: a.request_id, model: a.model, querySource: a.query_source ?? null, + usage: { + input: Number(a.input_tokens), output: Number(a.output_tokens), cacheRead: Number(a.cache_read_tokens), + cacheWriteTotal: Number(a.cache_creation_tokens), webSearch: 0, + }, + costUsd: Number(a.cost_usd), + }) + } + } + return out +} + /** Reads ledger.jsonl lines written by the generated cost-ledger hook. */ export function readLedger(file) { - const requests = new Map() // keyed by requestId: a line written twice counts once + const requests = new Map() // keyed by requestId: the latest line wins (a request may be re-written once final) const costStates = new Map() for (const { entry } of jsonLines(file)) { if (entry.kind === 'cost-state') costStates.set(entry.sessionId, entry) - else if (entry.kind === 'request') requests.set(`${entry.sessionId}/${entry.requestId}`, entry) + else if (entry.kind === 'request') requests.set(`${entry.sessionId}/${entry.requestId}`, { ...entry, final: entry.final ?? true }) // the first hook version wrote only final rows } return { requests: [...requests.values()], costStates } } diff --git a/.agents/skills/bpmn2agent-cost/scripts/test.mjs b/.agents/skills/bpmn2agent-cost/scripts/test.mjs index 11fba8c..8e6d3a8 100644 --- a/.agents/skills/bpmn2agent-cost/scripts/test.mjs +++ b/.agents/skills/bpmn2agent-cost/scripts/test.mjs @@ -5,7 +5,7 @@ import os from 'node:os' import path from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' -import { buildReport, attribute } from './cost-report.mjs' +import { buildReport, attribute, completeRequests, elementIdOf } from './cost-report.mjs' import { HAUS_COST, MAP, OPUS_COST, makeFixture } from './fixture.mjs' import { costOf, loadPrices, readSession, sumUsage } from './read-usage.mjs' @@ -48,6 +48,71 @@ test('attribution order: description id, skill, lane, main, unassigned', () => { assert.deepEqual(by.req_a3, { type: 'unassigned', id: 'nicht zugeordnet' }) }) +test('agents of the Workflow tool (subagents/workflows//) are read, with their phase', () => { + const s = readSession(makeFixture(tmp()).transcript) + const a3 = s.requests.find((r) => r.requestId === 'req_a3') + assert.equal(a3.source, 'subagent') + assert.equal(a3.phase, 'Eingang') + assert.equal(s.files, 4) // main + three agents, one of them nested +}) + +test('a composite dispatch label resolves to the inner element, an unknown one to nothing', () => { + assert.equal(elementIdOf('K2 Story prüfen', MAP), 'K2') + assert.equal(elementIdOf('Call_X/C1 Einordnen', MAP), 'C1') + assert.equal(elementIdOf('Call_X/ZZ nope', MAP), null) + assert.equal(elementIdOf(null, MAP), null) +}) + +test('phase: the map wins, else the agent\'s own workflowPhase, else none', () => { + const s = readSession(makeFixture(tmp()).transcript) + const rep = buildReport({ ...s, map: MAP, prices }) + const by = Object.fromEntries(rep.byPhase.map((p) => [p.name, p.requests])) + assert.deepEqual(by, { 'Prüfung': 1, 'Eingang': 2, '(ohne Phase)': 2 }) // K2 by map; C1 by map + a3 by meta; main + QA lane agent +}) + +test('workflow agents with snapshot-only output: total stays exact, the missing output is an estimate', () => { + const s = readSession(makeFixture(tmp(), { snapshotWorkflowAgents: true }).transcript) + assert.equal(s.incomplete, 1) + const rep = buildReport({ ...s, map: MAP, prices }) + assert.equal(rep.ok, true, JSON.stringify(rep.checks)) + near(rep.totals.sessionUsd, 5 * OPUS_COST + HAUS_COST, 'exact total from cost-state') + assert.equal(rep.estimate.requests, 1) + near(rep.estimate.outputTokens, 2000 - 3, 'the exact gap') + near(rep.totals.estimatedUsd, (1997 * 20) / 1e6, 'only the missing output is estimated') + assert.match(rep.checks.map((c) => c.text).join('\n'), /sind geschätzt/) +}) + +test('without cost-state an incomplete request stays a flagged lower bound', () => { + const s = readSession(makeFixture(tmp(), { snapshotWorkflowAgents: true }).transcript) + const rep = buildReport({ ...s, costState: null, map: MAP, prices }) + assert.equal(rep.estimate, null) + assert.match(rep.checks.map((c) => c.text).join('\n'), /nur eine Untergrenze/) +}) + +test('no estimate when tokens do not match (resumed session): lower bound instead of a wrong split', () => { + const s = readSession(makeFixture(tmp(), { snapshotWorkflowAgents: true }).transcript) + const requests = s.requests.filter((r) => r.requestId !== 'req_a1') // an earlier process is missing + const rep = buildReport({ ...s, requests, map: MAP, prices }) + assert.equal(rep.estimate, null) + assert.match(rep.checks.map((c) => c.text).join('\n'), /Untergrenze/) +}) + +test('telemetry api_request events make it exact: output per request and the helper call', () => { + const s = readSession(makeFixture(tmp(), { snapshotWorkflowAgents: true }).transcript) + const otel = new Map([ + ['req_a3', { requestId: 'req_a3', model: 'claude-opus-5-5', querySource: 'repl', usage: { input: 1000, output: 2000, cacheRead: 100000, cacheWriteTotal: 15000 }, costUsd: OPUS_COST }], + ['aux1', { requestId: 'aux1', model: 'claude-haiku-4-5-20251001', querySource: 'web_search_tool', usage: { input: 10000, output: 1000, cacheRead: 0, cacheWriteTotal: 0 }, costUsd: HAUS_COST }], + ]) + const rep = buildReport({ ...s, otel, map: MAP, prices }) + assert.equal(rep.ok, true, JSON.stringify(rep.checks)) + assert.equal(rep.estimate, null) + near(rep.totals.sessionUsd, 5 * OPUS_COST + HAUS_COST, 'total') + near(rep.totals.auxiliaryUsd, 0, 'nothing left only in cost-state') + const aux = rep.byUnit.find((u) => u.type === 'auxiliary') + near(aux.costUsd, HAUS_COST, 'helper call priced as OpenTelemetry reports it') + assert.equal(completeRequests({ requests: s.requests, costState: null, otel, prices }).info.otel.matched, 1) +}) + test('a skill shared by several elements is attributed to the skill, not guessed', () => { const r = { source: 'main', attributionSkill: 'story-check', description: null, agentType: null } assert.deepEqual(attribute(r, MAP), { type: 'skill', id: 'story-check' }) @@ -80,7 +145,7 @@ test('a missing subagent transcript (resumed session) is a warning with the reas // cost-state of the full session against only the main thread: tokens missing, not exceeding const rep = buildReport({ ...full, requests: s.requests, map: MAP, prices }) assert.equal(rep.ok, true) - assert.match(rep.checks.map((c) => c.text).join('\n'), /fortgesetzt oder geleert/) + assert.match(rep.checks.map((c) => c.text).join('\n'), /fortgesetzte bzw\. geleerte Session/) }) test('cost-state behind the transcript (resumed session) warns and the transcript sum counts', () => { diff --git a/.agents/skills/bpmn2agent-generate/assets/templates/hook-cost-ledger-template.mjs b/.agents/skills/bpmn2agent-generate/assets/templates/hook-cost-ledger-template.mjs index b1a36e3..a2b77c2 100644 --- a/.agents/skills/bpmn2agent-generate/assets/templates/hook-cost-ledger-template.mjs +++ b/.agents/skills/bpmn2agent-generate/assets/templates/hook-cost-ledger-template.mjs @@ -15,7 +15,7 @@ // // Line shapes (one JSON object per line): // {kind:'request', sessionId, requestId, source, agentId, agentType, description, attributionSkill, -// attributionMcpServer, attributionMcpTool, model, usage:{input,output,cacheRead,cacheWrite5m,cacheWrite1h,webSearch}} +// attributionMcpServer, attributionMcpTool, model, phase, final, chars, usage:{input,output,cacheRead,cacheWrite5m,cacheWrite1h,webSearch}} // {kind:'cost-state', sessionId, version, costState:{totalCostUSD, hasUnknownModelCost, modelUsage}} // // Smoke test (expect two lines, then none on a second run): @@ -46,6 +46,8 @@ function toUsage(u) { } // One API request spans several lines; only the last carries the final output_tokens. +const chars = new Map() // requestId -> length of the content it produced (a weight for completing output tokens later) + function readThread(file, meta, requests) { for (const e of jsonLines(file)) { if (e.type !== 'assistant' || !e.requestId || !e.message?.usage || !e.message.model) continue @@ -55,14 +57,26 @@ function readThread(file, meta, requests) { if (!cur || usage.output >= cur.usage.output) { requests.set(e.requestId, { kind: 'request', requestId: e.requestId, source: meta.source, agentId: meta.agentId ?? null, - agentType: meta.agentType ?? null, description: meta.description ?? null, + agentType: meta.agentType ?? null, description: meta.description ?? null, phase: meta.phase ?? null, attributionSkill: e.attributionSkill ?? null, attributionMcpServer: e.attributionMcpServer ?? null, attributionMcpTool: e.attributionMcpTool ?? null, model: e.message.model, stopReason: e.message.stop_reason ?? null, usage, }) } + chars.set(e.requestId, (chars.get(e.requestId) ?? 0) + JSON.stringify(e.message.content ?? '').length) } } +function agentTranscripts(dir) { + if (!fs.existsSync(dir)) return [] + const out = [] + for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const p = path.join(dir, e.name) + if (e.isDirectory()) out.push(...agentTranscripts(p)) + else if (/^agent-.*\.jsonl$/.test(e.name)) out.push(p) + } + return out +} + // Sleeps without spinning; a held lock only ever lasts for one append. const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) @@ -78,14 +92,12 @@ function main() { const requests = new Map() readThread(transcript, { source: 'main' }, requests) - const subDir = path.join(path.dirname(transcript), sessionId, 'subagents') - if (fs.existsSync(subDir)) { - for (const f of fs.readdirSync(subDir).filter((n) => /^agent-.*\.jsonl$/.test(n))) { - const agentId = f.slice('agent-'.length, -'.jsonl'.length) - let m = {} - try { m = JSON.parse(fs.readFileSync(path.join(subDir, `agent-${agentId}.meta.json`), 'utf8')) } catch { /* no meta */ } - readThread(path.join(subDir, f), { source: 'subagent', agentId, agentType: m.agentType, description: m.description }, requests) - } + // agents of the Workflow tool sit deeper: subagents/workflows//agent-.jsonl + for (const file of agentTranscripts(path.join(path.dirname(transcript), sessionId, 'subagents'))) { + const agentId = path.basename(file).slice('agent-'.length, -'.jsonl'.length) + let m = {} + try { m = JSON.parse(fs.readFileSync(path.join(path.dirname(file), `agent-${agentId}.meta.json`), 'utf8')) } catch { /* no meta */ } + readThread(file, { source: 'subagent', agentId, agentType: m.agentType, description: m.description, phase: m.workflowPhase }, requests) } let costState = null let version = null @@ -105,18 +117,23 @@ function main() { } if (fd < 0) return process.stderr.write(`${WORKFLOW} cost ledger: could not lock ${ledger}, skipped\n`) try { - const seen = new Set() + const seen = new Map() // requestId -> how it was last written let lastCostState = null if (fs.existsSync(ledger)) { for (const e of jsonLines(ledger)) { if (e.sessionId !== sessionId) continue - if (e.kind === 'request') seen.add(e.requestId) + if (e.kind === 'request') seen.set(e.requestId, `${e.usage?.output}|${e.final ?? true}`) if (e.kind === 'cost-state') lastCostState = JSON.stringify(e.costState) } } + // A request is (re)written when it is new or has improved. Agents of the Workflow tool never get a final + // output_tokens written back, so their rows stay final:false; the cost report completes them. const lines = [] for (const r of requests.values()) { - if (r.stopReason && !seen.has(r.requestId)) { const { stopReason, ...row } = r; lines.push({ ...row, sessionId }) } + const sig = `${r.usage.output}|${!!r.stopReason}` + if (seen.get(r.requestId) === sig) continue + const { stopReason, ...row } = r + lines.push({ ...row, final: !!stopReason, chars: chars.get(r.requestId) ?? 0, sessionId }) } if (costState) { const cs = { totalCostUSD: costState.totalCostUSD, hasUnknownModelCost: !!costState.hasUnknownModelCost, modelUsage: costState.modelUsage } diff --git a/.agents/skills/bpmn2agent-generate/scripts/build-cost-map.mjs b/.agents/skills/bpmn2agent-generate/scripts/build-cost-map.mjs index 4bb8b00..8262bba 100644 --- a/.agents/skills/bpmn2agent-generate/scripts/build-cost-map.mjs +++ b/.agents/skills/bpmn2agent-generate/scripts/build-cost-map.mjs @@ -16,6 +16,9 @@ import path from 'node:path' import { createRequire } from 'node:module' const COST_BEARING = new Set(['skill', 'script', 'agent-checklist']) +// Never dispatched as a step. Everything else may be: a hand-maintained workflow also runs a `hook` +// or `orchestrator` element as an agent, so its label must resolve to the element, not to a lane. +const NOT_DISPATCHED = new Set(['context-source', 'workflow-input', 'workflow-output', 'not-generated']) /** .../skills//... -> ; .../agents/.md -> agent:; else null */ function ownerOf(p) { @@ -41,7 +44,7 @@ export function buildCostMap(spec) { if (COST_BEARING.has(el.kind)) (skills[o.skill] ??= new Set()).add(id) else if (el.kind === 'orchestrator' || el.kind === 'human-checkpoint') orchestrationSkills.add(o.skill) } - if (!COST_BEARING.has(el.kind)) continue + if (NOT_DISPATCHED.has(el.kind)) continue elements[id] = { label: el.label, lane: el.lane ?? null, diff --git a/.agents/skills/bpmn2agent-verify/scripts/verify.mjs b/.agents/skills/bpmn2agent-verify/scripts/verify.mjs index b772c80..ef7f6ef 100644 --- a/.agents/skills/bpmn2agent-verify/scripts/verify.mjs +++ b/.agents/skills/bpmn2agent-verify/scripts/verify.mjs @@ -952,7 +952,7 @@ if (!spec) { artCat.fail( `"${relPath}" does not match the spec` + (missing.length ? `; elements missing from it: [${missing.join(', ')}]` : '') + - (foreign.length ? `; elements it lists that the spec does not have as cost-bearing: [${foreign.join(', ')}]` : '') + + (foreign.length ? `; elements it lists that the spec does not have as dispatchable: [${foreign.join(', ')}]` : '') + ' — re-run bpmn2agent-generate step 6b', 'generate' ); diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2b47134..bb62ee8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -257,11 +257,19 @@ ohne Preis oder ein `total_cost_usd` aus `claude -p`, das um mehr als 1 % abweic die Session fortgesetzt oder geleert worden, steht `cost-state` hinter dem Transkript oder deckt nur den letzten Prozess ab; das ist eine Warnung, und die Transkriptsumme gilt. +**Agents des Workflow-Tools.** Ihre Transkripte liegen unter `subagents/workflows//`, und +`meta.json` trägt neben der Beschreibung (dem Label) auch `workflowPhase`. Die Ausgabe-Tokens stehen dort +aber nur als Zwischenstand, Eingabe und Cache sind exakt. Der Bericht schließt das auf zwei Wegen: mit +`--otel` (die `api_request`-Ereignisse der Telemetrie, per `request_id` verbunden) exakt, ohne nach +`cost-state` und Textlänge verteilt und als **geschätzt** ausgewiesen. Die Gesamtsumme ist in beiden +Fällen exakt; am Pilotlauf lag die Schätzung je Element zusammen 1,8 % der Kosten daneben. + **Grenzen.** Das Transkriptformat ist intern und kann sich mit jeder Claude-Code-Version ändern; der Leser scheitert dann laut statt still falsch zu summieren. Kosten außerhalb von Claude (Gemini/ -NotebookLM über MCP) werden gezählt, aber nicht bepreist. OpenTelemetry (`claude_code.cost.usage` -mit `agent.name`, `skill.name`) taugt als Gegenprobe über viele Läufe und fürs Dashboard, kennt aber -keine Element-ID und gibt `agent.name` nur mit `OTEL_LOG_TOOL_DETAILS=1` preis. +NotebookLM über MCP) werden gezählt, aber nicht bepreist. OpenTelemetry allein +(`claude_code.cost.usage` mit `agent.name`, `skill.name`) taugt als Gegenprobe und fürs Dashboard, kennt +aber keine Element-ID und gibt `agent.name` nur mit `OTEL_LOG_TOOL_DETAILS=1` preis; als Quelle für +`cost-report` sind die `api_request`-Ereignisse mit `request_id` brauchbar. ## Wissensschicht diff --git a/docs/plans/laufkosten/plan.md b/docs/plans/laufkosten/plan.md index c690020..a1f0057 100644 --- a/docs/plans/laufkosten/plan.md +++ b/docs/plans/laufkosten/plan.md @@ -1,8 +1,9 @@ # Laufkosten je BPMN-Element messen Status: umgesetzt am 2026-10-04 (T0 bis T8), Abweichungen und Befunde in [`spike.md`](spike.md). -Offen für einen echten Lauf: die Felder der Hook-Payloads, das Verhalten des Workflow-Tools und der -Abgleich mit OpenTelemetry (siehe spike.md „Offen für T8“). Aufgaben: [`tasks.md`](tasks.md). +Der Pilotlauf mit dem Workflow-Tool ist ausgewertet (spike.md „Pilotlauf“) und hat zwei Korrekturen +gebracht: Workflow-Agents liegen unter `subagents/workflows//`, und ihre Ausgabe-Tokens sind nur +ein Zwischenstand (exakt mit `--otel`, sonst gekennzeichnet geschätzt). Aufgaben: [`tasks.md`](tasks.md). ## Ziel diff --git a/docs/plans/laufkosten/spike.md b/docs/plans/laufkosten/spike.md index 5c35c37..9548b97 100644 --- a/docs/plans/laufkosten/spike.md +++ b/docs/plans/laufkosten/spike.md @@ -55,7 +55,44 @@ ersten Entwurf haben echte Daten widerlegt und wurden korrigiert: - **`cost-report` braucht die Spec nicht:** Er liest nur die Kostenkarte. Im Erzeuger-Repo liegt sie unter `generated//.claude/hooks/-cost-map.json`. -## Offen für T8 (braucht einen echten Lauf) +## Pilotlauf mit dem Workflow-Tool (dark-factory, 2026-10-04) + +Ein echter Lauf von `product-vision-to-user-stories` im Factory-Repo (`agentCap: 14`, 15 Agents, +Gesamtkosten 10,02 USD), mit Kostenprotokoll-Hook, einem Hook, der die Payloads mitschreibt, und +OpenTelemetry in eine lokale Senke. Die drei offenen Fragen sind beantwortet; zwei Annahmen waren +falsch und wurden korrigiert. + +| Frage | Befund | +|---|---| +| Hook-Payload | `SubagentStop`: `agent_id`, `agent_type`, `agent_transcript_path`, `transcript_path` (**Hauptthread**), `session_id`, `cwd`, `permission_mode`, `prompt_id`, `effort`, `stop_hook_active`, `background_tasks`, `session_crons`, `scratchpad_dir`. `Stop`: dazu `last_assistant_message`. `SessionEnd`: `reason` (`prompt_input_exit`, `clear`), `session_id`, `transcript_path`. **Keine Token- oder Kostenfelder.** Der Hook braucht weiterhin nur `transcript_path`. | +| Feuert `SubagentStop` für Workflow-Agents? | **Ja**, 15 Ereignisse für 15 Agents. | +| Wo liegen die Transkripte der Workflow-Agents? | **Tiefer als gedacht:** `/subagents/workflows//agent-.jsonl` (+ `.meta.json`, `journal.jsonl`), daneben `/workflows/.json`. Leser und Hook scannten nur `subagents/*.jsonl` und fanden 0 von 15 Agents (0,16 von 10,02 USD). Beide scannen jetzt jede Tiefe. | +| Ist das Label die `description`? | **Ja**, und `meta.json` trägt zusätzlich `workflowPhase` (der `phase()`-Titel). Phase und Element kommen damit aus dem Transkript, nicht aus der Spec. 15 von 15 Labels beginnen mit einer Element-ID; zwei Formen kamen vor, die der Plan nicht kannte: zusammengesetzte IDs `Call_R1/R_4` (innerer Schritt einer Call Activity, gilt der Teil nach `/`) und Elemente, die die Spec als `hook` oder `orchestrator` führt, die die Fabrik aber als Agent ausführt (die Karte führt jetzt alle ausführbaren Elemente). | +| OpenTelemetry-Gegenprobe | `claude_code.cost.usage` summiert auf **10,0193 USD**, also exakt `cost-state` und die Summe des Berichts. Die `api_request`-Ereignisse tragen dieselbe `request_id` wie die Transkriptzeilen, dazu exakte Tokens und `cost_usd`. `agent.name` kam mit `OTEL_LOG_TOOL_DETAILS=1` im Klartext. | + +**Korrigierte Annahme: Ausgabe-Tokens der Workflow-Agents.** Im Transkript eines Workflow-Agents trägt +die Zeile einer Anfrage nur den Zwischenstand der Ausgabe (16 statt 365 Tokens, kein `stop_reason`), +der Endwert wird nie nachgetragen. Von 168 Anfragen waren nur 34 vollständig. Eingabe- und +Cache-Tokens stimmen dagegen bei allen 168 exakt mit OpenTelemetry überein. Der Rest (1,26 USD) sind 52 +Hilfsaufrufe (`web_search_tool`, `web_fetch_apply`), die in keinem Transkript stehen. Zwei Wege, das zu +schließen: + +- **Mit Telemetrie (exakt):** `cost-report.mjs --otel` ersetzt je `request_id` die Ausgabe und führt die + Hilfsaufrufe als eigene Zeilen. Summe 10,0193 USD, nichts geschätzt. +- **Ohne Telemetrie (geschätzt, gekennzeichnet):** Stimmen Eingabe und Cache je Modell exakt mit + `cost-state` überein, ist die fehlende Ausgabe in der Summe exakt bekannt. Sie wird nach Textlänge auf + die unvollständigen Anfragen verteilt. Am Pilot: Gesamtsumme exakt, je Element eine Abweichung von + zusammen 1,8 % der Kosten (größter Einzelfehler 0,05 USD bei einem Element mit 0,67 USD). Stimmen die + Tokens nicht überein (fortgesetzte Session), gibt es keine Schätzung, nur eine Untergrenze mit Hinweis. + +Nicht brauchbar: `totalTokens` und `tokens` in `workflows/.json` entsprechen keiner Kombination +aus Eingabe, Cache und Ausgabe. + +**Was der Lauf gekostet hat** (exakt): Phase *0 Idee-Brief & DR-01* 6,90 USD, davon der Schritt +„Claims extrahieren & Abdeckung prüfen“ (`R_3b`) allein 2,76 USD (27 % des Laufs); Lane *Researcher* +6,24 USD; Hilfsaufrufe 1,26 USD. + +## Früher offen (jetzt beantwortet) 1. **Hook-Payload:** Welche Felder `SubagentStop`, `Stop` und `SessionEnd` tatsächlich liefern und ob `SubagentStop` auch für Agents aus dem Workflow-Tool feuert. Der Hook ist so gebaut, dass er diff --git a/examples/user-story-refinement/generated/user-story-refinement/.claude/hooks/user-story-refinement-cost-ledger.mjs b/examples/user-story-refinement/generated/user-story-refinement/.claude/hooks/user-story-refinement-cost-ledger.mjs index c392c24..a436688 100644 --- a/examples/user-story-refinement/generated/user-story-refinement/.claude/hooks/user-story-refinement-cost-ledger.mjs +++ b/examples/user-story-refinement/generated/user-story-refinement/.claude/hooks/user-story-refinement-cost-ledger.mjs @@ -15,7 +15,7 @@ // // Line shapes (one JSON object per line): // {kind:'request', sessionId, requestId, source, agentId, agentType, description, attributionSkill, -// attributionMcpServer, attributionMcpTool, model, usage:{input,output,cacheRead,cacheWrite5m,cacheWrite1h,webSearch}} +// attributionMcpServer, attributionMcpTool, model, phase, final, chars, usage:{input,output,cacheRead,cacheWrite5m,cacheWrite1h,webSearch}} // {kind:'cost-state', sessionId, version, costState:{totalCostUSD, hasUnknownModelCost, modelUsage}} // // Smoke test (expect two lines, then none on a second run): @@ -46,6 +46,8 @@ function toUsage(u) { } // One API request spans several lines; only the last carries the final output_tokens. +const chars = new Map() // requestId -> length of the content it produced (a weight for completing output tokens later) + function readThread(file, meta, requests) { for (const e of jsonLines(file)) { if (e.type !== 'assistant' || !e.requestId || !e.message?.usage || !e.message.model) continue @@ -55,14 +57,26 @@ function readThread(file, meta, requests) { if (!cur || usage.output >= cur.usage.output) { requests.set(e.requestId, { kind: 'request', requestId: e.requestId, source: meta.source, agentId: meta.agentId ?? null, - agentType: meta.agentType ?? null, description: meta.description ?? null, + agentType: meta.agentType ?? null, description: meta.description ?? null, phase: meta.phase ?? null, attributionSkill: e.attributionSkill ?? null, attributionMcpServer: e.attributionMcpServer ?? null, attributionMcpTool: e.attributionMcpTool ?? null, model: e.message.model, stopReason: e.message.stop_reason ?? null, usage, }) } + chars.set(e.requestId, (chars.get(e.requestId) ?? 0) + JSON.stringify(e.message.content ?? '').length) } } +function agentTranscripts(dir) { + if (!fs.existsSync(dir)) return [] + const out = [] + for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const p = path.join(dir, e.name) + if (e.isDirectory()) out.push(...agentTranscripts(p)) + else if (/^agent-.*\.jsonl$/.test(e.name)) out.push(p) + } + return out +} + // Sleeps without spinning; a held lock only ever lasts for one append. const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) @@ -78,14 +92,12 @@ function main() { const requests = new Map() readThread(transcript, { source: 'main' }, requests) - const subDir = path.join(path.dirname(transcript), sessionId, 'subagents') - if (fs.existsSync(subDir)) { - for (const f of fs.readdirSync(subDir).filter((n) => /^agent-.*\.jsonl$/.test(n))) { - const agentId = f.slice('agent-'.length, -'.jsonl'.length) - let m = {} - try { m = JSON.parse(fs.readFileSync(path.join(subDir, `agent-${agentId}.meta.json`), 'utf8')) } catch { /* no meta */ } - readThread(path.join(subDir, f), { source: 'subagent', agentId, agentType: m.agentType, description: m.description }, requests) - } + // agents of the Workflow tool sit deeper: subagents/workflows//agent-.jsonl + for (const file of agentTranscripts(path.join(path.dirname(transcript), sessionId, 'subagents'))) { + const agentId = path.basename(file).slice('agent-'.length, -'.jsonl'.length) + let m = {} + try { m = JSON.parse(fs.readFileSync(path.join(path.dirname(file), `agent-${agentId}.meta.json`), 'utf8')) } catch { /* no meta */ } + readThread(file, { source: 'subagent', agentId, agentType: m.agentType, description: m.description, phase: m.workflowPhase }, requests) } let costState = null let version = null @@ -105,18 +117,23 @@ function main() { } if (fd < 0) return process.stderr.write(`${WORKFLOW} cost ledger: could not lock ${ledger}, skipped\n`) try { - const seen = new Set() + const seen = new Map() // requestId -> how it was last written let lastCostState = null if (fs.existsSync(ledger)) { for (const e of jsonLines(ledger)) { if (e.sessionId !== sessionId) continue - if (e.kind === 'request') seen.add(e.requestId) + if (e.kind === 'request') seen.set(e.requestId, `${e.usage?.output}|${e.final ?? true}`) if (e.kind === 'cost-state') lastCostState = JSON.stringify(e.costState) } } + // A request is (re)written when it is new or has improved. Agents of the Workflow tool never get a final + // output_tokens written back, so their rows stay final:false; the cost report completes them. const lines = [] for (const r of requests.values()) { - if (r.stopReason && !seen.has(r.requestId)) { const { stopReason, ...row } = r; lines.push({ ...row, sessionId }) } + const sig = `${r.usage.output}|${!!r.stopReason}` + if (seen.get(r.requestId) === sig) continue + const { stopReason, ...row } = r + lines.push({ ...row, final: !!stopReason, chars: chars.get(r.requestId) ?? 0, sessionId }) } if (costState) { const cs = { totalCostUSD: costState.totalCostUSD, hasUnknownModelCost: !!costState.hasUnknownModelCost, modelUsage: costState.modelUsage } diff --git a/examples/user-story-refinement/generated/user-story-refinement/.claude/hooks/user-story-refinement-cost-map.json b/examples/user-story-refinement/generated/user-story-refinement/.claude/hooks/user-story-refinement-cost-map.json index 14e8191..708d668 100644 --- a/examples/user-story-refinement/generated/user-story-refinement/.claude/hooks/user-story-refinement-cost-map.json +++ b/examples/user-story-refinement/generated/user-story-refinement/.claude/hooks/user-story-refinement-cost-map.json @@ -3,30 +3,60 @@ "file": "user-story-refinement.bpmn", "elements": [ "B4", + "B5", "C1", "C2", "C3", "C4", + "D1", "D2a", "D2b", "D2c", "D2d", "D3", "D4", + "D5", "D6", "E1", "E2", "E3", + "E4", "E5", + "End_AnStoryUebergeben", + "End_AnliegenVerworfen", + "End_BugUebergeben", + "End_FachkonzeptAenderung", + "End_FeedbackGeparkt", + "End_StorySprintReady", + "End_StoryVerworfen", + "End_StoryZusammengefuehrt", + "Gw_Bezug", + "Gw_Bug", + "Gw_Erkenntnis", + "Gw_Fachkonzept", + "Gw_Invest", + "Gw_Plausibel", + "Gw_Ready", + "Gw_Schaetzung", + "Gw_Sprintgroesse", + "Gw_Unsicherheit", "I1", "I2", + "I3", "I4", + "I5", "I6", + "K1", "K2", "K3", "K4", "K5", "K6", + "Merge_Einordnung", + "Merge_Formulierung", + "Merge_Refinement", + "Merge_Schaetzung", + "Merge_Validierung", "P1", "P10", "P2", @@ -35,7 +65,15 @@ "P5", "P6", "P7", - "P8" + "P8", + "P9", + "Par_AmigosJoin", + "Par_AmigosSplit", + "Par_KlaerungJoin", + "Par_KlaerungSplit", + "Par_PlausiJoin", + "Par_PlausiSplit", + "Start_FeedbackEingegangen" ] }, "workflow": "user-story-refinement", @@ -91,6 +129,15 @@ "story-clarification" ] }, + "B5": { + "label": "Erkenntnisse auswerten", + "lane": "product-owner", + "phase": null, + "kind": "human-checkpoint", + "skills": [ + "user-story-refinement" + ] + }, "C1": { "label": "Story unter Epic in User Story Map einordnen", "lane": "product-owner", @@ -127,6 +174,15 @@ "story-writing" ] }, + "D1": { + "label": "Refinement mit Three Amigos ansetzen", + "lane": "product-owner", + "phase": null, + "kind": "human-checkpoint", + "skills": [ + "user-story-refinement" + ] + }, "D2a": { "label": "Wert & Fachregeln erläutern (Was)", "lane": "product-owner", @@ -181,6 +237,15 @@ "story-refinement" ] }, + "D5": { + "label": "Story Points schätzen (Planning Poker)", + "lane": "entwicklung", + "phase": null, + "kind": "human-checkpoint", + "skills": [ + "user-story-refinement" + ] + }, "D6": { "label": "Story vertikal schneiden (Splitting-Muster)", "lane": "product-owner", @@ -217,6 +282,15 @@ "story-readiness" ] }, + "E4": { + "label": "Story priorisieren & Übernahme ins Backlog freigeben", + "lane": "product-owner", + "phase": null, + "kind": "human-checkpoint", + "skills": [ + "user-story-refinement" + ] + }, "E5": { "label": "Nachgelagerten Spike durchführen", "lane": "entwicklung", @@ -226,6 +300,168 @@ "story-readiness" ] }, + "End_AnliegenVerworfen": { + "label": "Anliegen verworfen", + "lane": "product-owner", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, + "End_AnStoryUebergeben": { + "label": "Als Änderung an offene Story übergeben", + "lane": "product-owner", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, + "End_BugUebergeben": { + "label": "An Fehlerbearbeitung übergeben", + "lane": "product-owner", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, + "End_FachkonzeptAenderung": { + "label": "Fachkonzept-Änderung beantragt", + "lane": "product-owner", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, + "End_FeedbackGeparkt": { + "label": "Feedback im Opportunity Backlog geparkt", + "lane": "product-owner", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, + "End_StorySprintReady": { + "label": "Story sprint-ready", + "lane": "product-owner", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, + "End_StoryVerworfen": { + "label": "Story verworfen", + "lane": "product-owner", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, + "End_StoryZusammengefuehrt": { + "label": "Story in bestehende Story überführt", + "lane": "product-owner", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, + "Gw_Bezug": { + "label": "Wie hängt das Anliegen mit dem Backlog zusammen?", + "lane": "product-owner", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, + "Gw_Bug": { + "label": "Fehlverhalten einer bestehenden Funktion?", + "lane": "product-owner", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, + "Gw_Erkenntnis": { + "label": "Tragen die Erkenntnisse die Story?", + "lane": "product-owner", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, + "Gw_Fachkonzept": { + "label": "Mit dem Fachkonzept vereinbar?", + "lane": "product-owner", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, + "Gw_Invest": { + "label": "INVEST erfüllt?", + "lane": "product-owner", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, + "Gw_Plausibel": { + "label": "Konsistent mit dem übrigen Backlog?", + "lane": "product-owner", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, + "Gw_Ready": { + "label": "Definition of Ready erfüllt?", + "lane": "product-owner", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, + "Gw_Schaetzung": { + "label": "Schätzungen konvergiert?", + "lane": "entwicklung", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, + "Gw_Sprintgroesse": { + "label": "Passt die Story in einen Sprint?", + "lane": "product-owner", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, + "Gw_Unsicherheit": { + "label": "Relevante Unsicherheit offen?", + "lane": "product-owner", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, "I1": { "label": "Feedback erfassen & als Problem formulieren", "lane": "product-owner", @@ -244,6 +480,15 @@ "story-triage" ] }, + "I3": { + "label": "Änderungshinweis an offene Story freigeben", + "lane": "product-owner", + "phase": null, + "kind": "human-checkpoint", + "skills": [ + "user-story-refinement" + ] + }, "I4": { "label": "Änderungshinweis an offener Story ergänzen", "lane": "product-owner", @@ -253,6 +498,15 @@ "story-backlog-publish" ] }, + "I5": { + "label": "Parken im Opportunity Backlog freigeben", + "lane": "product-owner", + "phase": null, + "kind": "human-checkpoint", + "skills": [ + "user-story-refinement" + ] + }, "I6": { "label": "Feedback im Opportunity Backlog anlegen", "lane": "product-owner", @@ -262,6 +516,15 @@ "story-backlog-publish" ] }, + "K1": { + "label": "Anliegen mit Feedbackgeber klären", + "lane": "product-owner", + "phase": null, + "kind": "human-checkpoint", + "skills": [ + "user-story-refinement" + ] + }, "K2": { "label": "Gegen Epic-Ziel & Fachkonzept prüfen", "lane": "product-owner", @@ -307,6 +570,46 @@ "story-clarification" ] }, + "Merge_Einordnung": { + "lane": "product-owner", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, + "Merge_Formulierung": { + "lane": "product-owner", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, + "Merge_Refinement": { + "lane": "product-owner", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, + "Merge_Schaetzung": { + "lane": "entwicklung", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, + "Merge_Validierung": { + "lane": "ux-design", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, "P1": { "label": "Auf Duplikate & Überschneidungen prüfen", "lane": "product-owner", @@ -387,6 +690,72 @@ "skills": [ "story-backlog-publish" ] + }, + "P9": { + "label": "Zusammenführung freigeben", + "lane": "product-owner", + "phase": null, + "kind": "human-checkpoint", + "skills": [ + "user-story-refinement" + ] + }, + "Par_AmigosJoin": { + "lane": "qa-test", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, + "Par_AmigosSplit": { + "lane": "product-owner", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, + "Par_KlaerungJoin": { + "lane": "product-owner", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, + "Par_KlaerungSplit": { + "lane": "product-owner", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, + "Par_PlausiJoin": { + "lane": "product-owner", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, + "Par_PlausiSplit": { + "lane": "product-owner", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] + }, + "Start_FeedbackEingegangen": { + "label": "Nutzer- oder Stakeholder-Feedback eingegangen", + "lane": "product-owner", + "phase": null, + "kind": "orchestrator", + "skills": [ + "user-story-refinement" + ] } }, "skills": {