@davesheffer/hunch 1.45.2 → 1.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/dist/cli/index.js +290 -353
  2. package/dist/cli/invocation.d.ts +22 -13
  3. package/dist/cli/invocation.js +49 -29
  4. package/dist/cli/verify.d.ts +28 -0
  5. package/dist/cli/verify.js +265 -0
  6. package/dist/constitution/delta.js +2 -2
  7. package/dist/constitution/experiment.d.ts +1 -1
  8. package/dist/constitution/sourceMutation.js +28 -18
  9. package/dist/core/agenthook.js +26 -6
  10. package/dist/core/boundedSpawn.d.ts +57 -0
  11. package/dist/core/boundedSpawn.js +321 -0
  12. package/dist/core/commitrepair.d.ts +43 -138
  13. package/dist/core/commitrepair.js +76 -167
  14. package/dist/core/config.d.ts +6 -0
  15. package/dist/core/config.js +1 -0
  16. package/dist/core/correction.js +9 -9
  17. package/dist/core/delivery.d.ts +6 -1
  18. package/dist/core/delivery.js +71 -5
  19. package/dist/core/docanchors.js +9 -9
  20. package/dist/core/drift.js +22 -27
  21. package/dist/core/escalations.d.ts +16 -58
  22. package/dist/core/escalations.js +39 -121
  23. package/dist/core/glob.d.ts +7 -7
  24. package/dist/core/glob.js +12 -11
  25. package/dist/core/groundingLag.d.ts +2 -9
  26. package/dist/core/groundingLag.js +5 -11
  27. package/dist/core/groundingMerge.d.ts +2 -7
  28. package/dist/core/groundingMerge.js +35 -54
  29. package/dist/core/migrate.d.ts +10 -1
  30. package/dist/core/migrate.js +17 -6
  31. package/dist/core/paths.d.ts +17 -24
  32. package/dist/core/paths.js +37 -43
  33. package/dist/core/projectDna.js +11 -1
  34. package/dist/core/refrepair.d.ts +2 -1
  35. package/dist/core/refrepair.js +4 -4
  36. package/dist/core/relativeImports.js +6 -2
  37. package/dist/core/repairqueue.d.ts +17 -34
  38. package/dist/core/repairqueue.js +48 -98
  39. package/dist/core/taskSelection.js +48 -6
  40. package/dist/core/testSelection.d.ts +122 -0
  41. package/dist/core/testSelection.js +588 -0
  42. package/dist/core/types.d.ts +3 -3
  43. package/dist/core/types.js +1 -1
  44. package/dist/extractors/diff.d.ts +4 -4
  45. package/dist/extractors/diff.js +25 -29
  46. package/dist/extractors/git.d.ts +8 -23
  47. package/dist/extractors/git.js +77 -95
  48. package/dist/extractors/helm.d.ts +10 -0
  49. package/dist/extractors/helm.js +43 -59
  50. package/dist/extractors/indexer.d.ts +6 -0
  51. package/dist/extractors/indexer.js +168 -184
  52. package/dist/extractors/k8sManifest.d.ts +23 -80
  53. package/dist/extractors/k8sManifest.js +319 -690
  54. package/dist/extractors/languages.d.ts +37 -37
  55. package/dist/extractors/languages.js +83 -54
  56. package/dist/extractors/nativeTreeSitter.d.ts +4 -4
  57. package/dist/extractors/nativeTreeSitter.js +17 -14
  58. package/dist/extractors/parse.d.ts +27 -13
  59. package/dist/extractors/parse.js +120 -56
  60. package/dist/extractors/workspaces.js +3 -1
  61. package/dist/integrations/claudemd.js +1 -0
  62. package/dist/integrations/gitignore.js +3 -5
  63. package/dist/integrations/health.d.ts +6 -0
  64. package/dist/integrations/health.js +33 -12
  65. package/dist/integrations/hooks.d.ts +13 -16
  66. package/dist/integrations/hooks.js +31 -30
  67. package/dist/integrations/mergeDriver.js +18 -17
  68. package/dist/integrations/optout.d.ts +3 -0
  69. package/dist/integrations/optout.js +17 -0
  70. package/dist/integrations/providers.d.ts +6 -2
  71. package/dist/integrations/providers.js +19 -2
  72. package/dist/integrations/team.js +4 -2
  73. package/dist/integrations/uninstall.d.ts +10 -4
  74. package/dist/integrations/uninstall.js +22 -18
  75. package/dist/mcp/server.d.ts +4 -5
  76. package/dist/mcp/server.js +107 -81
  77. package/dist/serve/app.js +9 -1
  78. package/dist/serve/config.js +5 -0
  79. package/dist/serve/writelock.d.ts +3 -1
  80. package/dist/serve/writelock.js +17 -3
  81. package/dist/store/hunchStore.d.ts +2 -1
  82. package/dist/store/hunchStore.js +20 -23
  83. package/dist/store/jsonStore.d.ts +13 -3
  84. package/dist/store/jsonStore.js +37 -13
  85. package/dist/store/stateBinding.js +17 -4
  86. package/dist/synthesis/provider.d.ts +19 -21
  87. package/dist/synthesis/provider.js +93 -154
  88. package/dist/synthesis/synthesize.d.ts +8 -8
  89. package/dist/synthesis/synthesize.js +33 -50
  90. package/package.json +3 -3
  91. package/server.json +2 -2
@@ -1341,8 +1341,8 @@ export class HunchStore {
1341
1341
  * instant — "what did we believe as of commit X?". Omit `asOf` for the full,
1342
1342
  * history-inclusive view (backward-compatible default). */
1343
1343
  why(target, opts = {}) {
1344
- // An absolute target (an agent's edit-payload path, verbatim) never matched
1345
- // the repo-relative stored file paths below (issue #299).
1344
+ // An agent's verbatim edit-payload path is absolute; stored paths are
1345
+ // repo-relative, so rewrite before any matching (issue #299).
1346
1346
  target = repoRelativeTarget(target, this.paths.root);
1347
1347
  // A record id (every delivered brief line ends in `hunch_why("dec_…")`) resolves
1348
1348
  // to that record directly (issue #474). With `asOf`, a record not in force then
@@ -1350,8 +1350,10 @@ export class HunchStore {
1350
1350
  const lookup = WHY_RECORD_ID.test(target) ? this.whyRecord(target, opts.canRead, opts.asOf) : undefined;
1351
1351
  if (lookup?.record)
1352
1352
  return { target, decisions: [], bugs: [], constraints: [], symbols: [], components: [], byId: lookup };
1353
- // A miss is reported only for a generated-shape id; anything else resolves as before.
1354
- const byId = lookup && WHY_GENERATED_ID.test(target) ? lookup : undefined;
1353
+ // A miss is reported for a generated-shape id, or for any id on file that was not
1354
+ // in force at `asOf` (issue #493); anything else resolves as before.
1355
+ const byId = lookup && (WHY_GENERATED_ID.test(target) || (opts.asOf && this.whyRecord(target, opts.canRead).record))
1356
+ ? lookup : undefined;
1355
1357
  const decisions = this.recs("decisions").filter(opts.canRead ?? (() => true));
1356
1358
  const bugs = this.recs("bugs").filter(opts.canRead ?? (() => true));
1357
1359
  const constraints = this.recs("constraints").filter(opts.canRead ?? (() => true));
@@ -1362,11 +1364,11 @@ export class HunchStore {
1362
1364
  // so an unanchored suffix pulled unrelated files' records into why()/the
1363
1365
  // pre-edit grounding block (issue #32). Segment-anchored matching only.
1364
1366
  //
1365
- // A target that IS a path Hunch's index already knows about (README.md,
1366
- // vscode-extension/index.ts, ...) names exactly one file — suffix-matching
1367
- // it too pulls in every OTHER file that merely shares a basename (issue
1368
- // #299: root index.ts delivered vscode-extension/index.ts's rules and vice
1369
- // versa). "Known to the index" is answered from already-loaded graph data
1367
+ // A target that is a path the index already knows (README.md,
1368
+ // `vscode-extension/index.ts`) names exactly ONE file. Suffix-matching it too
1369
+ // would pull in every other file sharing its basename (issue #299: root
1370
+ // `index.ts` received `vscode-extension/index.ts`'s rules and vice versa). "Known
1371
+ // to the index" is answered from already-loaded graph data, looked up
1370
1372
  // first (a symbol's exact file, or an indexed component's path glob — so a
1371
1373
  // symbol-less indexed file still counts as real), and only then from the
1372
1374
  // working tree, for a real file the index cannot see at all (issue #334).
@@ -1446,7 +1448,8 @@ export class HunchStore {
1446
1448
  }
1447
1449
  /** Transitive blast radius: every symbol/component that (in)directly depends on
1448
1450
  * `id`, via a recursive CTE over the edges graph (hunch_get_dependents). We
1449
- * walk edges BACKWARD (edges.to = current) following call/dep/import/contains. */
1451
+ * walk edges BACKWARD (edges.to = current) following call/dep/import/contains,
1452
+ * plus `references` — the only edge YAML/Helm/Kustomize emit (issue #486). */
1450
1453
  getDependents(id, maxDepth = 6) {
1451
1454
  return this.db.prepare(
1452
1455
  /* sql */ `
@@ -1455,7 +1458,7 @@ export class HunchStore {
1455
1458
  UNION
1456
1459
  SELECT e."from", up.depth + 1
1457
1460
  FROM edges e JOIN up ON e."to" = up.node
1458
- WHERE e.type IN ('calls','depends_on','imports','contains','implements') AND up.depth < ?
1461
+ WHERE e.type IN ('calls','depends_on','imports','contains','implements','references') AND up.depth < ?
1459
1462
  )
1460
1463
  SELECT up.node AS id, MIN(up.depth) AS depth,
1461
1464
  COALESCE(s.name || ' @ ' || s.file, c.name, up.node) AS via
@@ -1497,7 +1500,7 @@ export class HunchStore {
1497
1500
  UNION
1498
1501
  SELECT e."from", up.depth + 1
1499
1502
  FROM edges e JOIN up ON e."to" = up.node
1500
- WHERE e.type IN ('calls','depends_on','imports','contains','implements') AND up.depth < ?
1503
+ WHERE e.type IN ('calls','depends_on','imports','contains','implements','references') AND up.depth < ?
1501
1504
  )
1502
1505
  SELECT s.file AS file, s.name AS via, MIN(up.depth) AS depth
1503
1506
  FROM up JOIN symbols s ON s.id = up.node
@@ -1693,9 +1696,8 @@ export class HunchStore {
1693
1696
  * longer enforced. Pass `{ asOf }` to instead return the invariants in force at
1694
1697
  * that instant (time-travel: "what must I not have broken as of commit X?"). */
1695
1698
  checkConstraints(scope, opts = {}) {
1696
- // Constraint scopes are always repo-relative; an absolute target (an
1697
- // agent's edit-payload path, verbatim) matched nothing even when a
1698
- // blocking rule plainly applied (issue #296).
1699
+ // Scopes are repo-relative; an absolute target matched nothing even when a
1700
+ // blocking rule applied (issue #296).
1699
1701
  scope = repoRelativeTarget(scope, this.paths.root);
1700
1702
  const all = this.recs("constraints");
1701
1703
  const asOf = opts.asOf;
@@ -1710,11 +1712,8 @@ export class HunchStore {
1710
1712
  * glob, and the queried scope may be either too. Advisory only — findings never
1711
1713
  * enter any block path. Sorted worst-first, then id for stable output. */
1712
1714
  liveFindingsFor(scope) {
1713
- // Same absolute-target and same-basename-leak gaps as why() (issue #299): an
1714
- // un-rewritten absolute scope never matched the repo-relative `affected_files`
1715
- // below, and an un-guarded suffix fallback could pull in an unrelated file
1716
- // that merely shares a basename. Normalize once and reuse the same "is this
1717
- // path known to the index" guard.
1715
+ // Same absolute-target and same-basename-leak gaps as why() (issue #299):
1716
+ // normalize once and reuse the known-path guard.
1718
1717
  const t = repoRelativeTarget(scope, this.paths.root);
1719
1718
  const indexed = this.isKnownPath(t);
1720
1719
  const live = (f) => f.triage === "open" || f.triage === "accepted-risk" || f.triage === "scheduled";
@@ -2337,9 +2336,7 @@ export class HunchStore {
2337
2336
  * a task on `target`, ordered by what matters most — invariants first, then the
2338
2337
  * why, then blast radius and bug history — trimmed to a rough token budget. */
2339
2338
  assembleContext(target, budget = 1500, opts = {}) {
2340
- // An absolute target (an agent's edit-payload path, verbatim) must be
2341
- // repo-relative BEFORE it reaches why()/liveFindingsFor() below, or an
2342
- // absolute path never matches the repo-relative stored data (issue #299).
2339
+ // Normalize before it reaches why()/liveFindingsFor() below (issue #299).
2343
2340
  target = repoRelativeTarget(target, this.paths.root);
2344
2341
  const w = this.why(target, opts);
2345
2342
  const symIds = w.symbols.map((s) => s.id);
@@ -126,12 +126,22 @@ export declare class JsonStore {
126
126
  * overlay. Returns the number of files removed. Invalidates the memoized load. */
127
127
  dropAll(kind: EntityKind): number;
128
128
  /** Persist a schema migration: rewrite every LOADABLE record in its current shape.
129
- * A record that still fails validation after migration is kept untouched (never
130
- * deleted) and counted as `skipped`, so migration can't lose data. The caller
131
- * bumps the manifest afterward. */
129
+ * A record that still fails validation after migration (or whose migration
130
+ * throws) is kept in its ORIGINAL shape (never deleted, never half-migrated) and
131
+ * counted as `skipped`, so migration can't lose data. The caller stamps the
132
+ * manifest at the returned `version`: SCHEMA_VERSION only when nothing was
133
+ * skipped, else the FROM version, so a manual fix plus a rerun re-applies the
134
+ * (idempotent) migrations to the skipped records instead of skipping them.
135
+ * A bad record of a kind NO pending migration touches can't be fixed by a
136
+ * rerun, so it is counted as `invalid` instead and doesn't hold the version. */
132
137
  persistMigration(): {
133
138
  migrated: number;
134
139
  skipped: number;
140
+ invalid: number;
141
+ version: number;
135
142
  };
143
+ /** Migrate + validate one raw record for persistMigration; a throwing migration
144
+ * step counts as a failure, so the record stays in its original shape. */
145
+ private tryMigrate;
136
146
  }
137
147
  export {};
@@ -8,7 +8,7 @@ import { randomBytes } from "node:crypto";
8
8
  import { hostname } from "node:os";
9
9
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
10
10
  import { ENTITY_KINDS, SCHEMAS } from "../core/types.js";
11
- import { BASELINE_VERSION, migrateRaw, SCHEMA_VERSION } from "../core/migrate.js";
11
+ import { BASELINE_VERSION, hasPendingMigration, migrateRaw, SCHEMA_VERSION } from "../core/migrate.js";
12
12
  import { writeFileAtomic } from "../core/io.js";
13
13
  import { readStoreArtifact } from "../core/storeArtifact.js";
14
14
  import { createdAfterLock, heldLockNonces, judgeSameHostOwner, processStartedAt, selfStartToken, startTokenOf } from "../core/procstart.js";
@@ -942,19 +942,29 @@ export class JsonStore {
942
942
  return names.length;
943
943
  }
944
944
  /** Persist a schema migration: rewrite every LOADABLE record in its current shape.
945
- * A record that still fails validation after migration is kept untouched (never
946
- * deleted) and counted as `skipped`, so migration can't lose data. The caller
947
- * bumps the manifest afterward. */
945
+ * A record that still fails validation after migration (or whose migration
946
+ * throws) is kept in its ORIGINAL shape (never deleted, never half-migrated) and
947
+ * counted as `skipped`, so migration can't lose data. The caller stamps the
948
+ * manifest at the returned `version`: SCHEMA_VERSION only when nothing was
949
+ * skipped, else the FROM version, so a manual fix plus a rerun re-applies the
950
+ * (idempotent) migrations to the skipped records instead of skipping them.
951
+ * A bad record of a kind NO pending migration touches can't be fixed by a
952
+ * rerun, so it is counted as `invalid` instead and doesn't hold the version. */
948
953
  persistMigration() {
949
954
  let migrated = 0;
950
955
  let skipped = 0;
956
+ let invalid = 0;
951
957
  this.cache.clear(); // every record is rewritten; drop all memoized loads
952
958
  const version = this.schemaVersion(); // read once; we're migrating FROM this
953
959
  for (const kind of ENTITY_KINDS) {
954
960
  const directory = this.safeKindDirectory(kind, false);
955
961
  if (!directory)
956
962
  continue;
957
- const schema = SCHEMAS[kind];
963
+ const pending = hasPendingMigration(kind, version);
964
+ const fail = () => { if (pending)
965
+ skipped++;
966
+ else
967
+ invalid++; };
958
968
  const single = SINGLE_FILE[kind];
959
969
  if (single) {
960
970
  const f = join(directory.lexical, single);
@@ -966,23 +976,23 @@ export class JsonStore {
966
976
  arr = JSON.parse(text);
967
977
  }
968
978
  catch {
969
- skipped++;
979
+ fail();
970
980
  continue;
971
981
  }
972
982
  if (!Array.isArray(arr)) {
973
- skipped++;
983
+ fail();
974
984
  continue;
975
985
  }
976
986
  const kept = [];
977
987
  for (const raw of arr) {
978
- const r = schema.safeParse(this.migrate(kind, raw, version));
988
+ const r = this.tryMigrate(kind, raw, version);
979
989
  if (r.success) {
980
990
  kept.push(r.data);
981
991
  migrated++;
982
992
  }
983
993
  else {
984
994
  kept.push(raw); // preserve unmigratable records rather than drop them
985
- skipped++;
995
+ fail();
986
996
  }
987
997
  }
988
998
  this.writeContainedFile(directory, f, encode(kept), this.maxBytes(kind));
@@ -995,24 +1005,38 @@ export class JsonStore {
995
1005
  const text = this.readContainedFile(directory, p, this.maxBytes(kind));
996
1006
  if (text === null)
997
1007
  continue;
1008
+ // A 0-byte merge-driver tombstone holds no record (issue #37) — the
1009
+ // loader's rule; counting it would pin the manifest version forever.
1010
+ if (text.trim() === "")
1011
+ continue;
998
1012
  raw = JSON.parse(text);
999
1013
  }
1000
1014
  catch {
1001
- skipped++;
1015
+ fail();
1002
1016
  continue;
1003
1017
  }
1004
- const r = schema.safeParse(this.migrate(kind, raw, version));
1018
+ const r = this.tryMigrate(kind, raw, version);
1005
1019
  if (r.success) {
1006
1020
  this.writeContainedFile(directory, p, encode(r.data), this.maxBytes(kind));
1007
1021
  migrated++;
1008
1022
  }
1009
1023
  else {
1010
- skipped++; // leave the file as-is
1024
+ fail(); // leave the file as-is
1011
1025
  }
1012
1026
  }
1013
1027
  }
1014
1028
  }
1015
- return { migrated, skipped };
1029
+ return { migrated, skipped, invalid, version: skipped ? version : SCHEMA_VERSION };
1030
+ }
1031
+ /** Migrate + validate one raw record for persistMigration; a throwing migration
1032
+ * step counts as a failure, so the record stays in its original shape. */
1033
+ tryMigrate(kind, raw, version) {
1034
+ try {
1035
+ return SCHEMAS[kind].safeParse(this.migrate(kind, raw, version));
1036
+ }
1037
+ catch {
1038
+ return { success: false };
1039
+ }
1016
1040
  }
1017
1041
  }
1018
1042
  //# sourceMappingURL=jsonStore.js.map
@@ -795,15 +795,21 @@ function writeStateAuthorized(store, input, opts, access) {
795
795
  // land its `superseded` event in THIS ledger, invisible to that partition's subscribers.
796
796
  if (!inScope(supersededRecord))
797
797
  throw new StateRefusal("conflict", `supersedes ${request.supersedes} is on record in ${scopePath(recordScope(supersededRecord, repo))}, not ${scopePath(request.scope)}: a write supersedes only a record in its own partition`, { incumbent_id: String(supersededRecord.id), reason: "supersede target in another partition" });
798
+ const matches = typeof request.expected_version === 'number'
799
+ ? latestSeqFor(ledger, String(supersededRecord.id)) === request.expected_version
800
+ : request.expected_version === stateHash(supersededRecord);
798
801
  if (stateHash(supersededVisibility ?? null) !== stateHash(nextVisibility ?? null)) {
799
802
  if (supersededVisibility ? supersededVisibility.owner !== request.principal.id : request.principal.kind !== 'human')
800
803
  deny();
801
- const matches = typeof request.expected_version === 'number'
802
- ? latestSeqFor(readLedger(hunchDir, request.scope), String(supersededRecord.id)) === request.expected_version
803
- : request.expected_version === stateHash(supersededRecord);
804
- if (!matches)
804
+ if (request.expected_version === null)
805
805
  throw new StateRefusal('malformed', 'changing visibility during supersession requires the predecessor expected_version');
806
806
  }
807
+ // A supersession's expected_version names the predecessor (#510): a stale one is a conflict.
808
+ if (request.expected_version !== null && !matches)
809
+ throw new StateRefusal("conflict", `expected_version does not match the superseded ${supersededRecord.id}`, { incumbent_id: String(supersededRecord.id), reason: "expected_version mismatch" });
810
+ }
811
+ else if (request.supersedes && request.expected_version !== null) {
812
+ throw new StateRefusal("conflict", `expected_version names ${request.supersedes}, which is not on record`, { incumbent_id: request.supersedes, reason: "expected_version without incumbent" });
807
813
  }
808
814
  if (!incumbent && !supersededRecord && nextVisibility?.owner !== undefined && nextVisibility.owner !== request.principal.id)
809
815
  deny();
@@ -873,6 +879,13 @@ function writeStateAuthorized(store, input, opts, access) {
873
879
  if (!ok)
874
880
  throw new StateRefusal("conflict", `expected_version does not match the incumbent ${id}`, { incumbent_id: id, reason: "expected_version mismatch" });
875
881
  }
882
+ // Compare-and-swap with no incumbent (#510): a writer naming a version believes a record exists,
883
+ // so a fresh create under it is a stale read, never silently accepted. Numeric 0 is the ledger's
884
+ // "never seen" and asserts absence; a supersession's expected_version names its predecessor.
885
+ if (!existing && request.expected_version !== null && !request.supersedes
886
+ && !(typeof request.expected_version === "number" && latestSeqFor(ledger, id) === request.expected_version)) {
887
+ throw new StateRefusal("conflict", `expected_version ${JSON.stringify(request.expected_version)} names a version, but ${id} has none on record: pass null to create it (0 asserts the id was never written)`, { incumbent_id: id, reason: "expected_version without incumbent" });
888
+ }
876
889
  if (replayLink) {
877
890
  const recordHash = stateHash(replayLink);
878
891
  appendChanges(hunchDir, request.scope, missingEventFor(ledger, facet, replayLink, request.principal.id), { key: request.idempotency_key, entry: { record_id: replayLink.id, record_hash: recordHash, payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
@@ -111,8 +111,9 @@ export interface SynthProvider {
111
111
  }
112
112
  /** Every selectable synthesis mode. `auto` is a preference value rather than a
113
113
  * provider: it resolves the invocation's origin without choosing by availability.
114
- * "openai-compat" is the opt-in local/self-hosted HTTP provider (Ollama, vLLM,
115
- * LM Studio, ...) — not a subscription, but explicitly selectable like one. */
114
+ * "openai-compat" is the opt-in local or self-hosted HTTP provider (Ollama, vLLM,
115
+ * LM Studio and so on). It is not a subscription, but it can be selected
116
+ * explicitly like one. */
116
117
  export declare const SYNTH_PROVIDER_NAMES: readonly ["claude-cli", "codex-cli", "cursor-agent", "kimi-cli", "openai-compat", "deterministic"];
117
118
  export declare const SYNTH_PREFERENCES: readonly ["auto", "claude-cli", "codex-cli", "cursor-agent", "kimi-cli", "openai-compat", "deterministic"];
118
119
  export type SynthProviderName = (typeof SYNTH_PROVIDER_NAMES)[number];
@@ -172,8 +173,11 @@ export declare function safeModel(v: string | undefined, fallback: undefined): s
172
173
  * the explicit skip flag preserves that neutral-cwd isolation without loading a
173
174
  * target repo's agent rules or MCP configuration. */
174
175
  export declare function codexExecArgs(model?: string): string[];
176
+ /** Sanitize HUNCH_SYNTH_TIMEOUT_MS: a finite number > 0, else `fallback`. */
175
177
  export declare function safeTimeout(v: string | undefined, fallback: number): number;
178
+ /** Sanitize HUNCH_SYNTH_MAX_TOKENS: a finite number > 0, else `fallback`. */
176
179
  export declare function safeMaxTokens(v: string | undefined, fallback: number): number;
180
+ /** True only when the user explicitly opted in to public (possibly metered) hosts. */
177
181
  export declare function meteredHostsAllowed(env?: NodeJS.ProcessEnv): boolean;
178
182
  export declare class OpenAICompatProvider extends PromptSynthProvider {
179
183
  readonly name = "openai-compat";
@@ -182,9 +186,10 @@ export declare class OpenAICompatProvider extends PromptSynthProvider {
182
186
  }
183
187
  /** Best-effort: does the configured openai-compat endpoint look like Ollama with
184
188
  * an UNSET num_ctx? Returns
185
- * an advisory warning string when so, or null when the endpoint isn't reachable,
186
- * doesn't look like Ollama's /api/show shape, or already has num_ctx set — this
187
- * is diagnostics only, never thrown, never blocking. Deliberately does NOT try to
189
+ * a warning string when the model does not pin num_ctx, or null when the
190
+ * endpoint is unreachable, does not look like Ollama's /api/show response shape,
191
+ * or already has num_ctx set. Diagnostics only: it never throws and never blocks.
192
+ * Deliberately does NOT try to
188
193
  * report the model's effective context length: modern Ollama defaults may come
189
194
  * from server configuration or VRAM tiers, and model_info keys are not a stable
190
195
  * parse target. We therefore report only the observed fact — whether num_ctx is
@@ -203,19 +208,12 @@ export declare class DeterministicProvider implements SynthProvider {
203
208
  * tagged. If nothing parses, hand the raw output to the mapper (→ it finds the JSON
204
209
  * draft or throws → deterministic fallback). Tolerant by design: drift degrades, never crashes. */
205
210
  export declare function extractCodexText(out: string): string;
206
- /** Test-only: clears the availability memoization cache so a test that toggles
207
- * env vars mid-process (e.g. HUNCH_SYNTH_BASE_URL) isn't served a stale result
208
- * cached by an earlier call in the same process. Never call from production code. */
211
+ /** Test-only: empty the availability cache so a test that changes env vars
212
+ * mid-process is not served a stale cached result. */
209
213
  export declare function __resetAvailabilityCacheForTests(): void;
210
- /** "ollama" is accepted as an alias for "openai-compat" — the provider is not
211
- * Ollama-specific (it speaks the OpenAI chat-completions format any self-hosted
212
- * server can implement), but Ollama is the most common self-hosted target and
213
- * users reach for that name first. Applied to the HUNCH_SYNTH_PROVIDER env var in
214
- * resolveSynthesisProvider below, and exported so the `hunch provider <name>` CLI
215
- * command (index.ts) normalizes it the same way before validating/persisting a
216
- * local preference — the two paths must agree, or a user who sets one and reads
217
- * the other back gets a confusing "unknown provider" message for a name that
218
- * actually works. */
214
+ /** The provider is not Ollama-specific, but Ollama is the name users try first, so
215
+ * "ollama" is an alias for "openai-compat". The env var and `hunch provider <name>`
216
+ * both normalize through here so the two paths agree. */
219
217
  export declare function normalizeProviderName(v: string | undefined): string | undefined;
220
218
  /** Read a per-user, gitignored choice. Invalid/missing local state is treated as auto;
221
219
  * `writeSynthesisPreference` refuses to overwrite malformed data so this forgiveness
@@ -258,10 +256,10 @@ export declare class EnsembleProvider implements SynthProvider {
258
256
  export declare function selectEnsemble(opts?: ProviderSelectionOptions & {
259
257
  samples?: number;
260
258
  }): Promise<EnsembleProvider | null>;
261
- /** Pick a provider to run the Critic pass — the same resolved provider normal
262
- * synthesis would use (subscription CLI or the opt-in openai-compat endpoint),
263
- * honoring the same env/local-preference/auto policy. Returns null when that
264
- * resolves to the deterministic fallback — verification then no-ops and the
259
+ /** Pick the provider for the Critic pass: the same resolved provider normal
260
+ * synthesis would use (a subscription CLI or the opt-in openai-compat endpoint),
261
+ * under the same env / local-preference / auto policy. Returns null when that
262
+ * resolves to the deterministic fallback, in which case verification does nothing. The
265
263
  * un-audited draft stands (graceful degradation; dec_18a81c8291). */
266
264
  export declare function selectVerifier(opts?: ProviderSelectionOptions): Promise<SynthProvider | null>;
267
265
  /** Map model text → RelevanceVerdict, or null when nothing usable parses (→ the