@davesheffer/hunch 1.47.1 → 1.48.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.
@@ -242,6 +242,22 @@ function ascendToDef(node, defNodeTypes) {
242
242
  }
243
243
  return null;
244
244
  }
245
+ /** Index in `symbols` of the narrowest symbol whose [startByte, endByte) range
246
+ * holds `at`; on equal spans the earlier symbol wins. -1 when nothing encloses it. */
247
+ function innermostSymbolAt(symbols, at) {
248
+ let found = -1;
249
+ let narrowest = Infinity;
250
+ symbols.forEach((sym, i) => {
251
+ if (at < sym.startByte || at >= sym.endByte)
252
+ return;
253
+ const span = sym.endByte - sym.startByte;
254
+ if (span < narrowest) {
255
+ narrowest = span;
256
+ found = i;
257
+ }
258
+ });
259
+ return found;
260
+ }
245
261
  /** Map each call site to the innermost symbol whose byte-range contains it.
246
262
  * The result is keyed by the symbol's INDEX in `parsed.symbols`, not its start
247
263
  * byte: two distinct symbols can share a start byte (a synthetic whole-file root
@@ -252,17 +268,8 @@ function ascendToDef(node, defNodeTypes) {
252
268
  export function attributeCalls(parsed) {
253
269
  const out = new Map();
254
270
  for (const call of parsed.calls) {
255
- let best = null;
256
- let bestIndex = -1;
257
- for (let i = 0; i < parsed.symbols.length; i++) {
258
- const s = parsed.symbols[i];
259
- if (call.atByte >= s.startByte && call.atByte < s.endByte) {
260
- if (!best || s.endByte - s.startByte < best.endByte - best.startByte) {
261
- best = s;
262
- bestIndex = i;
263
- }
264
- }
265
- }
271
+ const bestIndex = innermostSymbolAt(parsed.symbols, call.atByte);
272
+ const best = bestIndex >= 0 ? parsed.symbols[bestIndex] : undefined;
266
273
  if (best && best.name !== call.callee) {
267
274
  let m = out.get(bestIndex);
268
275
  if (!m) {
@@ -305,15 +312,7 @@ export function attributeQualifiedCalls(parsed) {
305
312
  }
306
313
  if (!target)
307
314
  continue;
308
- let best = null;
309
- let bestIndex = -1;
310
- for (let i = 0; i < parsed.symbols.length; i++) {
311
- const s = parsed.symbols[i];
312
- if (call.atByte >= s.startByte && call.atByte < s.endByte && (!best || s.endByte - s.startByte < best.endByte - best.startByte)) {
313
- best = s;
314
- bestIndex = i;
315
- }
316
- }
315
+ const bestIndex = innermostSymbolAt(parsed.symbols, call.atByte);
317
316
  if (bestIndex < 0)
318
317
  continue;
319
318
  const list = out.get(bestIndex) ?? [];
@@ -329,16 +328,7 @@ export function attributeQualifiedCalls(parsed) {
329
328
  export function attributeRelations(parsed) {
330
329
  const out = new Map();
331
330
  for (const relation of parsed.relations) {
332
- let best = null;
333
- let bestIndex = -1;
334
- for (let index = 0; index < parsed.symbols.length; index++) {
335
- const symbol = parsed.symbols[index];
336
- if (relation.atByte >= symbol.startByte && relation.atByte < symbol.endByte
337
- && (!best || symbol.endByte - symbol.startByte < best.endByte - best.startByte)) {
338
- best = symbol;
339
- bestIndex = index;
340
- }
341
- }
331
+ const bestIndex = innermostSymbolAt(parsed.symbols, relation.atByte);
342
332
  if (bestIndex < 0)
343
333
  continue;
344
334
  const list = out.get(bestIndex) ?? [];
@@ -609,14 +609,16 @@ function sharedInvocationFor(root, mark, invocation, own) {
609
609
  }
610
610
  return null;
611
611
  }
612
- /** Shared idempotent create/append/update-in-place logic for every hunch git
613
- * hook: write a fresh hook file, replace our own managed block in place if the
614
- * invocation changed, or append after any pre-existing (non-hunch) hook body
615
- * without clobbering it. Used by all hook installers below — the copies had
616
- * already drifted (installPreCommitHook was missing the chmodSync on its
617
- * "updated" path) before this was unified. When a hook manager owns the file,
618
- * or the block would sit after an exec/exit, nothing is written and the result
619
- * carries the snippet to add by hand (issue #311). */
612
+ /** The one idempotent install path every Hunch git hook goes through: create the
613
+ * hook file when there is none; rewrite our own managed block in place when its
614
+ * invocation moved; otherwise append the block after whatever non-Hunch body the
615
+ * file already holds, never clobbering it. Each installer used to carry its own
616
+ * copy of this, and the copies drifted (one forgot to chmod the file on its
617
+ * update path), hence a single shared implementation.
618
+ *
619
+ * When a hook manager owns the file, or the block would land after an `exec` /
620
+ * `exit` that stops it from ever running, nothing is written: the result
621
+ * carries the snippet for the user to add by hand instead (issue #311). */
620
622
  function installManagedBlock(root, hookName, mark, end, build, invocation) {
621
623
  // A linked worktree whose branch predates the opt-out marker shares the main
622
624
  // checkout's hooks dir: never re-hook a repository `hunch uninstall` opted out (#488).
@@ -281,10 +281,11 @@ export declare class HunchStore {
281
281
  total: number;
282
282
  }>;
283
283
  /** Post-fusion rerank by graph PRIORS (dec_25e277f479): relevance ordering, not
284
- * just reachability. Trust weight w = liveness × provenance × recency: liveness 0.6
285
- * for superseded/retired/rejected, provenance 1.0 / 0.85 / 0.75 for
286
- * human_confirmed / llm_draft / extracted-inferred, recency 0.7 + 0.3·½^(age/90d),
287
- * and proven anchored-file staleness 0.8. Every factor is ranking-only.
284
+ * just reachability. Trust weight w = provenance × recency: provenance 1.0 / 0.9 /
285
+ * 0.85 / 0.75 for human_confirmed / agent_recorded / llm_draft / extracted-inferred,
286
+ * recency 0.7 + 0.3·½^(age/90d), and proven anchored-file staleness 0.8. Liveness
287
+ * (superseded/retired/rejected) is a separate DEAD_PRIOR_SHIFT, and a superseded
288
+ * decision always lands just below its live successor. Every factor is ranking-only.
288
289
  * Runbook trigger phrases matching the query boost ×1.5 (exact intent beats
289
290
  * keyword luck). Structural refs (symbols/components/edges) stay neutral.
290
291
  *
@@ -302,10 +303,13 @@ export declare class HunchStore {
302
303
  * they were the #1 fused hit, and 88% from fused rank 8. Both retrieval layers
303
304
  * ranked the record first and the rerank alone threw it away.
304
305
  *
305
- * A shift of at most ±MAX_PRIOR_SHIFT positions restores the intended semantics:
306
+ * A shift of at most ±MAX_PRIOR_SHIFT positions (+DEAD_PRIOR_SHIFT for history)
307
+ * restores the intended semantics:
306
308
  * a stale or low-provenance record visibly dims, an exact runbook-trigger match
307
309
  * visibly promotes, and neither can leapfrog the whole pool. Same measurement
308
- * after: 0% evicted from fused ranks 0–8. Deterministic; ties keep fused order. */
310
+ * after: 0% of live records evicted from fused ranks 0–8 (a dead record is meant to
311
+ * sink; only from fused #0/#1 is it guaranteed to stay on an 8-hit page).
312
+ * Deterministic; ties keep fused order. */
309
313
  private rerankByPriors;
310
314
  /**
311
315
  * Score only freshness that the existing graph clocks can prove: an anchored path changed after
@@ -24,7 +24,7 @@ import { cochangeFor } from "../core/cochange.js";
24
24
  import { withServedDatabase } from "../core/served.js";
25
25
  import { normalizePath, rankTaskRecords, recordIdsOf, selectLatestTasks, selectTaskSlots } from "../core/taskRanking.js";
26
26
  import { resolveTaskRankingMode } from "../core/taskRankingMode.js";
27
- import { currentForTopic, isInForce } from "../core/topics.js";
27
+ import { currentForTopic, isInForce, isLive, liveForTopic } from "../core/topics.js";
28
28
  import { edgeId } from "../core/ids.js";
29
29
  import { WHY_GENERATED_ID, WHY_RECORD_ID } from "../core/whyRecord.js";
30
30
  import { isStrictBlocker, isVetoBlocker } from "../core/strictgate.js";
@@ -878,10 +878,11 @@ export class HunchStore {
878
878
  return { embedded, skipped: docs.length - todo.length, total: docs.length };
879
879
  }
880
880
  /** Post-fusion rerank by graph PRIORS (dec_25e277f479): relevance ordering, not
881
- * just reachability. Trust weight w = liveness × provenance × recency: liveness 0.6
882
- * for superseded/retired/rejected, provenance 1.0 / 0.85 / 0.75 for
883
- * human_confirmed / llm_draft / extracted-inferred, recency 0.7 + 0.3·½^(age/90d),
884
- * and proven anchored-file staleness 0.8. Every factor is ranking-only.
881
+ * just reachability. Trust weight w = provenance × recency: provenance 1.0 / 0.9 /
882
+ * 0.85 / 0.75 for human_confirmed / agent_recorded / llm_draft / extracted-inferred,
883
+ * recency 0.7 + 0.3·½^(age/90d), and proven anchored-file staleness 0.8. Liveness
884
+ * (superseded/retired/rejected) is a separate DEAD_PRIOR_SHIFT, and a superseded
885
+ * decision always lands just below its live successor. Every factor is ranking-only.
885
886
  * Runbook trigger phrases matching the query boost ×1.5 (exact intent beats
886
887
  * keyword luck). Structural refs (symbols/components/edges) stay neutral.
887
888
  *
@@ -899,40 +900,46 @@ export class HunchStore {
899
900
  * they were the #1 fused hit, and 88% from fused rank 8. Both retrieval layers
900
901
  * ranked the record first and the rerank alone threw it away.
901
902
  *
902
- * A shift of at most ±MAX_PRIOR_SHIFT positions restores the intended semantics:
903
+ * A shift of at most ±MAX_PRIOR_SHIFT positions (+DEAD_PRIOR_SHIFT for history)
904
+ * restores the intended semantics:
903
905
  * a stale or low-provenance record visibly dims, an exact runbook-trigger match
904
906
  * visibly promotes, and neither can leapfrog the whole pool. Same measurement
905
- * after: 0% evicted from fused ranks 0–8. Deterministic; ties keep fused order. */
907
+ * after: 0% of live records evicted from fused ranks 0–8 (a dead record is meant to
908
+ * sink; only from fused #0/#1 is it guaranteed to stay on an 8-hit page).
909
+ * Deterministic; ties keep fused order. */
906
910
  rerankByPriors(hits, limit, query, freshnessRoot = this.paths.root) {
907
911
  if (!hits.length)
908
912
  return hits; // a SINGLE hit still runs — topic-chain promotion must fire for the lone stale match
909
913
  const now = Date.now();
910
914
  const q = query?.toLowerCase();
911
- // Topic-chain promotion: for every superseded decision in the pool whose topic
912
- // has a live successor NOT already in the pool, inject the successor at the
913
- // predecessor's rank — the reader asked about the topic, and the graph's one
914
- // live answer must be reachable even when only history matches lexically.
915
+ const decisionsById = new Map(this.recs("decisions").map((decision) => [decision.id, decision]));
916
+ // Successor promotion: for every superseded decision in the pool whose live
917
+ // successor (its topic's current decision, or the open end of its superseded_by
918
+ // chain) is NOT already in the pool, inject the successor — the reader asked about
919
+ // the subject, and the graph's one live answer must be reachable even when only
920
+ // history matches lexically. Where it lands is settled after scoring, below.
915
921
  const present = new Set(hits.map((h) => h.ref));
916
- // Each candidate carries its fused POSITION; an injected successor inherits its
917
- // predecessor's position + a half step, so it lands immediately after it and an
918
- // exact-match live record already in the pool is never displaced by an injection.
919
922
  const pool = hits.map((h, i) => ({ h, pos: i }));
920
- for (const [i, h] of hits.entries()) {
923
+ const successorOf = new Map(); // superseded ref -> live successor ref
924
+ for (const h of hits) {
921
925
  if (h.kind !== "decisions")
922
926
  continue;
923
- const d = this.recs("decisions").find((x) => x.id === h.ref);
924
- if (!d?.topic || !(d.status === "superseded" || d.superseded_by))
927
+ const d = decisionsById.get(h.ref);
928
+ if (!d || !(d.status === "superseded" || d.superseded_by))
929
+ continue;
930
+ const succ = liveSuccessor(d, decisionsById, this.recs("decisions"));
931
+ const cur = succ?.decision;
932
+ if (!succ || !cur || cur.id === h.ref)
925
933
  continue;
926
- const cur = currentForTopic(this.recs("decisions"), d.topic);
927
- if (!cur || cur.id === h.ref || present.has(cur.id))
934
+ successorOf.set(h.ref, cur.id);
935
+ if (present.has(cur.id))
928
936
  continue;
929
937
  present.add(cur.id);
930
938
  pool.push({
931
- h: { ref: cur.id, kind: "decisions", title: cur.title, snippet: `current for topic "${d.topic}" (supersedes ${h.ref})`, score: h.score },
932
- pos: i + 0.5,
939
+ h: { ref: cur.id, kind: "decisions", title: cur.title, snippet: succ.via === "topic" ? `current for topic "${d.topic}" (supersedes ${h.ref})` : `live end of the supersede chain from ${h.ref}`, score: h.score },
940
+ pos: Infinity, // it matched nothing itself: placed only by its predecessor, below
933
941
  });
934
942
  }
935
- const decisionsById = new Map(this.recs("decisions").map((decision) => [decision.id, decision]));
936
943
  const staleDecisionIds = this.staleDecisionIds(pool.filter(({ h }) => h.kind === "decisions")
937
944
  .map(({ h }) => decisionsById.get(h.ref))
938
945
  .filter((decision) => !!decision), freshnessRoot);
@@ -940,8 +947,6 @@ export class HunchStore {
940
947
  const m = this.priorMeta(h.ref, h.kind);
941
948
  let w = 1;
942
949
  if (m) {
943
- if (m.dead)
944
- w *= 0.6;
945
950
  // agent_recorded sits BETWEEN human_confirmed and llm_draft. It is testimony —
946
951
  // a human directed the capture but did not countersign it through /capture — so
947
952
  // it must not carry human authority (strict/veto gates key on human_confirmed).
@@ -965,14 +970,45 @@ export class HunchStore {
965
970
  if (q && m.triggers?.some((tr) => q.includes(tr) || tr.includes(q)))
966
971
  w *= 1.5;
967
972
  }
968
- // w > 1 (a trigger match) shifts UP, w < 1 shifts DOWN, both clamped.
969
- const shift = Math.max(-MAX_PRIOR_SHIFT, Math.min(MAX_PRIOR_SHIFT, (1 - w) * PRIOR_SHIFT_SCALE));
973
+ // w > 1 (a trigger match) shifts UP, w < 1 shifts DOWN, both clamped. A dead record
974
+ // (superseded/retired/rejected) takes the full bound PLUS DEAD_PRIOR_SHIFT: folded
975
+ // into w, liveness was a no-op exactly where it mattered — an old or non-human
976
+ // live record already reaches the clamp on provenance × recency, so its superseded
977
+ // twin landed at the same position and fused order alone decided which one
978
+ // answered "what is the rule now". Never below the bound, so a dead memory record
979
+ // cannot leapfrog any live memory record that fused ahead of it.
980
+ const shift = m?.dead ? MAX_PRIOR_SHIFT + DEAD_PRIOR_SHIFT
981
+ : Math.max(-MAX_PRIOR_SHIFT, Math.min(MAX_PRIOR_SHIFT, (1 - w) * PRIOR_SHIFT_SCALE));
970
982
  // Recorded intent outranks code that merely shares the query's vocabulary. Applied
971
983
  // OUTSIDE the clamp above: that bound keeps the trust dimmer from becoming the sort
972
984
  // key, whereas this is a kind-level tie-break between two different answer types.
973
985
  const memory = MEMORY_KINDS.has(h.kind) ? -MEMORY_PRIOR_SHIFT : 0;
974
- return { h, pos: pos + shift + memory };
986
+ return { h, fused: pos, shift, memory, dead: !!m?.dead, pos: pos + shift + memory };
975
987
  });
988
+ // A superseded decision never outranks its own live successor, whatever the
989
+ // provenance tiers or fused order (dec_e622668785: promotion ADDS the current truth
990
+ // above the stale hit, never erases the trail). The successor takes the
991
+ // predecessor's FUSED rank weighed by its OWN trust — the query matched the subject
992
+ // there — bounded on both sides: never above a live record that fused ahead of the
993
+ // predecessor (it does not borrow a match it never made), always above the
994
+ // predecessor itself. The floor is taken in memory-neutral space: the kind-level
995
+ // memory shift is not part of the match, and mixing it in let one code symbol fused
996
+ // ahead (shift 0 vs memory's −16) pin the successor to just above its dead
997
+ // predecessor, behind code and behind live records the predecessor outranked. Dead
998
+ // records never raise the floor — a closed record does not shield the live answer.
999
+ // The bounds cannot cross: a live record fused ahead sits at most at (f − 1) + MAX,
1000
+ // below the predecessor's f + MAX + DEAD − 0.5.
1001
+ const at = new Map(scored.map((x) => [x.h.ref, x]));
1002
+ for (const [oldRef, curRef] of successorOf) {
1003
+ const prev = at.get(oldRef), cur = at.get(curRef);
1004
+ if (!prev || !cur)
1005
+ continue;
1006
+ let floor = -Infinity;
1007
+ for (const x of scored)
1008
+ if (x !== cur && !x.dead && x.fused < prev.fused)
1009
+ floor = Math.max(floor, x.pos - x.memory);
1010
+ cur.pos = Math.min(cur.pos, Math.max(prev.fused + cur.shift - 0.5, floor + 0.25) + cur.memory, prev.pos - 0.5);
1011
+ }
976
1012
  scored.sort((a, b) => a.pos - b.pos);
977
1013
  return scored.slice(0, limit).map((x) => x.h);
978
1014
  }
@@ -2410,6 +2446,32 @@ function supersessionChain(d, decs) {
2410
2446
  }
2411
2447
  return [...back, d, ...fwd].map((x) => ({ id: x.id, status: x.status, title: x.title }));
2412
2448
  }
2449
+ /** The decision that answers "what is the rule now" for a superseded `d`: its topic's
2450
+ * single live decision when it has a topic, otherwise the end of its superseded_by
2451
+ * chain when that end is still in force. Null when the graph has no unambiguous one. */
2452
+ function liveSuccessor(d, byId, all) {
2453
+ if (d.topic) {
2454
+ const cur = currentForTopic(all, d.topic);
2455
+ if (cur)
2456
+ return { decision: cur, via: "topic" };
2457
+ // A collision has no single answer; an empty topic just means the successor was
2458
+ // recorded without one, so its superseded_by link is still the authority.
2459
+ if (liveForTopic(all, d.topic).length > 1)
2460
+ return null;
2461
+ }
2462
+ const seen = new Set([d.id]);
2463
+ let end;
2464
+ for (let cur = d.superseded_by ? byId.get(d.superseded_by) : undefined; cur && !seen.has(cur.id); cur = cur.superseded_by ? byId.get(cur.superseded_by) : undefined) {
2465
+ seen.add(cur.id);
2466
+ end = cur;
2467
+ }
2468
+ if (!end || !isLive(end))
2469
+ return null;
2470
+ // The chain end is one side of its own topic's collision: still no single answer.
2471
+ if (end.topic && liveForTopic(all, end.topic).length > 1)
2472
+ return null;
2473
+ return { decision: end, via: "chain" };
2474
+ }
2413
2475
  function round(n) {
2414
2476
  return Math.round(n * 100) / 100;
2415
2477
  }
@@ -2438,6 +2500,13 @@ const GRAPH_TOKEN_CAP = boundedWhole(numEnv("HUNCH_GRAPH_TOKEN_CAP", 2_000), 2_0
2438
2500
  * rerankByPriors for the measurement that fixed it at 4. */
2439
2501
  const PRIOR_SHIFT_SCALE = numEnv("HUNCH_PRIOR_SHIFT_SCALE", 12);
2440
2502
  const MAX_PRIOR_SHIFT = numEnv("HUNCH_MAX_PRIOR_SHIFT", 4);
2503
+ /** Positions a superseded/retired/rejected record drops BEYOND the trust clamp. Inside the
2504
+ * clamp, liveness (×0.6 → 4.8) always saturated it, so a dead record got the same +4 as
2505
+ * any old or non-human live record and fused order alone decided between them. A dead
2506
+ * record now shifts MAX_PRIOR_SHIFT + this (6): 2 breaks that tie by one fused rank, and
2507
+ * the own-successor case is handled separately by successor placement. Still well inside
2508
+ * an 8-hit page. Set HUNCH_DEAD_PRIOR_SHIFT=0 to restore the old flat +4. */
2509
+ const DEAD_PRIOR_SHIFT = numEnv("HUNCH_DEAD_PRIOR_SHIFT", 2);
2441
2510
  /** A stale anchored decision stays visible and authoritative; it moves down by at most the shared prior clamp. */
2442
2511
  const STALE_DECISION_PRIOR_WEIGHT = Math.min(1, numEnv("HUNCH_STALE_DECISION_PRIOR_WEIGHT", 0.8));
2443
2512
  const DECISION_FRESHNESS_SCOPE_QUERY_CAP = 256;
@@ -0,0 +1,75 @@
1
+ /** Upper bound on PR description characters that reach a prompt. */
2
+ export declare const PR_BODY_MAX_CHARS = 8000;
3
+ /** Upper bound on rendered review-thread characters that reach a prompt. */
4
+ export declare const PR_THREAD_MAX_CHARS = 14000;
5
+ export interface PrContext {
6
+ number: number;
7
+ title: string;
8
+ /** may be "" when only a review thread exists */
9
+ body: string;
10
+ /** true when the body was cut to PR_BODY_MAX_CHARS */
11
+ truncated: boolean;
12
+ /** the rendered human review thread, oldest first (only when requested) */
13
+ thread?: string;
14
+ /** true when the thread was cut to PR_THREAD_MAX_CHARS */
15
+ threadTruncated?: boolean;
16
+ }
17
+ /** One review-thread message: a conversation comment, a review body, or an inline comment. */
18
+ export interface PrThreadMessage {
19
+ author: string;
20
+ bot: boolean;
21
+ kind: "comment" | "review" | "inline";
22
+ state?: string;
23
+ body: string;
24
+ at: string;
25
+ }
26
+ /** A PR's review thread as fetched: the newest messages, and whether older ones exist. */
27
+ export interface PrThread {
28
+ messages: PrThreadMessage[];
29
+ /** true when GitHub holds older messages than were fetched */
30
+ truncated: boolean;
31
+ }
32
+ /** (owner/repo, PR number, cwd) → the PR's thread, or null when GitHub has no such PR;
33
+ * throws when the lookup itself fails. Same contract as PrFetcher. */
34
+ export type PrThreadFetcher = (repository: string, pr: number, cwd: string) => Promise<PrThread | null>;
35
+ /** What the fetcher returns: the raw GitHub pull-request fields we use. */
36
+ export interface PrRecord {
37
+ title: string;
38
+ body: string | null;
39
+ merge_commit_sha: string | null;
40
+ merged_at: string | null;
41
+ }
42
+ /** (owner/repo, PR number, cwd) → the PR, or null when GitHub has none; throws when the
43
+ * lookup itself fails. Injectable for tests. */
44
+ export type PrFetcher = (repository: string, pr: number, cwd: string) => Promise<PrRecord | null>;
45
+ /** The PR number a commit subject names: squash "Title (#N)" or a GitHub merge commit. */
46
+ export declare function prNumberFromSubject(subject: string): number | undefined;
47
+ /** owner/repo of a GitHub `origin`, or null for any other host or no origin. Memoized per root. */
48
+ export declare function githubRepositoryOf(root: string): Promise<string | null>;
49
+ /** One `gh api` call. `gh` brings the user's own auth, so no token passes through Hunch.
50
+ * Resolves null when GitHub answers but has no usable PR (404, an issue number);
51
+ * THROWS when gh itself is unusable (missing, offline, timed out, unauthenticated). */
52
+ export declare const ghPrFetcher: PrFetcher;
53
+ /** Does this failed `gh api graphql` run carry GitHub's NOT_FOUND answer? (GraphQL
54
+ * errors exit non-zero but still print the JSON answer on stdout.) */
55
+ export declare function isNotFoundAnswer(err: unknown): boolean;
56
+ /** The thread out of a successful THREAD_QUERY answer, or null when there is no such PR. */
57
+ export declare function parseThreadAnswer(stdout: string): PrThread | null;
58
+ /** One `gh api graphql` call for the PR's conversation comments, review bodies, and
59
+ * inline review comments. Resolves null when GitHub has no such PR; throws when gh
60
+ * itself is unusable, exactly like ghPrFetcher. */
61
+ export declare const ghPrThreadFetcher: PrThreadFetcher;
62
+ /** The PR context for one commit, or null. One fetch per (repo, PR, sha, thread?) per
63
+ * process. `thread` also fetches the human review thread for a matching PR. */
64
+ export declare function prContextFor(root: string, commit: {
65
+ sha: string;
66
+ subject: string;
67
+ }, opts?: {
68
+ fetcher?: PrFetcher;
69
+ threadFetcher?: PrThreadFetcher;
70
+ thread?: boolean;
71
+ repository?: string | null;
72
+ env?: NodeJS.ProcessEnv;
73
+ }): Promise<PrContext | null>;
74
+ /** Test seam: forget memoized fetches, origins and the failure count. */
75
+ export declare function clearPrContextMemo(): void;