onto-mcp 0.4.12 → 0.4.13

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 (128) hide show
  1. package/.onto/authority/core-lexicon.yaml +1 -1
  2. package/.onto/authority/supported-models.yaml +137 -0
  3. package/.onto/domains/accounting-kr/competency_qs.md +153 -153
  4. package/.onto/processes/reconstruct/obligation-coverage-ledger.yaml +842 -0
  5. package/.onto/processes/reconstruct/obligation-coverage-recorded.yaml +225 -0
  6. package/.onto/processes/reconstruct/ontology-seeding-and-maturation-design.md +93 -54
  7. package/.onto/processes/reconstruct/reconstruct-contract-registry.yaml +169 -8
  8. package/.onto/processes/reconstruct/reconstruct-execution-ux-contract.md +72 -1
  9. package/.onto/processes/reconstruct/source-profiles/spreadsheet.md +35 -0
  10. package/.onto/processes/review/productized-live-path.md +9 -5
  11. package/.onto/processes/review/prompt-execution-runner-contract.md +32 -8
  12. package/.onto/processes/review/review-target-profile-contract.md +47 -4
  13. package/.onto/processes/shared/pipeline-execution-ledger-contract.md +11 -0
  14. package/.onto/processes/shared/target-material-kind-contract.md +1 -1
  15. package/README.md +64 -1
  16. package/dist/cli.js +11 -1
  17. package/dist/core-api/reconstruct-api.js +575 -24
  18. package/dist/core-api/review-api.js +70 -5
  19. package/dist/core-api/review-progress.js +7 -0
  20. package/dist/core-api/runtime-observation.js +7 -0
  21. package/dist/core-runtime/artifact-io.js +75 -3
  22. package/dist/core-runtime/cli/claude-code-review-unit-executor.js +19 -8
  23. package/dist/core-runtime/cli/materialize-review-prompt-packets.js +68 -14
  24. package/dist/core-runtime/cli/prepare-review-session.js +34 -1
  25. package/dist/core-runtime/cli/render-review-final-output.js +2 -1
  26. package/dist/core-runtime/cli/run-review-prompt-execution.js +758 -48
  27. package/dist/core-runtime/cli/run-seats.js +18 -0
  28. package/dist/core-runtime/cli/runtime-submit-context.js +10 -2
  29. package/dist/core-runtime/cli/unit-resubmit.js +269 -0
  30. package/dist/core-runtime/cli/worker-structured-output.js +80 -2
  31. package/dist/core-runtime/discovery/host-detection.js +71 -22
  32. package/dist/core-runtime/discovery/review-cert-assemble.js +208 -0
  33. package/dist/core-runtime/discovery/review-cert-record.js +442 -0
  34. package/dist/core-runtime/discovery/review-execution-units.js +22 -0
  35. package/dist/core-runtime/discovery/seat-inventory.js +167 -0
  36. package/dist/core-runtime/discovery/settings-chain.js +311 -52
  37. package/dist/core-runtime/discovery/supported-models.js +282 -29
  38. package/dist/core-runtime/discovery/synthesize-cert-assemble.js +197 -0
  39. package/dist/core-runtime/discovery/synthesize-cert-capsule.js +399 -0
  40. package/dist/core-runtime/discovery/synthesize-cert-judge.js +340 -0
  41. package/dist/core-runtime/discovery/synthesize-cert-loop.js +234 -0
  42. package/dist/core-runtime/discovery/synthesize-cert-mutation.js +117 -0
  43. package/dist/core-runtime/discovery/synthesize-cert-packet.js +182 -0
  44. package/dist/core-runtime/discovery/synthesize-cert-record.js +831 -0
  45. package/dist/core-runtime/discovery/synthesize-cert-sampler.js +336 -0
  46. package/dist/core-runtime/effort-calibration-ingest.js +135 -0
  47. package/dist/core-runtime/llm/dispatch-breaker.js +412 -0
  48. package/dist/core-runtime/llm/dispatch-fallback-adapter-capabilities.js +33 -0
  49. package/dist/core-runtime/llm/llm-caller.js +188 -47
  50. package/dist/core-runtime/llm/mock-llm-realization.js +35 -4
  51. package/dist/core-runtime/llm/model-switcher.js +1 -1
  52. package/dist/core-runtime/llm/openai-responses-incomplete-error.js +46 -0
  53. package/dist/core-runtime/llm/sealed-dispatch-capability.js +359 -0
  54. package/dist/core-runtime/llm/structured-dispatch-error.js +15 -0
  55. package/dist/core-runtime/onboard/claude-profile-scan.js +65 -0
  56. package/dist/core-runtime/onboard/cli-host.js +5 -2
  57. package/dist/core-runtime/onboard/configure-provider.js +33 -1
  58. package/dist/core-runtime/onboard/host-target.js +28 -3
  59. package/dist/core-runtime/onboard/register.js +54 -12
  60. package/dist/core-runtime/pipeline-execution-ledger.js +10 -0
  61. package/dist/core-runtime/reconstruct/artifact-types.js +35 -0
  62. package/dist/core-runtime/reconstruct/claim-projection-validation.js +17 -0
  63. package/dist/core-runtime/reconstruct/competency-projection-contract.js +63 -0
  64. package/dist/core-runtime/reconstruct/comprehension-artifact.js +321 -0
  65. package/dist/core-runtime/reconstruct/comprehension-reduce.js +367 -0
  66. package/dist/core-runtime/reconstruct/comprehension-semantic-map.js +771 -0
  67. package/dist/core-runtime/reconstruct/contract-registry.js +20 -0
  68. package/dist/core-runtime/reconstruct/dispatch-fallback-artifacts.js +640 -0
  69. package/dist/core-runtime/reconstruct/execution-telemetry.js +83 -9
  70. package/dist/core-runtime/reconstruct/final-output-sections.js +89 -0
  71. package/dist/core-runtime/reconstruct/governing-snapshot.js +36 -1
  72. package/dist/core-runtime/reconstruct/leaf-reader.js +305 -0
  73. package/dist/core-runtime/reconstruct/llm-dispatch-failure.js +270 -0
  74. package/dist/core-runtime/reconstruct/llm-touch-fingerprint.js +94 -0
  75. package/dist/core-runtime/reconstruct/material-admission-validation.js +18 -0
  76. package/dist/core-runtime/reconstruct/material-profile-validation.js +14 -0
  77. package/dist/core-runtime/reconstruct/materialize-preparation.js +304 -18
  78. package/dist/core-runtime/reconstruct/maturation-validation.js +1231 -53
  79. package/dist/core-runtime/reconstruct/mock-llm-realization.js +107 -3
  80. package/dist/core-runtime/reconstruct/obligation-assertion.js +18 -0
  81. package/dist/core-runtime/reconstruct/ontology-seed-validation.js +27 -0
  82. package/dist/core-runtime/reconstruct/output-budget.js +6 -0
  83. package/dist/core-runtime/reconstruct/pipeline-execution-ledger.js +87 -3
  84. package/dist/core-runtime/reconstruct/post-seed-validation.js +118 -5
  85. package/dist/core-runtime/reconstruct/purpose-authority-validation.js +117 -2
  86. package/dist/core-runtime/reconstruct/record.js +42 -0
  87. package/dist/core-runtime/reconstruct/registry-verification-validation.js +16 -0
  88. package/dist/core-runtime/reconstruct/run-control-validation.js +1011 -34
  89. package/dist/core-runtime/reconstruct/run.js +5198 -536
  90. package/dist/core-runtime/reconstruct/seed-authoring-readiness-validation.js +54 -3
  91. package/dist/core-runtime/reconstruct/semantic-quality-gate.js +105 -2
  92. package/dist/core-runtime/reconstruct/source-observation-delta-validation.js +41 -0
  93. package/dist/core-runtime/reconstruct/source-observations.js +166 -0
  94. package/dist/core-runtime/reconstruct/source-safety-validation.js +71 -183
  95. package/dist/core-runtime/reconstruct/source-scout-pack-validation.js +37 -1
  96. package/dist/core-runtime/reconstruct/terminal-validation.js +132 -2
  97. package/dist/core-runtime/review/continuation-plan.js +12 -5
  98. package/dist/core-runtime/review/issue-artifact-runtime.js +4 -1
  99. package/dist/core-runtime/review/materializers.js +100 -11
  100. package/dist/core-runtime/review/nesting-batch.js +6 -2
  101. package/dist/core-runtime/review/obligation-shardability.js +138 -0
  102. package/dist/core-runtime/review/pipeline-execution-ledger.js +42 -2
  103. package/dist/core-runtime/review/review-artifact-utils.js +217 -5
  104. package/dist/core-runtime/review/review-execution-steps.js +32 -8
  105. package/dist/core-runtime/review/review-progress-contract.js +8 -0
  106. package/dist/core-runtime/review/review-prompt-budget.js +97 -0
  107. package/dist/core-runtime/review/semantic-quality-gate.js +33 -7
  108. package/dist/core-runtime/review/spreadsheet-review-disposition.js +94 -0
  109. package/dist/core-runtime/route-identity.js +182 -0
  110. package/dist/core-runtime/spreadsheet-header-escalation.js +204 -0
  111. package/dist/core-runtime/spreadsheet-structure-observer.js +2689 -0
  112. package/dist/core-runtime/target-material-kind.js +58 -0
  113. package/dist/mcp/server.js +12 -5
  114. package/dist/tui/app.js +167 -0
  115. package/dist/tui/data/event-follower.js +116 -0
  116. package/dist/tui/data/node-detail.js +42 -0
  117. package/dist/tui/data/projection-poll.js +26 -0
  118. package/dist/tui/data/session-discovery.js +65 -0
  119. package/dist/tui/index.js +108 -0
  120. package/dist/tui/screens/log.js +25 -0
  121. package/dist/tui/screens/node-detail.js +17 -0
  122. package/dist/tui/screens/session-selector.js +26 -0
  123. package/dist/tui/screens/workflow-tree.js +90 -0
  124. package/dist/tui/view-model/reconstruct-adapter.js +162 -0
  125. package/dist/tui/view-model/review-adapter.js +270 -0
  126. package/dist/tui/view-model/tree-view-model.js +19 -0
  127. package/package.json +18 -3
  128. package/settings.example.json +77 -16
@@ -0,0 +1,97 @@
1
+ import { REVIEW_EXECUTION_UNIT_IDS } from "../discovery/settings-chain.js";
2
+ import { reviewExecutionUnitActor } from "./review-execution-profile.js";
3
+ /**
4
+ * Reference context window (tokens) that maps to multiplier 1 — the smallest
5
+ * window that still earns the DEFAULT (unchanged) budget. A window at or below
6
+ * this floors to multiplier 1. PRELIMINARY (INV-BENCH-1).
7
+ */
8
+ export const BASELINE_WINDOW_TOKENS = 200_000;
9
+ /**
10
+ * The fixed embed line budget applied to a packet embed when no window-scaled
11
+ * value is resolved — the no-regression floor and the single source of this
12
+ * value, shared by the prepare-time budget resolution and the packet stage's
13
+ * fallback. (Unchanged from the historical packet-stage default of 300.)
14
+ */
15
+ export const DEFAULT_MAX_EMBED_LINES = 300;
16
+ /**
17
+ * Hard ceiling on the window multiplier, so an arbitrarily large window cannot
18
+ * blow up the prompt budget without bound. PRELIMINARY (INV-BENCH-1).
19
+ */
20
+ export const MAX_WINDOW_MULTIPLIER = 4;
21
+ /**
22
+ * Convert a resolved context window (tokens) into a budget multiplier.
23
+ * undefined (unresolved model / no registered window) → 1 (no regression).
24
+ * Otherwise clamp(window / BASELINE_WINDOW_TOKENS, 1, MAX_WINDOW_MULTIPLIER) —
25
+ * floored at 1 so a small-window model is never penalised below DEFAULT, and
26
+ * ceilinged at MAX so a huge window cannot grow the budget without bound.
27
+ */
28
+ export function reviewWindowMultiplier(contextWindowTokens) {
29
+ if (contextWindowTokens === undefined)
30
+ return 1;
31
+ return Math.min(Math.max(contextWindowTokens / BASELINE_WINDOW_TOKENS, 1), MAX_WINDOW_MULTIPLIER);
32
+ }
33
+ /**
34
+ * Window-proportional embed line budget — the true binding cut applied to the
35
+ * packet embed (`truncateForEmbedding`). Floored at `defaultMaxEmbedLines` so
36
+ * multiplier 1 returns the unchanged DEFAULT (no regression), and ceilinged at
37
+ * `defaultMaxEmbedLines * MAX_WINDOW_MULTIPLIER`.
38
+ */
39
+ export function deriveReviewMaxEmbedLines(multiplier, defaultMaxEmbedLines) {
40
+ return Math.min(Math.max(Math.round(defaultMaxEmbedLines * multiplier), defaultMaxEmbedLines), defaultMaxEmbedLines * MAX_WINDOW_MULTIPLIER);
41
+ }
42
+ /** Merge an actor llm ref with an optional unit override (override wins per
43
+ * field), the same shallow merge the execution-profile resolver uses. Returns
44
+ * undefined only when both are absent. */
45
+ function mergeLensLlmRef(base, override) {
46
+ if (!base && !override)
47
+ return undefined;
48
+ return { ...(base ?? {}), ...(override ?? {}) };
49
+ }
50
+ /** Look up the registered context window (tokens) for a resolved lens llm ref.
51
+ * The registry key is the MODEL provider + model id (e.g. openai/gpt-5.5), NOT
52
+ * the runtime execution adapter (codex_cli / claude_code). An unresolved
53
+ * provider or model, an unregistered pair, or an entry without a window all
54
+ * yield undefined. */
55
+ function resolveLlmRefWindowTokens(llm, registry) {
56
+ const provider = llm?.provider;
57
+ const modelId = llm?.model;
58
+ if (!provider || !modelId)
59
+ return undefined;
60
+ return registry.supported_models.find((entry) => entry.provider === provider && entry.model === modelId)?.context_window_tokens;
61
+ }
62
+ /**
63
+ * Resolve the lens model's context window (tokens) for the review prompt budget.
64
+ *
65
+ * All lenses share the single "lens" actor model, so the common path resolves
66
+ * that one representative (`execution.lens.llm`). When a lens-CLASS unit override
67
+ * (a unit whose actor is "lens": lens / issue_stance_response /
68
+ * deliberation_response) selects a different model, every such effective lens
69
+ * model is resolved and the MINIMUM window is taken — the min can never overflow
70
+ * any lens, so the budget stays safe for the smallest-window lens (the floor
71
+ * keeps it conservative).
72
+ *
73
+ * Returns undefined when no lens model resolves to a registered window, so the
74
+ * caller falls through to multiplier 1 → DEFAULT budgets (no regression). Never
75
+ * throws.
76
+ */
77
+ export function resolveReviewLensContextWindowTokens(execution, registry) {
78
+ const lensActorLlm = execution.lens.llm;
79
+ const windows = [];
80
+ const baseWindow = resolveLlmRefWindowTokens(lensActorLlm, registry);
81
+ if (baseWindow !== undefined)
82
+ windows.push(baseWindow);
83
+ for (const unitId of REVIEW_EXECUTION_UNIT_IDS) {
84
+ if (reviewExecutionUnitActor(unitId) !== "lens")
85
+ continue;
86
+ const override = execution.units[unitId]?.llm;
87
+ if (!override)
88
+ continue;
89
+ const effective = mergeLensLlmRef(lensActorLlm, override);
90
+ const window = resolveLlmRefWindowTokens(effective, registry);
91
+ if (window !== undefined)
92
+ windows.push(window);
93
+ }
94
+ if (windows.length === 0)
95
+ return undefined;
96
+ return Math.min(...windows);
97
+ }
@@ -1,3 +1,22 @@
1
+ /** The complete check-id universe this gate can emit — the value-level single
2
+ * source (the check_id type derives from it). The review-cert/v2 evidence
3
+ * contract pins its per-run check universe against THIS list, so a harness
4
+ * that omits issue artifacts (the gate then emits a subset — see
5
+ * runSemanticQualityGate) cannot silently shrink the certified comparison. */
6
+ export const SEMANTIC_QUALITY_GATE_CHECK_IDS = [
7
+ "material_issue_recall",
8
+ "final_result_material_issue_recall",
9
+ "false_materiality_guard",
10
+ "boundary_uncertainty_preservation",
11
+ "non_material_finding_preservation",
12
+ "artifact_material_issue_recall",
13
+ "causal_materiality_shape",
14
+ "causal_relation_correctness",
15
+ "issue_dependency_preservation",
16
+ "actionability",
17
+ "count_list_consistency",
18
+ "grounding",
19
+ ];
1
20
  const DEFAULT_FIXTURE_ID = "review-pipeline-target-v1";
2
21
  const FIXTURES = {
3
22
  "review-pipeline-target-v1": {
@@ -458,14 +477,21 @@ export function evaluateReviewPipelineSemanticQualityGate(args) {
458
477
  `non_material_finding_count=${nonMaterialFindings.length}`,
459
478
  `boundary_notes_chars=${boundaryNotes?.length ?? 0}`,
460
479
  ]);
480
+ // Boundary uncertainty's preservation AUTHORITY is the finding-ledger
481
+ // (non-material findings), not the final Boundary Notes projection. A model
482
+ // preserves the decoy's uncertainty by recording it as a non-material,
483
+ // boundary-contextualized finding; whether it also echoes that into the final
484
+ // summary is a projection-style choice, not a quality signal — one model may
485
+ // prioritize the MATERIAL issue's own confidence boundaries in the final note
486
+ // and keep the non-material decoy in the ledger for audit, which is at least as
487
+ // sound. So this reads nonMaterialText (the authority) and stays independent of
488
+ // false_materiality_guard, which checks the ORTHOGONAL axis: the decoy was not
489
+ // mis-promoted to a material issue. (A decoy surfaced only in the final note but
490
+ // absent from the authority still fails here — authority is where it must live.)
461
491
  const boundaryUncertainty = check("boundary_uncertainty_preservation", !falseMaterialityCandidateObserved ||
462
- (boundaryNotes !== null &&
463
- textContainsAny(boundaryNotesText, fixture.boundaryUncertaintyTerms) &&
464
- textContainsAny(boundaryNotesText, fixture.boundaryContextTerms)), [
465
- boundaryNotes === null
466
- ? "Boundary Notes section missing"
467
- : `Boundary Notes chars=${boundaryNotes.length}`,
468
- `expected: compact note preserving ${fixture.boundaryUncertaintyTerms.join(", ")} uncertainty`,
492
+ containsBoundarySensitiveUncertainty(nonMaterialText, fixture), [
493
+ `non_material_boundary_chars=${nonMaterialText.length}`,
494
+ `expected boundary uncertainty (${fixture.boundaryUncertaintyTerms.join(", ")}) preserved in the finding-ledger authority (non-material findings)`,
469
495
  ]);
470
496
  const materialActionRefs = new Set(materialIssues.flatMap((issue) => strings(issue.action_candidates)));
471
497
  for (const item of actionCandidates) {
@@ -0,0 +1,94 @@
1
+ import path from "node:path";
2
+ import { inventoryHasRenderableStructure, } from "../spreadsheet-structure-observer.js";
3
+ /**
4
+ * risk_signal kinds that do NOT count as `structural_risk_signals` backing:
5
+ * - `unreadable_sheet_part`: an observation-failure marker, not a structural risk (so a
6
+ * risk-only corrupt shell backs nothing → not inspectable; R4 held).
7
+ * - `macro_present`: already owned by `access_and_protection_hygiene` (the observer pushes
8
+ * BOTH a `macro_present` boolean and a `macro_present` risk_signal — counting the signal
9
+ * here would double-back and re-open the #3/R4 asymmetry).
10
+ * - `external_links_present`: already owned by the explicit `external_links.length > 0`
11
+ * clause (same double-back hazard).
12
+ * The genuine residual structural-risk kinds are `ragged_row`, `oversized_zip_entry`,
13
+ * `pivot_table_cap`. (Error cells are NOT risk_signals — they live in `error_cells[]`,
14
+ * ORed in separately below.)
15
+ */
16
+ const NON_STRUCTURAL_RISK_KINDS = new Set([
17
+ "unreadable_sheet_part",
18
+ "macro_present",
19
+ "external_links_present",
20
+ ]);
21
+ /** True when a risk_signal kind is a genuine structural risk (not an observation-failure
22
+ * marker or a signal already owned by another obligation). */
23
+ export function isStructuralRiskSignal(kind) {
24
+ return !NON_STRUCTURAL_RISK_KINDS.has(kind);
25
+ }
26
+ /**
27
+ * The positive backing rule: each spreadsheet review obligation is listed only when its
28
+ * specific evidence exists in this ref's inventory. Mirrors the catalog in
29
+ * `reviewMaterialGoals("spreadsheet")` (target-material-kind.ts) one-to-one.
30
+ */
31
+ function computeBackedGoals(inv) {
32
+ const goals = [];
33
+ if (inv.formula_cells_total > 0) {
34
+ goals.push("formula_integrity");
35
+ }
36
+ if (inv.formula_patterns.some((pattern) => pattern.cross_sheet_refs.length > 0) ||
37
+ inv.cross_sheet_key_overlap.length > 0) {
38
+ goals.push("cross_sheet_reference_integrity");
39
+ }
40
+ if (inv.named_ranges.length > 0) {
41
+ goals.push("named_range_hygiene");
42
+ }
43
+ if (inv.data_validations.length > 0) {
44
+ goals.push("data_validation_coverage");
45
+ }
46
+ if (inv.macro_present || inv.sheets.some((sheet) => sheet.hidden || sheet.protected)) {
47
+ goals.push("access_and_protection_hygiene");
48
+ }
49
+ if (inv.risk_signals.some((signal) => isStructuralRiskSignal(signal.kind)) ||
50
+ inv.external_links.length > 0 ||
51
+ inv.error_cells.length > 0) {
52
+ goals.push("structural_risk_signals");
53
+ }
54
+ return goals;
55
+ }
56
+ /**
57
+ * Compute the single per-ref disposition from the shared workbook inventory (or its
58
+ * absence). Pure, total: never throws. `inventory` is undefined when the ref was never
59
+ * observed (e.g. a directory path that did not pass isSpreadsheetRef).
60
+ */
61
+ export function computeSpreadsheetDisposition(inventory, ref) {
62
+ const resolved = path.resolve(ref);
63
+ if (inventory === undefined) {
64
+ return {
65
+ ref: resolved,
66
+ inspectable: false,
67
+ reason: `${resolved}: workbook not observed`,
68
+ sha256: null,
69
+ backed_goals: [],
70
+ };
71
+ }
72
+ // The observer declined/failed to read it (unsupported format, unreadable, oversized,
73
+ // or a guarded decode crash) — content_sha256 is "" on a true skip, the real hash when
74
+ // the bytes were read but extraction is unimplemented (.xls etc.).
75
+ if (inventory.unsupported_reason !== null) {
76
+ return {
77
+ ref: resolved,
78
+ inspectable: false,
79
+ reason: `${resolved}: ${inventory.unsupported_reason}`,
80
+ sha256: inventory.content_sha256 || null,
81
+ backed_goals: [],
82
+ };
83
+ }
84
+ const renderable = inventoryHasRenderableStructure(inventory);
85
+ return {
86
+ ref: resolved,
87
+ inspectable: renderable,
88
+ reason: renderable
89
+ ? null
90
+ : `${resolved}: no renderable structure (empty or unreadable workbook)`,
91
+ sha256: inventory.content_sha256 || null,
92
+ backed_goals: renderable ? computeBackedGoals(inventory) : [],
93
+ };
94
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Unified route identity (effort-calibration simplification, design
3
+ * 20260617-effort-calibration-simplification-telemetry-derived-design.md §5).
4
+ *
5
+ * A RouteIdentity is a projection of the model-switcher's already-resolved
6
+ * selection — it REUSES LlmExecutionAdapter / LlmProviderName / LlmBillingMode
7
+ * rather than minting a parallel vocabulary. The only genuinely new fields are
8
+ * `route_provenance` (witnessed vs profile-derived) and `route_completeness`
9
+ * (whether the route resolved past model_provider).
10
+ *
11
+ * Provenance honesty (design §3, §8):
12
+ * - reconstruct derives execution_adapter / model_provider from the resolved
13
+ * selection carried on the call config (witnessed at the call boundary), NOT
14
+ * by reverse-mapping effective_base_url (which cannot recover model_provider
15
+ * for codex-cli://oauth or a custom openai-compatible base).
16
+ * - billing_mode is a code-path constant (`declared_billing_mode`), so it stays
17
+ * declared-provenance even on the reconstruct (otherwise witnessed) side.
18
+ * - effective_base_url is corroboration only, and identifies custom proxy bases
19
+ * that downgrade route_completeness.
20
+ */
21
+ import { DEFAULT_GROK_BASE_URL, DEFAULT_LMSTUDIO_BASE_URL, } from "./llm/model-switcher.js";
22
+ /**
23
+ * Map the model-switcher's RuntimeLlmProvider brand to the canonical model
24
+ * vendor. The only brand that differs from its model_provider is `codex` (the
25
+ * openai OAuth brand). Returns null for an unknown brand (route_completeness
26
+ * then degrades to under_determined).
27
+ */
28
+ export function modelProviderFromRuntimeProvider(provider) {
29
+ switch (provider) {
30
+ case "codex":
31
+ case "openai":
32
+ return "openai";
33
+ case "anthropic":
34
+ return "anthropic";
35
+ case "grok":
36
+ return "grok";
37
+ case "lmstudio":
38
+ return "lmstudio";
39
+ default:
40
+ return null;
41
+ }
42
+ }
43
+ /** The non-default openai-compatible bases that ARE normal provider routes. */
44
+ const KNOWN_OPENAI_COMPATIBLE_BASE_URLS = new Set([
45
+ DEFAULT_GROK_BASE_URL,
46
+ DEFAULT_LMSTUDIO_BASE_URL,
47
+ ]);
48
+ /**
49
+ * Whether an `openai_compatible_http` route runs against a CUSTOM proxy base —
50
+ * a base_url that is neither a mock sentinel nor one of the known provider
51
+ * defaults. The selection still resolves an adapter + model_provider, but a
52
+ * custom endpoint cannot be treated as the normal provider route (design §6 MF2):
53
+ * effective_base_url is corroboration only, so a custom base downgrades
54
+ * route_completeness rather than passing as fully witnessed.
55
+ */
56
+ function isCustomOpenAiCompatibleBase(adapter, baseUrl) {
57
+ if (adapter !== "openai_compatible_http")
58
+ return false;
59
+ if (!baseUrl)
60
+ return false;
61
+ return !KNOWN_OPENAI_COMPATIBLE_BASE_URLS.has(baseUrl);
62
+ }
63
+ function completeness(adapter, modelProvider, baseUrl) {
64
+ if (!modelProvider)
65
+ return "under_determined";
66
+ if (!adapter)
67
+ return "provider_only";
68
+ if (isCustomOpenAiCompatibleBase(adapter, baseUrl))
69
+ return "provider_only";
70
+ return "complete";
71
+ }
72
+ /**
73
+ * Build the witnessed reconstruct RouteIdentity from the fields available at the
74
+ * call-record boundary: the resolved provider brand and execution_adapter
75
+ * carried on the call config (witnessed), the call result's declared billing
76
+ * mode and effective_base_url. A `mock://` base short-circuits to the mock
77
+ * adapter (coarse; reconstruct contributes no realization).
78
+ */
79
+ export function witnessedReconstructRouteIdentity(input) {
80
+ const effectiveBaseUrl = input.effectiveBaseUrl ?? null;
81
+ if (effectiveBaseUrl?.startsWith("mock://")) {
82
+ return {
83
+ execution_adapter: "mock",
84
+ model_provider: null,
85
+ billing_mode: input.declaredBillingMode ?? "local",
86
+ effective_base_url: effectiveBaseUrl,
87
+ route_provenance: "witnessed",
88
+ route_completeness: "complete",
89
+ realization: null,
90
+ };
91
+ }
92
+ const adapter = input.executionAdapter ?? null;
93
+ const modelProvider = modelProviderFromRuntimeProvider(input.provider);
94
+ return {
95
+ execution_adapter: adapter,
96
+ model_provider: modelProvider,
97
+ billing_mode: input.declaredBillingMode ?? null,
98
+ effective_base_url: effectiveBaseUrl,
99
+ route_provenance: "witnessed",
100
+ route_completeness: completeness(adapter, modelProvider, effectiveBaseUrl),
101
+ realization: null,
102
+ };
103
+ }
104
+ /**
105
+ * Build a profile-derived RouteIdentity from a review route projection (already
106
+ * structurally rich: adapter / model_provider / billing / base_url). Review has
107
+ * no result-level base_url witness, so provenance is profile_derived (design
108
+ * §4, §11.1). `realization` (semantic_mock | boundary_stub | fixture) is
109
+ * review-only.
110
+ */
111
+ export function profileDerivedRouteIdentity(input) {
112
+ const adapter = input.executionAdapter ?? null;
113
+ const modelProvider = input.modelProvider ?? null;
114
+ const effectiveBaseUrl = input.effectiveBaseUrl ?? null;
115
+ return {
116
+ execution_adapter: adapter,
117
+ model_provider: modelProvider,
118
+ billing_mode: input.billingMode ?? null,
119
+ effective_base_url: effectiveBaseUrl,
120
+ route_provenance: "profile_derived",
121
+ route_completeness: completeness(adapter, modelProvider, effectiveBaseUrl),
122
+ realization: input.realization ?? null,
123
+ };
124
+ }
125
+ /**
126
+ * Derived single-string projection of a RouteIdentity for CLI `--route`
127
+ * comparison and grouping keys. The structured RouteIdentity stays canonical;
128
+ * this is a lossy projection (design §11.2). Shape:
129
+ * `<adapter|provider_only>:<billing>:<model_provider>`.
130
+ */
131
+ export function routeToken(identity) {
132
+ const adapter = identity.execution_adapter ?? "provider_only";
133
+ const billing = identity.billing_mode ?? "unknown";
134
+ const provider = identity.model_provider ?? "unknown";
135
+ return `${adapter}:${billing}:${provider}`;
136
+ }
137
+ const ROUTE_COMPLETENESS_ORDER = {
138
+ complete: 0,
139
+ provider_only: 1,
140
+ under_determined: 2,
141
+ };
142
+ /**
143
+ * The most degraded completeness across a set of identities. The route axis of
144
+ * the decision-grade gate (design §7 Q3) uses the worst — one legacy
145
+ * provider-only source taints the merged profile's route. `complete` is the
146
+ * fold identity (an empty list returns `complete`); a caller that treats "no
147
+ * identities at all" as a degradation must handle the empty case itself.
148
+ */
149
+ export function worstRouteCompleteness(values) {
150
+ let worst = "complete";
151
+ for (const value of values) {
152
+ if (ROUTE_COMPLETENESS_ORDER[value] > ROUTE_COMPLETENESS_ORDER[worst]) {
153
+ worst = value;
154
+ }
155
+ }
156
+ return worst;
157
+ }
158
+ /**
159
+ * Whether a declared `--route` hint corroborates a derived identity. The hint is
160
+ * a human label — historically provider-level (`"anthropic"`) or the slash form
161
+ * (`"anthropic/claude-cli"`) — while the canonical route is the structured
162
+ * RouteIdentity. The hint is demoted to a non-fatal cross-check (design §5,
163
+ * §11.2): an EXACT match against the full routeToken, the model_provider, or the
164
+ * execution_adapter always corroborates. The lenient provider-head fallback
165
+ * (first slash segment → model_provider) applies ONLY when the adapter is
166
+ * unknown (provider_only / under_determined): for a `complete` identity the
167
+ * adapter IS known, so a slash hint naming a different adapter (e.g.
168
+ * `anthropic/claude-cli` against an `anthropic_sdk` identity) must NOT be
169
+ * silently corroborated — that would hide the SDK-vs-OAuth split this refactor
170
+ * exists to surface.
171
+ */
172
+ export function routeHintMatches(hint, identity) {
173
+ if (hint === routeToken(identity) ||
174
+ hint === identity.model_provider ||
175
+ hint === identity.execution_adapter) {
176
+ return true;
177
+ }
178
+ if (identity.route_completeness === "complete")
179
+ return false;
180
+ const head = hint.split("/")[0] ?? hint;
181
+ return head === identity.model_provider;
182
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * P0.5 header escalation (design §10 C′ / §11 ESC-1) — a SEPARATE named step that
3
+ * runs AFTER the deterministic observer, never inside it. The observer flags a
4
+ * sheet whose header is ambiguous (e.g. a headerless all-text sheet whose first
5
+ * DATA row scored as labels) with `header_confidence: "low"`. For those sheets
6
+ * this step asks an LLM to resolve the header row under a strict bounded-submit
7
+ * contract, with a deterministic replay cache and a fail-soft downgrade to the
8
+ * heuristic when the model is unavailable or answers out of contract.
9
+ *
10
+ * LLM authority is narrow (capability-boundary): the model returns ONLY a
11
+ * header-row index (or null); code owns the trigger gate, the submission
12
+ * validation, the cache key, the downgrade policy, and the recorded provenance.
13
+ * The model is injected so this module stays deterministic under test; production
14
+ * wiring adapts the in-process llm caller to {@link HeaderEscalationLlm}. This
15
+ * module is intentionally unwired — consumers (reconstruct seed-stage / review)
16
+ * call it as a post-observation step.
17
+ */
18
+ import crypto from "node:crypto";
19
+ import { SPREADSHEET_OBSERVER_ADAPTER_VERSION } from "./spreadsheet-structure-observer.js";
20
+ /** Bumped when the escalation prompt/contract changes — folds into the cache key
21
+ * so a contract change invalidates prior decisions (design §11 CACHE-1). */
22
+ export const HEADER_ESCALATION_TRIGGER_VERSION = 1;
23
+ /** Rows shown to the model — the leading window the observer also scans. */
24
+ export const ESCALATION_ROW_WINDOW = 15;
25
+ /** Columns shown to the model — bounds the prompt for very wide sheets. */
26
+ export const ESCALATION_COL_WINDOW = 40;
27
+ /** Per-cell char cap in the rendered prompt, to keep the payload bounded. */
28
+ const ESCALATION_CELL_CHAR_CAP = 40;
29
+ /** Races the injected model call against an abort budget so a never-settling
30
+ * adapter rejects (→ `llm_unavailable`) instead of hanging the await. The timer
31
+ * is cleared when the model settles first; a late settle after timeout is
32
+ * ignored (no unhandled rejection). */
33
+ function callLlmWithTimeout(llm, args, timeoutMs) {
34
+ return new Promise((resolve, reject) => {
35
+ let settled = false;
36
+ const timer = setTimeout(() => {
37
+ if (settled)
38
+ return;
39
+ settled = true;
40
+ reject(new Error("header_escalation_timeout"));
41
+ }, timeoutMs);
42
+ // Defer through a microtask so a synchronous throw from the adapter routes
43
+ // to the rejection handler (clearing the timer) rather than escaping the
44
+ // executor with the timer still pending.
45
+ Promise.resolve()
46
+ .then(() => llm(args))
47
+ .then((value) => {
48
+ if (settled)
49
+ return;
50
+ settled = true;
51
+ clearTimeout(timer);
52
+ resolve(value);
53
+ }, (error) => {
54
+ if (settled)
55
+ return;
56
+ settled = true;
57
+ clearTimeout(timer);
58
+ reject(error instanceof Error ? error : new Error(String(error)));
59
+ });
60
+ });
61
+ }
62
+ function renderRowsTable(rows, colCount) {
63
+ const cols = Math.min(colCount, ESCALATION_COL_WINDOW);
64
+ const colSuffix = colCount > cols ? ` | … (+${colCount - cols} cols)` : "";
65
+ return rows
66
+ .slice(0, ESCALATION_ROW_WINDOW)
67
+ .map((row, index) => {
68
+ const cells = Array.from({ length: cols }, (_, col) => (row[col] ?? "")
69
+ .replace(/\s+/g, " ")
70
+ .trim()
71
+ .slice(0, ESCALATION_CELL_CHAR_CAP));
72
+ return `row ${index}: ${cells.map((cell) => cell || "∅").join(" | ")}${colSuffix}`;
73
+ })
74
+ .join("\n");
75
+ }
76
+ /** Bounded prompt: the leading rows + the exact JSON contract. Deterministic in
77
+ * the candidate, so its hash anchors the replay cache. */
78
+ export function renderHeaderPrompt(candidate) {
79
+ const shown = Math.min(candidate.rows.length, ESCALATION_ROW_WINDOW);
80
+ return [
81
+ `Sheet "${candidate.sheetName}" has an ambiguous header. Its first ${shown} rows (0-indexed) are below.`,
82
+ `Identify the single row that is the column HEADER (labels, not data). If the sheet has no header row, answer null.`,
83
+ ``,
84
+ renderRowsTable(candidate.rows, candidate.colCount),
85
+ ``,
86
+ `Reply with ONLY this JSON and no other fields: {"header_row_index": <0-based row index, or null>}.`,
87
+ ].join("\n");
88
+ }
89
+ function shortHash(value) {
90
+ return crypto.createHash("sha256").update(value).digest("hex").slice(0, 16);
91
+ }
92
+ /** Deterministic replay key (design §11 CACHE-1): any drift in content,
93
+ * extractor/trigger version, prompt, route provider, model id/effort, or
94
+ * data-layer caps yields a fresh key, invalidating a stale decision. */
95
+ export function headerEscalationCacheKey(parts) {
96
+ return crypto
97
+ .createHash("sha256")
98
+ .update(JSON.stringify([
99
+ parts.contentSha256,
100
+ parts.extractorVersion,
101
+ parts.triggerVersion,
102
+ parts.promptHash,
103
+ parts.provider,
104
+ parts.modelId,
105
+ parts.effort,
106
+ parts.dataLayerCapsHash,
107
+ parts.sheetName,
108
+ ]))
109
+ .digest("hex");
110
+ }
111
+ /** Strict bounded-submit validation: accept ONLY `{ header_row_index }` with an
112
+ * in-range integer or null; reject extra/unknown fields, wrong types, and
113
+ * out-of-range indices (the model owns no other field). */
114
+ export function parseHeaderSubmission(raw, rowCount) {
115
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
116
+ return null;
117
+ const keys = Object.keys(raw);
118
+ if (keys.length !== 1 || keys[0] !== "header_row_index")
119
+ return null;
120
+ const value = raw.header_row_index;
121
+ if (value === null)
122
+ return { header_row_index: null };
123
+ if (typeof value !== "number" || !Number.isInteger(value))
124
+ return null;
125
+ if (value < 0 || value >= rowCount)
126
+ return null;
127
+ return { header_row_index: value };
128
+ }
129
+ /**
130
+ * Resolve a sheet's header row, escalating to the model only when the heuristic
131
+ * was ambiguous. A high-confidence heuristic is trusted as-is. Returns the
132
+ * heuristic (with a `downgradeReason`) whenever the model is unavailable or
133
+ * answers out of contract, so escalation never blocks observation. Only
134
+ * successful LLM resolutions are cached (a transient downgrade re-attempts next
135
+ * run).
136
+ */
137
+ export async function escalateHeader(candidate, options) {
138
+ const heuristic = {
139
+ sheetName: candidate.sheetName,
140
+ headerRowIndex: candidate.heuristic.headerRowIndex,
141
+ confidence: candidate.heuristic.confidence,
142
+ source: "heuristic",
143
+ };
144
+ if (candidate.heuristic.confidence === "high")
145
+ return heuristic;
146
+ const prompt = renderHeaderPrompt(candidate);
147
+ const promptHash = shortHash(prompt);
148
+ const cacheKey = headerEscalationCacheKey({
149
+ contentSha256: options.contentSha256,
150
+ extractorVersion: SPREADSHEET_OBSERVER_ADAPTER_VERSION,
151
+ triggerVersion: HEADER_ESCALATION_TRIGGER_VERSION,
152
+ promptHash,
153
+ provider: options.model.provider,
154
+ modelId: options.model.id,
155
+ effort: options.model.effort,
156
+ dataLayerCapsHash: options.dataLayerCapsHash,
157
+ sheetName: candidate.sheetName,
158
+ });
159
+ // The cache is an optimization, never a gate: a durable backing store that
160
+ // throws on read must not block resolution.
161
+ const cached = cacheGet(options.cache, cacheKey);
162
+ if (cached)
163
+ return cached;
164
+ let raw;
165
+ try {
166
+ raw = await callLlmWithTimeout(options.llm, { prompt, promptHash }, options.timeoutMs);
167
+ }
168
+ catch {
169
+ return { ...heuristic, downgradeReason: "llm_unavailable" };
170
+ }
171
+ // Validate against the rendered window, not the full row count: the model can
172
+ // only legitimately pick a row it was shown.
173
+ const windowRowCount = Math.min(candidate.rows.length, ESCALATION_ROW_WINDOW);
174
+ const submission = parseHeaderSubmission(raw, windowRowCount);
175
+ if (!submission) {
176
+ return { ...heuristic, downgradeReason: "invalid_submission" };
177
+ }
178
+ const resolved = {
179
+ sheetName: candidate.sheetName,
180
+ headerRowIndex: submission.header_row_index,
181
+ confidence: "high",
182
+ source: "llm",
183
+ };
184
+ // A cache write failure (full disk, permissions) must not reject a valid
185
+ // resolution — replay support cannot change resolution availability.
186
+ cacheSet(options.cache, cacheKey, resolved);
187
+ return resolved;
188
+ }
189
+ function cacheGet(cache, key) {
190
+ try {
191
+ return cache?.get(key);
192
+ }
193
+ catch {
194
+ return undefined;
195
+ }
196
+ }
197
+ function cacheSet(cache, key, value) {
198
+ try {
199
+ cache?.set(key, value);
200
+ }
201
+ catch {
202
+ // non-fatal: the resolution is still returned.
203
+ }
204
+ }