@dzhechkov/harness-core 0.8.47 → 0.8.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/.dz-manifest.json +57 -37
  2. package/README.md +69 -4
  3. package/dist/publish.d.ts +1 -1
  4. package/dist/publish.d.ts.map +1 -1
  5. package/dist/publish.js +2 -2
  6. package/dist/release-line.d.ts +1 -6
  7. package/dist/release-line.d.ts.map +1 -1
  8. package/dist/release-line.js +11 -13
  9. package/dist/release-line.js.map +1 -1
  10. package/dist/setup-memory-deps.d.ts +14 -0
  11. package/dist/setup-memory-deps.d.ts.map +1 -0
  12. package/dist/setup-memory-deps.js +210 -0
  13. package/dist/setup-memory-deps.js.map +1 -0
  14. package/dist/setup.d.ts +2 -0
  15. package/dist/setup.d.ts.map +1 -1
  16. package/dist/setup.js +379 -378
  17. package/dist/setup.js.map +1 -1
  18. package/dist/stage-usage.d.ts +102 -2
  19. package/dist/stage-usage.d.ts.map +1 -1
  20. package/dist/stage-usage.js +252 -2
  21. package/dist/stage-usage.js.map +1 -1
  22. package/dist/test-receipt.d.ts +4 -2
  23. package/dist/test-receipt.d.ts.map +1 -1
  24. package/dist/test-receipt.js +97 -3
  25. package/dist/test-receipt.js.map +1 -1
  26. package/dist/workflow-run-dispatch.d.ts +15 -0
  27. package/dist/workflow-run-dispatch.d.ts.map +1 -1
  28. package/dist/workflow-run-dispatch.js +50 -8
  29. package/dist/workflow-run-dispatch.js.map +1 -1
  30. package/dist/workflow-run.d.ts +8 -1
  31. package/dist/workflow-run.d.ts.map +1 -1
  32. package/dist/workflow-run.js +64 -0
  33. package/dist/workflow-run.js.map +1 -1
  34. package/package.json +1 -1
  35. package/sbom.json +86 -36
  36. package/src/publish.ts +3 -3
  37. package/src/release-line.ts +11 -13
  38. package/src/setup-memory-deps.ts +166 -0
  39. package/src/setup.ts +95 -98
  40. package/src/stage-usage.ts +196 -2
  41. package/src/test-receipt.ts +90 -4
  42. package/src/workflow-run-dispatch.ts +50 -5
  43. package/src/workflow-run.ts +61 -1
package/src/setup.ts CHANGED
@@ -16,9 +16,12 @@
16
16
  * @packageDocumentation
17
17
  */
18
18
 
19
- import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
19
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync, renameSync } from 'node:fs';
20
20
  import { basename, dirname, isAbsolute, join, relative } from 'node:path';
21
- import { execSync, spawnSync } from 'node:child_process';
21
+ import { spawnSync } from 'node:child_process';
22
+
23
+ import { randomUUID } from 'node:crypto';
24
+ import { reconcileMemoryDependencies } from './setup-memory-deps.js';
22
25
 
23
26
  import { mergeManagedHookEntries } from './managed-hooks.js';
24
27
  import { writeUniqueStampedFile } from './stamped-path.js';
@@ -146,6 +149,8 @@ export interface SetupResult {
146
149
  readonly skipped: number;
147
150
  /** The memory backend this run actually used (feature `setup-backend-from-config`, FR-1). */
148
151
  readonly memoryBackend: MemoryBackend;
152
+ /** Observed persisted state; unknown is explicit on failed/uninitialized setup. */
153
+ readonly memoryBackendObserved?: MemoryBackend | 'unknown';
149
154
  /** Where {@link memoryBackend} came from — FR-3, also the `--json` field name. */
150
155
  readonly memoryBackendSource: MemoryBackendSource;
151
156
  /** `true` when an explicit `--memory jsonl` pulled an agentdb-configured project down (FR-2). */
@@ -547,11 +552,6 @@ function generateDzConfig(target: string, preset: string | undefined, backend: M
547
552
  }, null, 2);
548
553
  }
549
554
 
550
- /** True if `agentdb` resolves from the project's node_modules (the hook writer needs it there). */
551
- function isAgentdbInstalledLocally(projectRoot: string): boolean {
552
- return existsSync(join(projectRoot, 'node_modules', 'agentdb', 'package.json'));
553
- }
554
-
555
555
  /**
556
556
  * The exact agentdb version installed in the project, or `'latest'` as a fallback. Used to pin the
557
557
  * MCP server spec (`agentdb@<version>`) so the long-running MCP server and the hook writer — which
@@ -573,38 +573,6 @@ function installedAgentdbSpec(projectRoot: string): string {
573
573
  * build tools — and gives true cross-process WAL concurrency so the hook and the MCP server share
574
574
  * one live store). Best-effort: returns false (caller degrades to jsonl) if install fails.
575
575
  */
576
- function installAgentdbLocally(projectRoot: string): boolean {
577
- if (isAgentdbInstalledLocally(projectRoot)) return true;
578
- try {
579
- // Anchor npm to THIS project: without a package.json here, npm's prefix walk-up would
580
- // install into (and mutate the lockfile of) the nearest ANCESTOR project (audit code#2).
581
- const pkgJsonPath = join(projectRoot, 'package.json');
582
- if (!existsSync(pkgJsonPath)) {
583
- writeFileSync(pkgJsonPath, JSON.stringify({ name: 'dz-harness-project', private: true, version: '0.0.0' }, null, 2) + '\n');
584
- }
585
- // NB: use the ESM-imported execSync — `require()` is undefined in this ESM module (the
586
- // original agentdb hooks failed silently for exactly this reason). stdio:'ignore' (not
587
- // 'pipe') avoids execSync's 1 MB maxBuffer aborting the child on npm's verbose output.
588
- // --save-exact: agentdb is alpha; a semver range would let a later `npm update` drift the
589
- // local copy away from the version the MCP registration pins (audit gap G7).
590
- //
591
- // better-sqlite3@^11 (AM-2, dz-harness-hub issue #10 defect 1, MEASURED Node 20.20.2 with no
592
- // `make` on PATH): an unpinned `npm install better-sqlite3` resolved 12.11.1, which ships no
593
- // prebuilt binary for Node 20's ABI 115 — the install fell through to a node-gyp source build
594
- // and failed on a machine with no C toolchain. `agentdb` itself requests `^11.8.1`, which DOES
595
- // publish an ABI-115 prebuild, so pinning the range here costs nothing agentdb wasn't already
596
- // going to resolve to, and buys a working install on a bare Node 20/22 host.
597
- execSync('npm install agentdb better-sqlite3@^11 --save-exact --no-audit --no-fund --loglevel=error', {
598
- cwd: projectRoot,
599
- stdio: 'ignore',
600
- timeout: 300000,
601
- });
602
- return isAgentdbInstalledLocally(projectRoot);
603
- } catch {
604
- return false;
605
- }
606
- }
607
-
608
576
  /** Run full environment setup. */
609
577
  /** Marker that brackets the dz-harness section in a shared CLAUDE.md/AGENTS.md. */
610
578
  const DRIVER_MARKER_START = '<!-- dz-harness-driver:start -->';
@@ -957,19 +925,39 @@ export function runSetup(opts: SetupOptions): SetupResult {
957
925
  const resolvedMemory = resolveSetupMemoryBackend(opts.projectRoot, opts.memory, opts.noMemory === true);
958
926
  const backend: MemoryBackend = resolvedMemory.backend;
959
927
 
960
- // Step 0: Install agentdb + better-sqlite3 locally so the session-hook writer can import them
961
- // and share a native store with the MCP server. Best-effort — the writer self-degrades to a
962
- // jsonl marker (and self-heals once the deps exist) if this fails.
963
- if (backend === 'agentdb') {
964
- const ready = installAgentdbLocally(opts.projectRoot);
965
- if (ready) {
966
- steps.push({ name: 'Install agentdb + better-sqlite3', status: 'done', detail: 'local deps for real vector writes' });
967
- } else {
968
- steps.push({
969
- name: 'Install agentdb + better-sqlite3',
970
- status: 'error',
971
- detail: 'install failed — hooks log to sessions.jsonl until you run: npm i agentdb better-sqlite3',
972
- });
928
+ const configPath = join(dzDir, 'config.json');
929
+ let config: Record<string, unknown> | undefined;
930
+ const observedBackend = (): MemoryBackend | 'unknown' => {
931
+ try {
932
+ const value = JSON.parse(readFileSync(configPath, 'utf8')) as { memory?: { backend?: unknown } };
933
+ return value?.memory?.backend === 'agentdb' || value?.memory?.backend === 'jsonl' ? value.memory.backend : 'unknown';
934
+ } catch { return 'unknown'; }
935
+ };
936
+ const finish = (): SetupResult => ({
937
+ steps, totalSteps: steps.length, completed: steps.filter(step => step.status === 'done').length,
938
+ skipped: steps.filter(step => step.status === 'skipped').length,
939
+ memoryBackend: observedBackend() === 'unknown' ? resolvedMemory.backend : observedBackend() as MemoryBackend,
940
+ memoryBackendObserved: observedBackend(), memoryBackendSource: resolvedMemory.source,
941
+ memoryBackendDowngraded: resolvedMemory.downgraded,
942
+ });
943
+ try {
944
+ if (existsSync(configPath)) {
945
+ const value: unknown = JSON.parse(readFileSync(configPath, 'utf8'));
946
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) throw Error('config must be an object');
947
+ config = value as Record<string, unknown>;
948
+ const memory = config['memory'];
949
+ if (memory === null || typeof memory !== 'object' || Array.isArray(memory) || !['jsonl', 'agentdb'].includes(String((memory as Record<string, unknown>)['backend']))) throw Error('memory.backend must be jsonl or agentdb');
950
+ }
951
+ } catch (error) {
952
+ steps.push({ name: 'Memory saved state', status: 'error', detail: `unknown INCOMPLETE: malformed .dz/config.json preserved; ${String(error)}` });
953
+ return finish();
954
+ }
955
+ if (backend === 'agentdb' && !opts.noMemory) {
956
+ const deps = reconcileMemoryDependencies(opts.projectRoot);
957
+ steps.push({ name: 'Memory dependencies', status: deps.ready ? (deps.changedManifest || deps.changedLock || deps.detail.startsWith('repaired') ? 'done' : 'skipped') : 'error', detail: deps.detail });
958
+ if (!deps.ready) {
959
+ steps.push({ name: 'Memory backend transition', status: 'error', detail: `saved backend ${observedBackend()} INCOMPLETE before config persistence; dependency/npm/native failure; later memory phases not run` });
960
+ return finish();
973
961
  }
974
962
  }
975
963
 
@@ -981,38 +969,32 @@ export function runSetup(opts: SetupOptions): SetupResult {
981
969
  steps.push({ name: 'Create .dz directory', status: 'skipped', detail: 'already exists' });
982
970
  }
983
971
 
984
- // Step 2: Write .dz/config.json. FR-2: a DOWNGRADE (explicit --memory jsonl over an
985
- // agentdb-configured project) forces the write even without --force — "two truths after any
986
- // setup coincide" means the config may not keep claiming agentdb once the caller has explicitly
987
- // asked for jsonl.
988
- const configPath = join(dzDir, 'config.json');
989
- if (!existsSync(configPath) || opts.force) {
990
- writeFileSync(configPath, generateDzConfig(opts.target, opts.preset, backend));
991
- steps.push({ name: 'Write .dz/config.json', status: 'done', detail: `${backend} backend` });
992
- } else if (resolvedMemory.downgraded) {
993
- // Lead edit after Codex review (finding 3): a downgrade changes ONLY memory.backend — every other
994
- // field the owner keeps in .dz/config.json survives; an unparsable file falls back to regeneration.
995
- let rewritten = false;
996
- try {
997
- const cfg = JSON.parse(readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
998
- const memory = (cfg['memory'] !== null && typeof cfg['memory'] === 'object') ? (cfg['memory'] as Record<string, unknown>) : {};
999
- cfg['memory'] = { ...memory, backend };
1000
- writeFileSync(configPath, JSON.stringify(cfg, null, 2) + '\n');
1001
- rewritten = true;
1002
- } catch { /* fall through to regeneration */ }
1003
- if (!rewritten) writeFileSync(configPath, generateDzConfig(opts.target, opts.preset, backend));
1004
- steps.push({ name: 'Write .dz/config.json', status: 'done', detail: `memory.backend → ${backend} (other fields kept)` });
972
+ // Persist only the requested backend field of readable existing config, atomically.
973
+ const priorBackend = observedBackend();
974
+ if (opts.noMemory) {
975
+ steps.push({ name: 'Write .dz/config.json', status: 'skipped', detail: '--no-memory: existing memory configuration preserved' });
1005
976
  } else {
1006
- steps.push({ name: 'Write .dz/config.json', status: 'skipped', detail: 'already exists (use --force)' });
1007
- }
1008
- if (resolvedMemory.downgraded) {
1009
- steps.push({
1010
- name: 'Memory backend downgrade',
1011
- status: 'done',
1012
- detail: '⚠ memory backend downgraded agentdb → jsonl by --memory jsonl',
1013
- });
977
+ const transition = config !== undefined && opts.memory !== undefined && priorBackend !== backend;
978
+ if (config === undefined || opts.force || transition) {
979
+ const next = config !== undefined
980
+ ? { ...config, memory: { ...(config['memory'] as Record<string, unknown>), backend } }
981
+ : JSON.parse(generateDzConfig(opts.target, opts.preset, backend));
982
+ const temporary = `${configPath}.${randomUUID()}.tmp`;
983
+ try {
984
+ writeFileSync(temporary, JSON.stringify(next, null, 2) + '\n', { flag: 'wx' });
985
+ renameSync(temporary, configPath);
986
+ steps.push({ name: 'Write .dz/config.json', status: 'done', detail: transition ? `memory.backend ${priorBackend} → ${backend}; all other config fields kept` : `${backend} backend` });
987
+ } catch (error) {
988
+ if (existsSync(temporary)) rmSync(temporary);
989
+ steps.push({ name: 'Memory backend transition', status: 'error', detail: `saved backend ${observedBackend()} INCOMPLETE: config persistence failed; later memory wiring not run; ${String(error)}` });
990
+ return finish();
991
+ }
992
+ } else steps.push({ name: 'Write .dz/config.json', status: 'skipped', detail: 'already current; existing config preserved' });
993
+ if (resolvedMemory.downgraded) steps.push({ name: 'Memory backend downgrade', status: 'done', detail: '⚠ memory backend downgraded agentdb → jsonl by --memory jsonl; other config fields kept' });
1014
994
  }
1015
995
 
996
+ try {
997
+ if (!opts.noMemory) {
1016
998
  // Step 3: Initialize session log
1017
999
  const sessionsPath = join(dzDir, 'sessions.jsonl');
1018
1000
  if (!existsSync(sessionsPath)) {
@@ -1064,18 +1046,20 @@ export function runSetup(opts: SetupOptions): SetupResult {
1064
1046
  }
1065
1047
  }
1066
1048
 
1049
+ } // --no-memory performs no memory store initialization.
1050
+
1067
1051
  // Step 4.6: Install apply-leg — the WORK happens here (before "Configure hooks" writes
1068
1052
  // SessionStart), so a foreign SessionStart entry is already in place before that step's own
1069
1053
  // merge ever sees it; see `applyLegStepResult`'s doc for why order matters. The STEP is reported
1070
1054
  // further down, after "Configure hooks" pushes its own, so the printed order still reads as
1071
1055
  // "collect → rank → apply".
1072
- const applyLegStep = applyLegStepResult(opts, backend);
1056
+ const applyLegStep: SetupStep = opts.noMemory ? { name: 'Install apply-leg', status: 'skipped', detail: '--no-memory' } : applyLegStepResult(opts, backend);
1073
1057
 
1074
1058
  // Step 5: Configure hooks (write to .claude/settings.json) — EVENT-LEVEL merge (gap G2):
1075
1059
  // dz-generated entries (recognized by signature, incl. the broken legacy `agentdb add` hooks
1076
1060
  // this feature fixes) are replaced in place WITHOUT --force; the user's own hooks and every
1077
1061
  // other settings key are preserved. Full-file overwrite happens only when the file is absent.
1078
- if (!opts.noHooks) {
1062
+ if (!opts.noHooks && !opts.noMemory) {
1079
1063
  const settingsDir = join(opts.projectRoot, '.claude');
1080
1064
  const settingsPath = join(settingsDir, 'settings.json');
1081
1065
 
@@ -1253,7 +1237,7 @@ export function runSetup(opts: SetupOptions): SetupResult {
1253
1237
  }
1254
1238
  }
1255
1239
  } else {
1256
- steps.push({ name: 'Configure hooks', status: 'skipped', detail: '--no-hooks' });
1240
+ steps.push({ name: 'Configure hooks', status: 'skipped', detail: opts.noMemory ? '--no-memory: existing hooks and guards preserved; no delivery' : '--no-hooks' });
1257
1241
  }
1258
1242
 
1259
1243
  // Step 5.6: Install apply-leg — report pushed AFTER "Configure hooks" below (for a report order
@@ -1264,7 +1248,7 @@ export function runSetup(opts: SetupOptions): SetupResult {
1264
1248
  // Step 5.5: Register agentdb MCP through the SAME ownership-aware transaction used by `dz init`.
1265
1249
  // `.mcp.json` is the project-scope carrier Claude Code actually loads. A known historical dz
1266
1250
  // agentdb shape is adopted; an ambiguous hand-authored entry is preserved and named as an error.
1267
- if (backend === 'agentdb') {
1251
+ if (backend === 'agentdb' && !opts.noHooks && !opts.noMemory) {
1268
1252
  const agentdbEntry = {
1269
1253
  command: 'npx',
1270
1254
  // Pin to the INSTALLED agentdb version (not @latest) so the MCP server and the hook
@@ -1322,12 +1306,28 @@ export function runSetup(opts: SetupOptions): SetupResult {
1322
1306
  }
1323
1307
  }
1324
1308
 
1309
+ } catch (error) {
1310
+ steps.push({ name: 'Memory backend transition', status: 'error', detail: `saved backend ${observedBackend()} INCOMPLETE: managed wiring failed after persistence; ${String(error)}` });
1311
+ return finish();
1312
+ }
1313
+
1314
+ // Saved/local/native agreement is independent of hook installation.
1315
+ if (!opts.noMemory) {
1316
+ const actual = observedBackend();
1317
+ const problems: string[] = actual === backend ? [] : [`saved backend ${actual} differs from requested ${backend}`];
1318
+ if (backend === 'agentdb') {
1319
+ const deps = reconcileMemoryDependencies(opts.projectRoot, false);
1320
+ if (!deps.ready) problems.push(deps.detail);
1321
+ }
1322
+ steps.push({ name: 'Memory saved state', status: problems.length ? 'error' : 'done', detail: problems.length ? `saved backend ${actual} INCOMPLETE: ${problems.join('; ')}` : `saved backend ${actual}; dependencies/native state consistent` });
1323
+ }
1324
+
1325
1325
  // Step 5.9: agentdb wiring invariant check (audit code#3). Skip-branches across repeated runs
1326
1326
  // can leave inconsistent combinations (e.g. writer+MCP present but hooks still jsonl). Verify
1327
1327
  // the three-way invariant explicitly and surface a loud error step instead of silent "skipped"s.
1328
- if (backend === 'agentdb' && !opts.noHooks) {
1328
+ if (backend === 'agentdb' && !opts.noHooks && !opts.noMemory) {
1329
1329
  const problems: string[] = [];
1330
- if (!isAgentdbInstalledLocally(opts.projectRoot)) problems.push('deps missing (npm i agentdb better-sqlite3)');
1330
+ if (observedBackend() !== backend) problems.push(`saved backend ${observedBackend()} differs from ${backend}`);
1331
1331
  try {
1332
1332
  const settings = JSON.parse(readFileSync(join(opts.projectRoot, '.claude', 'settings.json'), 'utf-8')) as {
1333
1333
  hooks?: Record<string, unknown[]>;
@@ -1357,7 +1357,7 @@ export function runSetup(opts: SetupOptions): SetupResult {
1357
1357
  // sentinel check (e.g. sessions.jsonl, present in both backends) would skip agentdb.db/-wal/-shm
1358
1358
  // on the documented jsonl→agentdb `--force` switch, leaking the binary store into git.
1359
1359
  const gitignorePath = join(opts.projectRoot, '.gitignore');
1360
- const dzIgnoreLines = backend === 'agentdb'
1360
+ const dzIgnoreLines = opts.noMemory ? [] : backend === 'agentdb'
1361
1361
  ? ['.dz/agentdb.db', '.dz/agentdb.db-wal', '.dz/agentdb.db-shm',
1362
1362
  '.dz/agentdb-mcp.db', '.dz/agentdb-mcp.db-wal', '.dz/agentdb-mcp.db-shm',
1363
1363
  '.dz/sessions.jsonl']
@@ -1374,7 +1374,7 @@ export function runSetup(opts: SetupOptions): SetupResult {
1374
1374
  detail: `added ${missing.join(', ')}`,
1375
1375
  });
1376
1376
  } else {
1377
- steps.push({ name: 'Update .gitignore', status: 'skipped', detail: 'already ignoring .dz data' });
1377
+ steps.push({ name: 'Update .gitignore', status: 'skipped', detail: opts.noMemory ? '--no-memory: existing ignore rules preserved' : 'already ignoring .dz data' });
1378
1378
  }
1379
1379
 
1380
1380
  // Step 7: Install the CLI-driver skill + agent docs (--install-driver)
@@ -1383,13 +1383,10 @@ export function runSetup(opts: SetupOptions): SetupResult {
1383
1383
  steps.push({ name: 'Install driver skill', status: 'done', detail });
1384
1384
  }
1385
1385
 
1386
- return {
1387
- steps,
1388
- totalSteps: steps.length,
1389
- completed: steps.filter((s) => s.status === 'done').length,
1390
- skipped: steps.filter((s) => s.status === 'skipped').length,
1391
- memoryBackend: resolvedMemory.backend,
1392
- memoryBackendSource: resolvedMemory.source,
1393
- memoryBackendDowngraded: resolvedMemory.downgraded,
1394
- };
1386
+ const failed = steps.filter(step => step.status === 'error');
1387
+ if (!opts.noMemory && backend === 'agentdb') steps.push({
1388
+ name: 'Memory backend transition', status: failed.length ? 'error' : 'done',
1389
+ detail: failed.length ? `saved backend ${observedBackend()} INCOMPLETE; failed phase: ${failed.map(step => step.name).join(', ')}` : `saved backend ${observedBackend()} ready; ${priorBackend === backend ? 'already current' : `${priorBackend} → ${backend} transition complete`}`,
1390
+ });
1391
+ return finish();
1395
1392
  }
@@ -54,6 +54,199 @@ function price(row: RecordRow, dimensions: Record<typeof tokenFields[number], nu
54
54
  capturedAt: text(prices?.['snapshotAt']), fingerprint: fnv1a64(JSON.stringify(prices ?? MODEL_PRICES)), current: false, billed: false } };
55
55
  }
56
56
 
57
+ type RoutingInput = Parameters<typeof buildStageUsageReport>[0];
58
+ type RoutingAttempt = { ordinal: number; model: string | null; family: 'openai' | 'claude'; wrapperInvoked: boolean;
59
+ outcome: 'answered' | 'failed' | 'rejected'; reason: string; selected: boolean };
60
+ type RoutingStage = { evidenceKey: string; dispatchSeq: number; plannedModel: string | null;
61
+ plannedModelSource: 'plan-declared' | 'plan-omitted' | 'unavailable' | 'not-recorded'; requestedModel: string | null;
62
+ probeId: string | null; linkStatus: string };
63
+ type RoutingProbe = { probeId: string; runId: string; family: 'openai' | 'claude'; source: string;
64
+ selectedModel: string | null; complete: boolean; totalConsidered: number; attempts: RoutingAttempt[] };
65
+ const safeRoutingModel = (value: unknown): value is string => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(value);
66
+ const routingId = (value: unknown): value is string => typeof value === 'string' && /^[0-9a-f]{32}$/.test(value);
67
+ const routingRun = (value: unknown): value is string => typeof value === 'string' && /^[A-Za-z0-9_.-]{1,128}$/.test(value) && value !== '.' && value !== '..';
68
+ const routingFamily = (value: unknown): value is 'openai' | 'claude' => value === 'openai' || value === 'claude';
69
+ const exactRoutingKeys = (value: unknown, keys: readonly string[]): value is RecordRow => record(value)
70
+ && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)
71
+ && Reflect.ownKeys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
72
+ const routingAbsenceReasons = ['producer-not-recorded', 'id-factory-missing'];
73
+ const routingErrorReasons = ['id-factory-invalid', 'provenance-invalid', 'candidate-model-invalid', 'wrapper-result-invalid', 'selected-model-invalid'];
74
+
75
+ function validateRoutingAttempts(value: unknown, family: unknown, selectedModel: unknown): { complete: boolean; totalConsidered: number; attempts: RoutingAttempt[] } | null {
76
+ if (!exactRoutingKeys(value, ['schema', 'complete', 'totalConsidered', 'attempts']) || value['schema'] !== 'wf-probe-attempts-1'
77
+ || typeof value['complete'] !== 'boolean' || !routingFamily(family) || (selectedModel !== null && !safeRoutingModel(selectedModel))) return null;
78
+ const total = count(value['totalConsidered']);
79
+ const attempts = value['attempts'];
80
+ if (total === null || total === 0 || !Array.isArray(attempts) || attempts.length !== Math.min(total, 32) || value['complete'] !== (total <= 32)) return null;
81
+ const safe: RoutingAttempt[] = [];
82
+ for (const [index, a] of attempts.entries()) {
83
+ if (!exactRoutingKeys(a, ['ordinal', 'model', 'family', 'wrapperInvoked', 'outcome', 'reason', 'selected']) || a['ordinal'] !== index + 1
84
+ || a['family'] !== family || typeof a['wrapperInvoked'] !== 'boolean' || typeof a['selected'] !== 'boolean') return null;
85
+ const rejected = family === 'claude' && !a['wrapperInvoked'] && !a['selected'] && a['model'] === null && a['outcome'] === 'rejected' && a['reason'] === 'invalid-candidate';
86
+ const answered = a['wrapperInvoked'] && a['selected'] && safeRoutingModel(a['model']) && a['outcome'] === 'answered' && a['reason'] === 'answered';
87
+ const failed = a['wrapperInvoked'] && !a['selected'] && safeRoutingModel(a['model']) && a['outcome'] === 'failed'
88
+ && ['timeout', 'spawn-error', 'no-exit-code', 'exit-nonzero', 'unexpected-response'].includes(a['reason'] as string);
89
+ if (!(rejected || answered || failed)) return null;
90
+ safe.push({ ordinal: index + 1, model: a['model'] as string | null, family, wrapperInvoked: a['wrapperInvoked'],
91
+ outcome: a['outcome'] as RoutingAttempt['outcome'], reason: a['reason'] as string, selected: a['selected'] });
92
+ }
93
+ const selected = safe.filter(a => a.selected);
94
+ if (!value['complete'] ? selected.length !== 0 : selectedModel === null ? selected.length !== 0
95
+ : selected.length !== 1 || selected[0]?.ordinal !== total || selected[0]?.model !== selectedModel) return null;
96
+ return { complete: value['complete'], totalConsidered: total, attempts: safe };
97
+ }
98
+
99
+ function routingStageIdentity(row: RecordRow): { evidenceKey: string; dispatchSeq: number } | null {
100
+ const seq = count(row['dispatchSeq']);
101
+ if (seq === null || seq === 0 || !routingRun(row['runId'])) return null;
102
+ const evidenceKey = JSON.stringify(['wf-dispatch', row['runId'], seq]);
103
+ if (Object.hasOwn(row, 'evidenceKey') && row['evidenceKey'] !== evidenceKey) return null;
104
+ return { evidenceKey, dispatchSeq: seq };
105
+ }
106
+
107
+ /** Routing uses raw bounded evidence, independently of numerical first-wins normalization. */
108
+ function projectRoutingProvenance(input: RoutingInput) {
109
+ const diagnostics = new Set<string>();
110
+ let defect = false;
111
+ let partial = false;
112
+ let scopeInvalid = false;
113
+ let scopeIncomplete = false;
114
+ const mark = (reason: string, severity: 'defect' | 'partial' = 'partial') => {
115
+ diagnostics.add(reason);
116
+ if (severity === 'defect') defect = true; else partial = true;
117
+ };
118
+ const metadataInvalid = (reason = 'metadata-invalid') => mark(reason, 'defect');
119
+ const sourceMap: Record<string, [string, 'defect' | 'partial']> = {
120
+ 'inventory-truncated': ['source-truncated', 'partial'], 'source-input-too-large': ['source-too-large', 'partial'],
121
+ 'missing-or-unreadable-source': ['source-unreadable', 'partial'], 'malformed-record': ['source-malformed', 'defect'],
122
+ 'malformed-budget-schema': ['source-malformed', 'defect'],
123
+ 'inventory-run-state-unavailable-or-foreign': ['source-scope-incomplete', 'partial'],
124
+ 'inventory-trace-binding-unavailable': ['source-scope-incomplete', 'partial'],
125
+ };
126
+ const invalidSources = ['source-outside-root-or-nonregular', 'source-symlink', 'source-changed-during-read',
127
+ 'inventory-trace-binding-mismatch', 'inventory-trace-invalid', 'inventory-workflow-projection-identity-invalid',
128
+ 'inventory-workflow-projection-identity-mismatch', 'foreign-project-root', 'foreign-trace-run', 'unknown-source',
129
+ 'source-selection-ambiguous', 'source-selection-conflicting-run-dir', 'workflow-source-missing-or-ambiguous', 'fa-run-selection-ambiguous-or-unidentified'];
130
+ for (const diagnostic of input.diagnostics ?? []) {
131
+ if (typeof diagnostic !== 'string' || diagnostic.length === 0) continue;
132
+ const name = diagnostic.split(':', 1)[0]!;
133
+ const mapping = invalidSources.includes(name) ? ['source-scope-invalid', 'defect'] as const : sourceMap[name];
134
+ if (mapping) { mark(mapping[0], mapping[1]); scopeInvalid ||= mapping[0] === 'source-scope-invalid'; scopeIncomplete ||= mapping[1] === 'partial'; }
135
+ else { mark('source-diagnostic-unmapped'); scopeIncomplete = true; }
136
+ }
137
+ const maximum = input.maxRecords ?? 100000;
138
+ if (input.rows.length > maximum || (input.expected?.length ?? 0) > maximum) { mark('source-truncated'); scopeIncomplete = true; }
139
+ const rawRows = input.rows.slice(0, maximum);
140
+ const invalidNormalizedKeys = new Set<string>();
141
+ const stageMaps = new Map<string, { raw: RecordRow; tuple: string; conflict: boolean; legacy: boolean; valid: boolean; modelInvalid: boolean }>();
142
+ const probeMaps = new Map<string, { raw: RecordRow; tuple: string; conflict: boolean; valid: boolean; reason: string | null; summary: RoutingProbe | null }>();
143
+ let hasNew = false;
144
+ let hasLegacy = false;
145
+ if (input.sourceKind === 'workflow-budget') {
146
+ if (!routingRun(input.runId)) { mark('source-scope-invalid', 'defect'); scopeInvalid = true; }
147
+ if (input.expected === undefined) { mark('source-scope-incomplete'); scopeIncomplete = true; }
148
+ for (const raw of rawRows) {
149
+ if (raw['kind'] === 'probe') {
150
+ const keys = ['probeId', 'probeProvenance', 'probeSource', 'probeObservationReason'];
151
+ const present = keys.map(key => Object.hasOwn(raw, key));
152
+ if (present.every(value => !value)) { hasLegacy ||= raw['runId'] === input.runId; continue; }
153
+ hasNew ||= raw['runId'] === input.runId;
154
+ const reason = raw['probeObservationReason'];
155
+ const modelValid = raw['model'] === null || safeRoutingModel(raw['model']);
156
+ const reasonValid = reason === null || (typeof reason === 'string' && [...routingAbsenceReasons, ...routingErrorReasons].includes(reason));
157
+ const idValid = raw['probeId'] === null || routingId(raw['probeId']);
158
+ const sourceValid = raw['probeSource'] === 'dispatcher-child-seam' || raw['probeSource'] === 'scripted-dispatcher';
159
+ const provenance = reason === null ? validateRoutingAttempts(raw['probeProvenance'], raw['family'], raw['model']) : null;
160
+ const valid = present.every(Boolean) && routingRun(raw['runId']) && routingFamily(raw['family']) && idValid && sourceValid && modelValid && reasonValid
161
+ && (reason === null ? routingId(raw['probeId']) && provenance !== null : raw['probeProvenance'] === null);
162
+ const supplied = raw['probeProvenance'];
163
+ const unsafeAttemptModel = record(supplied) && Array.isArray(supplied['attempts']) && supplied['attempts'].slice(0, 32).some(a => record(a)
164
+ && Object.hasOwn(a, 'model') && a['model'] !== null && !safeRoutingModel(a['model']));
165
+ if (!valid) metadataInvalid(modelValid && !unsafeAttemptModel ? 'metadata-invalid' : 'model-invalid');
166
+ else if (typeof reason === 'string') mark(reason, routingErrorReasons.includes(reason) ? 'defect' : 'partial');
167
+ else if (provenance && !provenance.complete) mark('attempts-truncated');
168
+ if (!routingId(raw['probeId'])) continue;
169
+ const summary: RoutingProbe | null = valid && reason === null && provenance !== null ? {
170
+ probeId: raw['probeId'], runId: raw['runId'] as string, family: raw['family'] as RoutingProbe['family'], source: raw['probeSource'] as string,
171
+ selectedModel: raw['model'] as string | null, complete: provenance.complete, totalConsidered: provenance.totalConsidered, attempts: provenance.attempts,
172
+ } : null;
173
+ // Rejected metadata is one closed marker: never traverse, copy or stringify its raw payload.
174
+ const tuple = JSON.stringify(valid
175
+ ? ['valid', raw['runId'], raw['family'], raw['model'], raw['probeSource'], reason, provenance]
176
+ : ['invalid']);
177
+ const old = probeMaps.get(raw['probeId']);
178
+ if (old && old.tuple !== tuple) { old.conflict = true; metadataInvalid('identity-conflict'); }
179
+ else if (!old) probeMaps.set(raw['probeId'], { raw, tuple, conflict: false, valid, reason: typeof reason === 'string' ? reason : null, summary });
180
+ } else if (raw['kind'] === 'stage') {
181
+ const identity = routingStageIdentity(raw);
182
+ if (!identity || raw['runId'] !== input.runId) {
183
+ metadataInvalid();
184
+ const key = text(raw['evidenceKey']) ?? (count(raw['dispatchSeq']) !== null ? JSON.stringify(['wf-dispatch', raw['runId'], raw['dispatchSeq']]) : null);
185
+ if (key !== null) invalidNormalizedKeys.add(key);
186
+ continue;
187
+ }
188
+ const keys = ['plannedModel', 'plannedModelSource', 'probeId'];
189
+ const present = keys.map(key => Object.hasOwn(raw, key));
190
+ const legacy = present.every(value => !value);
191
+ hasLegacy ||= legacy; hasNew ||= !legacy;
192
+ const plan = raw['plannedModel'];
193
+ const source = raw['plannedModelSource'];
194
+ const requestValid = raw['requestedModel'] === null || safeRoutingModel(raw['requestedModel']);
195
+ const planValid = source === 'plan-declared' ? safeRoutingModel(plan) : (source === 'plan-omitted' || source === 'unavailable') && plan === null;
196
+ const valid = legacy || present.every(Boolean) && requestValid && planValid && routingFamily(raw['family']) && (raw['probeId'] === null || routingId(raw['probeId']));
197
+ const modelInvalid = !legacy && (!requestValid || (source === 'plan-declared' && !safeRoutingModel(plan)) || source === 'unavailable');
198
+ if (!valid || modelInvalid) metadataInvalid(modelInvalid ? 'model-invalid' : 'metadata-invalid');
199
+ const tuple = JSON.stringify(legacy
200
+ ? ['legacy', routingFamily(raw['family']) ? raw['family'] : null, safeRoutingModel(raw['requestedModel']) ? raw['requestedModel'] : null]
201
+ : valid ? ['valid', raw['family'], raw['requestedModel'], plan, source, raw['probeId']] : ['invalid']);
202
+ const old = stageMaps.get(identity.evidenceKey);
203
+ if (old && old.tuple !== tuple) { old.conflict = true; metadataInvalid('identity-conflict'); }
204
+ else if (!old) stageMaps.set(identity.evidenceKey, { raw, tuple, conflict: false, legacy, valid, modelInvalid });
205
+ } else metadataInvalid();
206
+ }
207
+ for (const expected of (input.expected ?? []).slice(0, maximum)) {
208
+ const identity = routingStageIdentity(expected);
209
+ if (!identity || expected['runId'] !== input.runId) { metadataInvalid(); continue; }
210
+ if (!stageMaps.has(identity.evidenceKey)) { mark('expected-dispatch-missing'); scopeIncomplete = true; }
211
+ }
212
+ }
213
+ if (hasNew && hasLegacy) mark('not-recorded');
214
+ const stages: RoutingStage[] = [];
215
+ for (const [evidenceKey, stage] of [...stageMaps].sort(([a], [b]) => a.localeCompare(b))) {
216
+ const raw = stage.raw;
217
+ const referenced = routingId(raw['probeId']) ? probeMaps.get(raw['probeId']) : undefined;
218
+ let linkStatus = 'linked';
219
+ if (stage.conflict || referenced?.conflict) linkStatus = 'identity-conflict';
220
+ else if (scopeInvalid || !stage.valid || stage.modelInvalid || referenced && (!referenced.valid || referenced.reason !== null && routingErrorReasons.includes(referenced.reason))) linkStatus = 'invalid';
221
+ else if (stage.legacy) linkStatus = 'not-recorded';
222
+ else if (referenced && referenced.raw['runId'] !== input.runId) linkStatus = 'foreign-probe';
223
+ else if (referenced && referenced.raw['family'] !== raw['family']) linkStatus = 'family-mismatch';
224
+ else if (referenced?.summary && (referenced.summary.selectedModel === null || referenced.summary.selectedModel !== raw['requestedModel'])) linkStatus = 'selection-mismatch';
225
+ else if (raw['probeId'] !== null && !referenced) linkStatus = 'missing-probe';
226
+ else if (raw['probeId'] === null || referenced?.reason !== null && referenced?.reason !== undefined) linkStatus = 'probe-unavailable';
227
+ else if (scopeIncomplete) linkStatus = 'scope-incomplete';
228
+ const suppress = ['identity-conflict', 'invalid', 'foreign-probe', 'family-mismatch', 'selection-mismatch'].includes(linkStatus);
229
+ if (suppress) metadataInvalid(linkStatus === 'invalid' ? 'metadata-invalid' : linkStatus);
230
+ else if (linkStatus !== 'linked' && linkStatus !== 'not-recorded') mark(linkStatus === 'scope-incomplete' ? 'source-scope-incomplete' : linkStatus);
231
+ const identity = routingStageIdentity(raw)!;
232
+ stages.push({ evidenceKey, dispatchSeq: identity.dispatchSeq, plannedModel: suppress || stage.legacy ? null : raw['plannedModel'] as string | null,
233
+ plannedModelSource: suppress ? 'unavailable' : stage.legacy ? 'not-recorded' : raw['plannedModelSource'] as RoutingStage['plannedModelSource'],
234
+ requestedModel: suppress || stage.legacy ? null : raw['requestedModel'] as string | null,
235
+ probeId: !suppress && (linkStatus === 'linked' || linkStatus === 'scope-incomplete') ? raw['probeId'] as string : null, linkStatus });
236
+ }
237
+ const probes = [...probeMaps.values()].filter(probe => !scopeInvalid && !probe.conflict && probe.summary && probe.raw['runId'] === input.runId)
238
+ .map(probe => probe.summary!).sort((a, b) => a.probeId.localeCompare(b.probeId));
239
+ if (!hasNew) diagnostics.add('not-recorded');
240
+ const status = defect ? 'defect' : partial ? 'partial' : hasNew ? 'observed' : 'not-recorded';
241
+ const exposedStages = hasNew || defect ? stages : [];
242
+ const stageFields = new Map(stages.map(stage => [stage.evidenceKey, {
243
+ plannedModel: stage.plannedModel, plannedModelSource: stage.plannedModelSource, probeId: stage.probeId,
244
+ }]));
245
+ for (const key of invalidNormalizedKeys) stageFields.set(key, { plannedModel: null, plannedModelSource: 'unavailable', probeId: null });
246
+ return { report: { schema: 'routing-provenance-1', status, probes: probes.length ? probes : null,
247
+ stages: exposedStages.length ? exposedStages : null, diagnostics: [...diagnostics].sort() }, stageFields };
248
+ }
249
+
57
250
  /** All amounts remain source-specific; inventory never substitutes for a numeric witness. */
58
251
  export function buildStageUsageReport(input: {
59
252
  sourceKind: string; sourcePath: string; runId: string | null; rows: readonly RecordRow[];
@@ -173,8 +366,9 @@ export function buildStageUsageReport(input: {
173
366
  const pricingComplete = rows.length > 0 && rows.every((r) => r.pricingKnown) && totalComplete;
174
367
  const conservation = { status: quantitativeDefect ? 'DEFECT' : totalComplete ? 'BALANCED' : 'INSUFFICIENT_DATA', scope: 'reported receipt attribution; not independent source verification', metric };
175
368
  const dimensionCoverage = Object.fromEntries(tokenFields.map((key) => [key, { known: rows.filter((r) => r[key] !== null).length, unknown: rows.filter((r) => r[key] === null).length + expectedMissing.length }]));
176
- return { schema: 'stage-usage-1', sourceKind: input.sourceKind, sourcePath: input.sourcePath, runId: input.runId, metric: compatible ? metric : 'incompatible-metrics',
177
- rows, verdict: conservation.status, complete: totalComplete && witnessComplete && pricingComplete,
369
+ const routing = projectRoutingProvenance(input);
370
+ return { routingProvenance: routing.report, schema: 'stage-usage-1', sourceKind: input.sourceKind, sourcePath: input.sourcePath, runId: input.runId, metric: compatible ? metric : 'incompatible-metrics',
371
+ rows: rows.map(row => ({ ...row, ...(routing.stageFields.get(row.evidenceKey ?? '') ?? { plannedModel: null, plannedModelSource: 'not-recorded', probeId: null }) })), verdict: conservation.status, complete: totalComplete && witnessComplete && pricingComplete,
178
372
  knownRunTotalTokens: known, knownAccountedTokens: accounted, knownUnaccountedTokens: unaccounted,
179
373
  stageTokensSum: accounted !== null && doubled !== null ? accounted + doubled : null, doubleAttributedTokens: doubled,
180
374
  runTotalTokens: totalComplete ? known : null, sourceVerifiedTotalTokens: witnessComplete && totalComplete ? known : null,