@dzhechkov/harness-core 0.7.7 → 0.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Fill the run-cost ledger's missing numbers from the host's own workflow record.
3
+ *
4
+ * THE DEFECT THIS CLOSES (MEASURED 2026-08-25). `.dz/feature-adr/run-cost-ledger.jsonl` had 87 rows:
5
+ * 66 hand-typed rows carried `tokens`/`minutes`, and ALL 20 rows written automatically by the
6
+ * pipeline carried `tokens:null, minutes:null, agents:null`. So "what did this feature cost" was
7
+ * answerable only for runs a human retyped — 76% of the data was manual transcription.
8
+ *
9
+ * The numbers were never MISSING. They sit in the Claude Code host's workflow record (per-agent
10
+ * `tokens` and `durationMs`, verified to sum to the record's own total), and `deriveCostLedger`
11
+ * already reads them. The sandboxed workflow legitimately cannot: it has no filesystem and never
12
+ * sees the completion notification, which is why it writes `null` rather than an estimate — the
13
+ * right call, and the reason this join belongs on the dz side, AFTER the run.
14
+ *
15
+ * FOUR RULES, each one a test:
16
+ * 1. A derived number is MARKED ({@link LEDGER_FILL_SOURCE}). A number we computed must never be
17
+ * indistinguishable from a number the operator asserted.
18
+ * 2. A non-null value is NEVER overwritten. The operator's number is their claim about their run;
19
+ * ours is a derivation. When they disagree, theirs stands and the disagreement is reported.
20
+ * 3. A row we cannot fill is REPORTED, never silently skipped — no `runId`, or a `runId` with no
21
+ * host record, comes back named. Silence would read as "nothing left to fill".
22
+ * 4. Every other field and the row ORDER survive byte-for-byte. This rewrites an append-only log;
23
+ * the only defensible rewrite is one that changes exactly the fields it says it changes.
24
+ *
25
+ * @packageDocumentation
26
+ */
27
+
28
+ /** Marks a value this module derived from the host record rather than one a human typed. */
29
+ export const LEDGER_FILL_SOURCE = 'host-record';
30
+
31
+ /**
32
+ * HOW the row was matched to a host record. This is not decoration: the two are different strengths
33
+ * of evidence and a reader must be able to tell them apart.
34
+ *
35
+ * - `runId` — the row named the run. Exact.
36
+ * - `slug` — the row named only the feature, and exactly ONE host run carried that slug. A feature
37
+ * run twice has two runs with one slug, and attributing one run's spend to the other row would be
38
+ * a fabrication, so an ambiguous slug is REFUSED rather than resolved to the first match.
39
+ */
40
+ export type LedgerFillKey = 'runId' | 'slug';
41
+
42
+ /** The facts lookup returns this when the row's key matches MORE THAN ONE host run. */
43
+ export const AMBIGUOUS = 'ambiguous' as const;
44
+
45
+ /** What the host record knows about one run. `null` for a field the record itself did not carry. */
46
+ export interface RunCostFacts {
47
+ readonly tokens: number | null;
48
+ readonly minutes: number | null;
49
+ readonly agents: number | null;
50
+ }
51
+
52
+ /** One row's outcome. `filled` lists the field names that changed — empty means nothing did. */
53
+ export interface LedgerBackfillRow {
54
+ readonly index: number;
55
+ readonly runId: string | null;
56
+ /** The row's feature slug, used as the fallback join key when it names no run. */
57
+ readonly slug: string | null;
58
+ /** Which key actually matched, when something was filled. */
59
+ readonly key: LedgerFillKey | null;
60
+ readonly filled: readonly string[];
61
+ /** Why nothing was filled. `null` when something was. */
62
+ readonly skipped: 'no-join-key' | 'no-host-record' | 'ambiguous-slug' | 'shared-run-claim' | 'already-complete' | 'malformed-line' | 'host-record-empty' | null;
63
+ }
64
+
65
+ export interface LedgerBackfillPlan {
66
+ /** The ledger's lines after the fill — same count, same order, one JSON object per line. */
67
+ readonly lines: readonly string[];
68
+ readonly rows: readonly LedgerBackfillRow[];
69
+ readonly filledRows: number;
70
+ /** Fields whose existing value DISAGREES with the derived one. Reported, never overwritten. */
71
+ readonly disagreements: readonly { index: number; field: string; existing: number; derived: number }[];
72
+ }
73
+
74
+ const FILLABLE = ['tokens', 'minutes', 'agents'] as const;
75
+
76
+ const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
77
+
78
+ /**
79
+ * Plan the fill. PURE: takes the ledger's raw lines and a runId→facts lookup, returns the new lines.
80
+ * Nothing is read or written here — the caller owns the file and the atomic replace.
81
+ */
82
+ export function planLedgerBackfill(input: {
83
+ readonly lines: readonly string[];
84
+ /**
85
+ * Resolve one join key to the host record's numbers. Returns {@link AMBIGUOUS} when the key
86
+ * matches more than one run — the caller must NOT collapse that to "no record": one is "we have
87
+ * nothing", the other is "we have too much to choose honestly", and only the second is a defect
88
+ * in the ledger's own key.
89
+ */
90
+ readonly facts: (key: LedgerFillKey, value: string) => RunCostFacts | typeof AMBIGUOUS | null;
91
+ }): LedgerBackfillPlan {
92
+ // A run's spend belongs to ONE row. An L/XL feature writes a `plan` row and a `full` row under one
93
+ // slug, and giving both the same run total would double-count that run for anyone who sums the
94
+ // column — a fabrication produced by addition rather than by writing. This is the mirror of the
95
+ // ambiguous-slug rule: there, many runs claimed one row; here, one run would be claimed by many.
96
+ // Both are refused, and for the same reason: we cannot tell which claim is the true one.
97
+ const claimants = new Map<string, number>();
98
+ for (const raw of input.lines) {
99
+ try {
100
+ const parsed: unknown = JSON.parse(raw);
101
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
102
+ const r = parsed as Record<string, unknown>;
103
+ if (FILLABLE.every((f) => isNum(r[f]))) continue;
104
+ const rid = typeof r['runId'] === 'string' && r['runId'] !== '' ? r['runId'] : null;
105
+ const slg = typeof r['slug'] === 'string' && r['slug'] !== '' ? r['slug'] : null;
106
+ const k = rid !== null ? 'runId:' + rid : slg !== null ? 'slug:' + slg : null;
107
+ if (k !== null) claimants.set(k, (claimants.get(k) ?? 0) + 1);
108
+ } catch { /* a torn line claims nothing */ }
109
+ }
110
+
111
+ const outLines: string[] = [];
112
+ const rows: LedgerBackfillRow[] = [];
113
+ const disagreements: { index: number; field: string; existing: number; derived: number }[] = [];
114
+ let filledRows = 0;
115
+
116
+ input.lines.forEach((raw, index) => {
117
+ // A line we cannot parse is passed through UNCHANGED. This rewrites an append-only log; a
118
+ // torn line is evidence, and dropping or "repairing" it would destroy the only trace of it.
119
+ let row: Record<string, unknown>;
120
+ try {
121
+ const parsed: unknown = JSON.parse(raw);
122
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not an object');
123
+ row = parsed as Record<string, unknown>;
124
+ } catch {
125
+ outLines.push(raw);
126
+ if (raw.trim() !== '') rows.push({ index, runId: null, slug: null, key: null, filled: [], skipped: 'malformed-line' });
127
+ return;
128
+ }
129
+
130
+ const runId = typeof row['runId'] === 'string' && row['runId'] !== '' ? row['runId'] : null;
131
+ const slug = typeof row['slug'] === 'string' && row['slug'] !== '' ? row['slug'] : null;
132
+ const missing = FILLABLE.filter((f) => !isNum(row[f]));
133
+ if (missing.length === 0) {
134
+ outLines.push(raw);
135
+ rows.push({ index, runId, slug, key: null, filled: [], skipped: 'already-complete' });
136
+ return;
137
+ }
138
+ // runId is exact and wins. The slug is the FALLBACK, and only because the sandboxed workflow
139
+ // cannot know its own run id — it has no access to one, so a row it writes can never name one.
140
+ const key: LedgerFillKey | null = runId !== null ? 'runId' : slug !== null ? 'slug' : null;
141
+ const value = runId ?? slug;
142
+ if (key === null || value === null) {
143
+ outLines.push(raw);
144
+ rows.push({ index, runId, slug, key: null, filled: [], skipped: 'no-join-key' });
145
+ return;
146
+ }
147
+ if ((claimants.get(key + ':' + value) ?? 0) > 1) {
148
+ outLines.push(raw);
149
+ rows.push({ index, runId, slug, key: null, filled: [], skipped: 'shared-run-claim' });
150
+ return;
151
+ }
152
+ const facts = input.facts(key, value);
153
+ if (facts === AMBIGUOUS) {
154
+ // Refuse, do not guess. A feature run twice has two host runs under one slug; picking either
155
+ // would attribute one run's spend to the other row and call it measured.
156
+ outLines.push(raw);
157
+ rows.push({ index, runId, slug, key: null, filled: [], skipped: 'ambiguous-slug' });
158
+ return;
159
+ }
160
+ if (facts === null) {
161
+ outLines.push(raw);
162
+ rows.push({ index, runId, slug, key: null, filled: [], skipped: 'no-host-record' });
163
+ return;
164
+ }
165
+
166
+ const filled: string[] = [];
167
+ const next: Record<string, unknown> = { ...row };
168
+ for (const field of FILLABLE) {
169
+ const derived = facts[field];
170
+ if (!isNum(derived)) continue;
171
+ if (isNum(row[field])) {
172
+ // Rule 2: the operator's number stands. A disagreement is REPORTED so it can be looked at,
173
+ // never resolved silently in our favour.
174
+ if (row[field] !== derived) disagreements.push({ index, field, existing: row[field] as number, derived });
175
+ continue;
176
+ }
177
+ next[field] = derived;
178
+ filled.push(field);
179
+ }
180
+ if (filled.length === 0) {
181
+ outLines.push(raw);
182
+ rows.push({ index, runId, slug, key: null, filled: [], skipped: 'host-record-empty' });
183
+ return;
184
+ }
185
+ // Rule 1: say where these numbers came from, and HOW they were matched. A slug match is weaker
186
+ // evidence than a run id, and a reader who cannot tell them apart cannot weigh the number.
187
+ next['filledFrom'] = LEDGER_FILL_SOURCE;
188
+ next['filledBy'] = key;
189
+ next['filledFields'] = filled;
190
+ outLines.push(JSON.stringify(next));
191
+ rows.push({ index, runId, slug, key, filled, skipped: null });
192
+ filledRows++;
193
+ });
194
+
195
+ return { lines: outLines, rows, filledRows, disagreements };
196
+ }
197
+
198
+ /** A candidate host run, as {@link CostLedgerRunRef} exposes it. */
199
+ export interface LedgerRunCandidate {
200
+ readonly runId: string;
201
+ readonly slug: string | null;
202
+ readonly startedAtMs: number | null;
203
+ }
204
+
205
+ /**
206
+ * Resolve the run a ledger row is being written FOR, at write time.
207
+ *
208
+ * This is the fix for the root defect behind the whole backfill: the sandboxed workflow has no
209
+ * access to its own run id — it is not in `args` and not a sandbox global — so a row it writes can
210
+ * never name one, and 16 of 20 automatic rows carried no join key at all (MEASURED 2026-08-25).
211
+ *
212
+ * The command that APPENDS the row does run on the host, where the records live, so it can answer
213
+ * the question the sandbox cannot. Resolving here rather than afterwards is strictly better: at
214
+ * write time the run is IN FLIGHT and is simply the newest one for that slug, while an hour later
215
+ * the same slug may have several and the choice becomes a guess.
216
+ *
217
+ * Refuses rather than guesses in every unclear case:
218
+ * - no slug, or no candidate for it → `null`;
219
+ * - the newest candidate is TIED with another on `startedAtMs`, or has no timestamp at all → `null`,
220
+ * because "newest" is then not a fact;
221
+ * - a row that already names a run keeps it — resolution never overwrites.
222
+ */
223
+ export function resolveLedgerRunId(
224
+ row: { readonly runId?: unknown; readonly slug?: unknown },
225
+ runs: readonly LedgerRunCandidate[],
226
+ ): string | null {
227
+ if (typeof row.runId === 'string' && row.runId !== '') return null;
228
+ const slug = typeof row.slug === 'string' && row.slug !== '' ? row.slug : null;
229
+ if (slug === null) return null;
230
+ const dated = runs.filter((r) => r.slug === slug && typeof r.startedAtMs === 'number' && Number.isFinite(r.startedAtMs));
231
+ if (dated.length === 0) return null;
232
+ let newest = dated[0] as LedgerRunCandidate;
233
+ for (const r of dated) if ((r.startedAtMs as number) > (newest.startedAtMs as number)) newest = r;
234
+ // A tie means the newest is not a fact. Two runs cannot both be the one this row is for.
235
+ if (dated.filter((r) => r.startedAtMs === newest.startedAtMs).length > 1) return null;
236
+ return newest.runId;
237
+ }
package/src/operations.ts CHANGED
@@ -1166,6 +1166,42 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
1166
1166
  }
1167
1167
  } catch { /* advisory only — the vector tier must never fail doctor */ }
1168
1168
 
1169
+ // 10. AQE store integrity (observability item 3/5). `.agentic-qe/integrity-log.jsonl` is the
1170
+ // best-attested log in this repo — a UserPromptSubmit hook runs a REAL `PRAGMA quick_check`,
1171
+ // stats the file and scans /proc for the process holding it, so unlike almost every other store
1172
+ // here it is instrument-witnessed rather than self-reported. It had 1508 rows and ZERO readers
1173
+ // (MEASURED 2026-08-25): on that day a live corruption was noticed from a prompt banner, while
1174
+ // fourteen recorded corruption events and their on-disk `memory.db.corrupt-*` artifacts sat
1175
+ // unread. A log nobody reads is not observability. This turns it into a health signal.
1176
+ //
1177
+ // Deliberately NOT an error exit. The reading is a HISTORY, and a corruption already recovered
1178
+ // must not redden doctor forever; the LAST verdict decides ok, the history is reported beside it.
1179
+ try {
1180
+ const logPath = join(root, '.agentic-qe', 'integrity-log.jsonl');
1181
+ if (existsSync(logPath)) {
1182
+ const rows: { ts?: unknown; check?: unknown; error?: unknown }[] = [];
1183
+ for (const line of readFileSync(logPath, 'utf-8').split('\n')) {
1184
+ const t = line.trim();
1185
+ if (t === '') continue;
1186
+ try { rows.push(JSON.parse(t) as { ts?: unknown; check?: unknown; error?: unknown }); } catch { /* a torn line is not a verdict */ }
1187
+ }
1188
+ const last = rows.length > 0 ? rows[rows.length - 1] : undefined;
1189
+ if (last !== undefined) {
1190
+ const bad = rows.filter((r) => r.check !== 'ok').length;
1191
+ const lastOk = last.check === 'ok';
1192
+ const when = typeof last.ts === 'string' ? last.ts : 'unknown time';
1193
+ const err = typeof last.error === 'string' && last.error !== '' ? ` (${last.error})` : '';
1194
+ checks.push({
1195
+ name: 'aqe store integrity',
1196
+ ok: lastOk,
1197
+ detail: lastOk
1198
+ ? `last quick_check ok at ${when}; ${bad} corruption event(s) recorded in ${rows.length} checks`
1199
+ : `last quick_check FAILED at ${when}${err} — ${bad} of ${rows.length} checks failed; back up .agentic-qe/memory.db before any repair`,
1200
+ });
1201
+ }
1202
+ }
1203
+ } catch { /* advisory only — a diagnostic never throws */ }
1204
+
1169
1205
  return { node: process.version, checks, ok: checks.every((check) => check.ok) };
1170
1206
  }
1171
1207
 
package/src/score.ts CHANGED
@@ -41,6 +41,71 @@ export interface RunScorecard {
41
41
  }
42
42
 
43
43
  /** The artifact texts of one run, keyed by RELATIVE path under `features/<slug>/`. */
44
+ /**
45
+ * The exact heading Step 5 asks for, and the exact heading the check looks for — ONE constant, so
46
+ * the two cannot disagree by editing.
47
+ *
48
+ * This is not tidiness. A recalled lesson at 0.90 relevance records the same defect already shipped
49
+ * once here: "a generator prompt and its QE gate MUST agree on section vocabulary — Step-3's Write
50
+ * instruction listed legacy ADR sections while the injected brief listed the current ones." A prompt
51
+ * asking for one heading while a check greps another produces a gate that fails every honest run,
52
+ * and a gate that fails every honest run gets switched off.
53
+ *
54
+ * The sandboxed workflow cannot import, so it carries this string INLINE; a test asserts the inline
55
+ * literal equals this export, which turns "remember to update both" into a red test.
56
+ */
57
+ export const OBSERVABILITY_SECTION = 'Observability';
58
+
59
+ /**
60
+ * Does an architecture artifact answer how the shipped feature will be watched?
61
+ *
62
+ * THREE outcomes, and the third is why this is usable at all:
63
+ * - `answered` — the section is there.
64
+ * - `nothing-to-observe` — the section is there and says the feature emits nothing at runtime. A
65
+ * pure refactor or a CI-only gate genuinely does; a checker that cannot express a true fact is a
66
+ * checker people disable. The requirement is that the question is ANSWERED, not that it is yes.
67
+ * - `absent` — no section. WARN-shaped by design: 107 architecture files predate this
68
+ * requirement, and a blocking verdict on day one would redden every re-run of every past feature.
69
+ *
70
+ * Honest limit, stated in ADR-002: nothing here verifies that a `nothing-to-observe` claim is TRUE.
71
+ * The pipeline now asks. It does not ensure.
72
+ */
73
+ export function observabilityAnswer(architectureMarkdown: string | undefined | null): 'answered' | 'nothing-to-observe' | 'empty' | 'absent' {
74
+ // Fenced blocks are stripped FIRST: a `## Observability` inside an example fence is not a section
75
+ // of this document, and accepting one is a false pass anybody could write by accident
76
+ // (cross-family review, finding 1).
77
+ const text = String(architectureMarkdown ?? '').replace(/\r\n/g, '\n').replace(/^ {0,3}(```|~~~)[\s\S]*?^ {0,3}\1[^\n]*$/gm, '');
78
+ // CommonMark: up to three leading spaces, then #s, then REQUIRED whitespace. `##Observability` is
79
+ // not a heading and must not pass; an indented one is, and must not be missed (finding 2).
80
+ const headingRe = new RegExp('^ {0,3}#{1,6}[ \t]+' + OBSERVABILITY_SECTION + '\\b.*$', 'gim');
81
+ const lines = text.split('\n');
82
+ const heads: number[] = [];
83
+ lines.forEach((l, i) => { headingRe.lastIndex = 0; if (new RegExp('^ {0,3}#{1,6}[ \t]+' + OBSERVABILITY_SECTION + '\\b', 'i').test(l)) heads.push(i); });
84
+ if (heads.length === 0) return 'absent';
85
+
86
+ // EVERY matching section is read, not just the first: a decoy or empty section at the top used to
87
+ // mask a real answer below it, making the verdict order-dependent (finding 3).
88
+ const verdicts: ('answered' | 'nothing-to-observe' | 'empty')[] = [];
89
+ for (const h of heads) {
90
+ let stop = lines.length;
91
+ for (let i = h + 1; i < lines.length; i++) {
92
+ if (/^ {0,3}#{1,6}[ \t]+/.test(lines[i] as string)) { stop = i; break; }
93
+ }
94
+ const body = lines.slice(h + 1, stop);
95
+ const first = body.map((l) => l.trim()).find((l) => l !== '' && !/^[-*]\s*$/.test(l));
96
+ if (first === undefined) { verdicts.push('empty'); continue; }
97
+ // A claim OPENS the answer. Merely containing the phrase is a MENTION — "The phrase 'nothing to
98
+ // observe' is not acceptable" opens with "The phrase" and is an answer, not a claim (finding 4).
99
+ const opener = first.replace(/^[-*>\s]*/, '').replace(/^\*\*/, '').replace(/^["'`]/, '');
100
+ verdicts.push(/^(nothing to observe|no runtime surface|emits nothing at runtime)/i.test(opener) ? 'nothing-to-observe' : 'answered');
101
+ }
102
+ // Best-of, in that order: one real answer anywhere beats a decoy; an explicit nothing-to-observe
103
+ // beats an empty section; all-empty is reported AS empty and never as answered (finding 5).
104
+ if (verdicts.includes('answered')) return 'answered';
105
+ if (verdicts.includes('nothing-to-observe')) return 'nothing-to-observe';
106
+ return 'empty';
107
+ }
108
+
44
109
  export type RunArtifacts = Readonly<Record<string, string>>;
45
110
 
46
111
  function collect(artifacts: RunArtifacts, predicate: (path: string) => boolean): string {
@@ -186,6 +251,37 @@ export function scoreRun(slug: string, artifacts: RunArtifacts): RunScorecard {
186
251
  disciplines.push({ id, title, verdict, evidence });
187
252
  };
188
253
 
254
+ // 0. Observability — does the artifact say how anyone would know this feature works once it ships?
255
+ // DESCRIPTIVE by design (ADR-002): 107 architecture files predate the requirement, so a blocking
256
+ // verdict on day one would redden every re-run of every past feature. `dz score` describes process
257
+ // discipline and blocks nothing, which is exactly the shape this needs while the corpus catches up.
258
+ {
259
+ const archText = collect(artifacts, (p) => p === '05_architecture.md');
260
+ // An ABSENT artifact and an EMPTY one are different facts, and `collect` returns '' for both.
261
+ // Saying "no 05_architecture.md artifact" about a file that exists is a false evidence string —
262
+ // the exact class this discipline exists to police (cross-family review, finding 6).
263
+ const archPresent = Object.prototype.hasOwnProperty.call(artifacts, '05_architecture.md');
264
+ if (!archPresent) {
265
+ add('observability-declared', 'architecture says how it will be watched', 'absent', 'no 05_architecture.md artifact');
266
+ } else if (archText.trim() === '') {
267
+ add('observability-declared', 'architecture says how it will be watched', 'absent', '05_architecture.md exists but is empty');
268
+ } else {
269
+ const answer = observabilityAnswer(archText);
270
+ if (answer === 'answered') {
271
+ add('observability-declared', 'architecture says how it will be watched', 'pass', 'the ' + OBSERVABILITY_SECTION + ' section answers how the shipped feature is watched');
272
+ } else if (answer === 'nothing-to-observe') {
273
+ // A complete answer, not a gap — and NOT verified. ADR-002 says so out loud.
274
+ add('observability-declared', 'architecture says how it will be watched', 'pass', 'declares nothing to observe at runtime (a complete answer; nothing here checks that it is true)');
275
+ } else if (answer === 'empty') {
276
+ // The heading is there and says nothing. `pass` here would be a verdict its own evidence
277
+ // string contradicts — the section cannot "answer how" while containing no answer.
278
+ add('observability-declared', 'architecture says how it will be watched', 'partial', 'the ' + OBSERVABILITY_SECTION + ' section is present but EMPTY — a heading is not an answer');
279
+ } else {
280
+ add('observability-declared', 'architecture says how it will be watched', 'absent', 'no ' + OBSERVABILITY_SECTION + ' section — the artifact does not say how anyone would know this works');
281
+ }
282
+ }
283
+ }
284
+
189
285
  // 1. ADR with a Confirmation — a named decision whose load-bearing property names its test.
190
286
  if (adrText === '') {
191
287
  add('adr-confirmation', 'ADR present, property → named test', 'absent', 'no 03_adr/*.md artifact');
@@ -0,0 +1,138 @@
1
+ /**
2
+ * One name per measured quantity — copied, not imported.
3
+ *
4
+ * THE PROBLEM (MEASURED 2026-08-25). Fifteen telemetry stores in this repo, each naming the same
5
+ * thing differently: `durationMs` here, `minutes` there, `wallMs` in a third; `tokens` vs
6
+ * `totalTokens` vs `tokensIn`/`tokensOut`. Joining two of them was interpretation, not lookup, which
7
+ * is why cost-per-feature was 76% typed by a human.
8
+ *
9
+ * WHY LITERALS AND NOT A DEPENDENCY. OpenTelemetry's `gen_ai.*` conventions are the right vocabulary
10
+ * and there is no package to depend on:
11
+ * • `@opentelemetry/semantic-conventions-genai` → 404 on npm (verified 2026-08-25);
12
+ * • in semconv v1.42.0 every `gen_ai.*` convention was DEPRECATED out of the main repo and moved
13
+ * to a new one with zero tags and zero releases;
14
+ * • 197 of 197 documents in that spec carry `stability: development`, which its own status page
15
+ * defines as "SHOULD NOT be used in production";
16
+ * • 40 fragments are queued, 6 of them breaking — and FOUR of those six are agent-layer.
17
+ * So: copy the names, version the copy, and mark the volatile half. A rename upstream then becomes a
18
+ * data migration against ONE file instead of a code change across call sites.
19
+ *
20
+ * WHAT THIS DOES NOT DO. It renames nothing. Migrating the existing stores onto these names is a
21
+ * separate change with its own risk; this module only makes the join possible.
22
+ *
23
+ * NOT in the loop-blob registry, deliberately: a module mirrored into the sandboxed workflow drags a
24
+ * registry regeneration and a region re-render behind every edit to it.
25
+ *
26
+ * @packageDocumentation
27
+ */
28
+
29
+ /**
30
+ * The contract's own version. Bump it when a name changes, and the change is then a migration
31
+ * against a versioned file — which is the whole point of copying rather than depending.
32
+ */
33
+ export const TELEMETRY_VOCAB_VERSION = 'dz-telemetry-vocab-1';
34
+
35
+ /**
36
+ * Where a name came from. This distinction is load-bearing and must not be flattened: `otel` means
37
+ * copied verbatim from the upstream convention, so a future reader can look it up and a future tool
38
+ * can consume it. `local` means OpenTelemetry carries the quantity as span STRUCTURE rather than as
39
+ * an attribute, so there was nothing to copy and we named it ourselves. Presenting a name we invented
40
+ * as a standard one would be the small lie that makes the whole contract untrustworthy.
41
+ */
42
+ export type FieldSource = 'otel' | 'local';
43
+
44
+ export interface TelemetryField {
45
+ /** The wire name to write into a record. */
46
+ readonly field: string;
47
+ readonly source: FieldSource;
48
+ /** What it holds, in one line. */
49
+ readonly means: string;
50
+ /**
51
+ * The UNIT the field is measured in, or `null` for a field that has none (an id, a label).
52
+ *
53
+ * Added after cross-family review found the hazard it closes: `minutes` was aliased onto a field
54
+ * whose name ends `_ms`, so a consumer following the alias would write minutes into a
55
+ * milliseconds field and nothing would say otherwise. A vocabulary that names a quantity without
56
+ * naming its unit invites exactly that.
57
+ */
58
+ readonly unit: 'ms' | 'tokens' | null;
59
+ }
60
+
61
+ /**
62
+ * The vocabulary. Keys are stable identifiers for OUR code to reference; `field` is what goes on
63
+ * disk, so an upstream rename touches the value and never the call sites.
64
+ */
65
+ export const TELEMETRY_FIELDS: Readonly<Record<string, TelemetryField>> = {
66
+ requestModel: { field: 'gen_ai.request.model', source: 'otel', means: 'the model id a stage asked for', unit: null },
67
+ // The upstream field means the SERVICE PROVIDER — anthropic, openai — not the product family.
68
+ // Cross-family review caught this being described as "claude, codex", which are our model
69
+ // families; those now have their own local field below rather than being smuggled in here.
70
+ providerName: { field: 'gen_ai.provider.name', source: 'otel', means: 'the inference provider — anthropic, openai', unit: null },
71
+ operationName: { field: 'gen_ai.operation.name', source: 'otel', means: 'what the call was: invoke_agent, invoke_workflow, plan, execute_tool', unit: null },
72
+ inputTokens: { field: 'gen_ai.usage.input_tokens', source: 'otel', means: 'tokens SENT — not a total', unit: 'tokens' },
73
+ outputTokens: { field: 'gen_ai.usage.output_tokens', source: 'otel', means: 'tokens PRODUCED — not a total', unit: 'tokens' },
74
+ evaluationLabel: { field: 'gen_ai.evaluation.score.label', source: 'otel', means: 'a graded verdict — our QE letter grade', unit: null },
75
+ // OpenTelemetry carries elapsed time as span structure, not as an attribute, so there is no
76
+ // upstream name to copy for a JSONL row. Ours, and said so.
77
+ durationMs: { field: 'dz.duration_ms', source: 'local', means: 'elapsed wall time of a stage or run', unit: 'ms' },
78
+ // Upstream splits usage into input and output and has no TOTAL. Ours are totals, and folding them
79
+ // into input_tokens would silently change the measured quantity (cross-family review).
80
+ totalTokens: { field: 'dz.total_tokens', source: 'local', means: 'input + output for a run or stage', unit: 'tokens' },
81
+ // Our routing families (claude, codex) are not providers; see providerName above.
82
+ modelFamily: { field: 'dz.model_family', source: 'local', means: 'the routing family — claude, codex', unit: null },
83
+ runId: { field: 'dz.run_id', source: 'local', means: 'the host workflow run a record belongs to', unit: null },
84
+ stage: { field: 'dz.stage', source: 'local', means: 'the feature-adr stage a record is about', unit: null },
85
+ };
86
+
87
+ /**
88
+ * The names whose upstream churn is highest — the agent layer, where FOUR of the six queued breaking
89
+ * changes land (`gen_ai.agent.version` removed from internal invoke_agent spans,
90
+ * `gen_ai.provider.name` dropped as required, `gen_ai.agent.id` scope changed).
91
+ *
92
+ * Separated by NAME rather than by footnote so a reader can tell "safe to build on" from "expect this
93
+ * to move" without reading a comment. Build on these only where a rename is cheap.
94
+ */
95
+ export const PROVISIONAL_TELEMETRY_FIELDS: Readonly<Record<string, TelemetryField>> = {
96
+ agentId: { field: 'gen_ai.agent.id', source: 'otel', means: 'PROVISIONAL — the agent instance', unit: null },
97
+ agentName: { field: 'gen_ai.agent.name', source: 'otel', means: 'PROVISIONAL — the agent role/label', unit: null },
98
+ agentVersion: { field: 'gen_ai.agent.version', source: 'otel', means: 'PROVISIONAL — slated for removal from internal spans', unit: null },
99
+ };
100
+
101
+ /**
102
+ * What this repo already writes → which vocabulary key it means.
103
+ *
104
+ * Without this the contract is a glossary nobody can act on. With it, the cost join that was hand
105
+ * work becomes a lookup. Wrong BY OMISSION for any store added without updating it — which is why
106
+ * {@link telemetryFieldFor} returns nothing rather than guessing.
107
+ */
108
+ export const LOCAL_FIELD_ALIASES: Readonly<Record<string, string>> = {
109
+ durationMs: 'durationMs',
110
+ wallMs: 'durationMs',
111
+ // `minutes` is DELIBERATELY absent. It names the same quantity in a different unit, and an alias
112
+ // carries no conversion — following it would write minutes into a field whose name ends `_ms`.
113
+ // An unmapped name is visible at the call site; a wrong unit is not (cross-family review).
114
+ tokens: 'totalTokens',
115
+ totalTokens: 'totalTokens',
116
+ recordTotalTokens: 'totalTokens',
117
+ tokensIn: 'inputTokens',
118
+ tokensOut: 'outputTokens',
119
+ model: 'requestModel',
120
+ coder: 'modelFamily',
121
+ family: 'modelFamily',
122
+ grade: 'evaluationLabel',
123
+ runId: 'runId',
124
+ stage: 'stage',
125
+ };
126
+
127
+ /**
128
+ * The vocabulary field a local name means, or `undefined` when we do not know.
129
+ *
130
+ * The unknown case is the load-bearing one. A vocabulary that invents a mapping for a field it has
131
+ * never seen produces a join that is silently wrong; one that returns nothing makes the gap visible
132
+ * at the call site, where somebody can fix it.
133
+ */
134
+ export function telemetryFieldFor(localName: string): TelemetryField | undefined {
135
+ const key = LOCAL_FIELD_ALIASES[localName];
136
+ if (key === undefined) return undefined;
137
+ return TELEMETRY_FIELDS[key] ?? PROVISIONAL_TELEMETRY_FIELDS[key];
138
+ }