@davesheffer/hunch 1.47.2 → 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.
@@ -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;
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Pull-request context for commit synthesis (#562 phase 1).
3
+ *
4
+ * On squash-merge repos the *why* often lives in the PR description, not the commit
5
+ * that lands. When a commit subject names its PR ("Title (#N)", or GitHub's
6
+ * "Merge pull request #N from owner/branch"), fetch that PR's description through the
7
+ * user's own `gh` CLI and hand it to synthesis as a separate, bounded, labeled section.
8
+ *
9
+ * Phase 2: when the caller asks (only the Critic-verified path does), also fetch the
10
+ * PR's human review/comment thread as a second bounded section. Bots are dropped; a
11
+ * thread lookup that fails keeps the description.
12
+ *
13
+ * Everything here degrades to `null` silently: no GitHub `origin`, no `gh`, offline,
14
+ * unauthenticated, a PR that does not match the commit, or HUNCH_PR_CONTEXT=0.
15
+ * PR text is untrusted author-written data; the prompt labels it as such.
16
+ */
17
+ import { execFile } from "node:child_process";
18
+ import { promisify } from "node:util";
19
+ import { canonicalRemoteRepositoryIdentity } from "../extractors/git.js";
20
+ /** Upper bound on PR description characters that reach a prompt. */
21
+ export const PR_BODY_MAX_CHARS = 8000;
22
+ /** Upper bound on rendered review-thread characters that reach a prompt. */
23
+ export const PR_THREAD_MAX_CHARS = 14000;
24
+ const SQUASH_SUBJECT = /\s\(#(\d{1,9})\)$/;
25
+ const MERGE_SUBJECT = /^Merge pull request #(\d{1,9}) from \S+$/;
26
+ /** The PR number a commit subject names: squash "Title (#N)" or a GitHub merge commit. */
27
+ export function prNumberFromSubject(subject) {
28
+ const s = subject.trim();
29
+ const m = SQUASH_SUBJECT.exec(s) ?? MERGE_SUBJECT.exec(s);
30
+ return m ? Number(m[1]) : undefined;
31
+ }
32
+ const run = promisify(execFile);
33
+ const origins = new Map();
34
+ /** owner/repo of a GitHub `origin`, or null for any other host or no origin. Memoized per root. */
35
+ export async function githubRepositoryOf(root) {
36
+ if (origins.has(root))
37
+ return origins.get(root);
38
+ let repository = null;
39
+ try {
40
+ const { stdout } = await run("git", ["-C", root, "remote", "get-url", "origin"], { encoding: "utf8", timeout: 10_000, windowsHide: true });
41
+ const m = /^provider:github:([a-z0-9-]+\/[a-z0-9._-]+)$/i.exec(canonicalRemoteRepositoryIdentity(stdout.trim(), root));
42
+ repository = m ? m[1] : null;
43
+ }
44
+ catch { /* no origin */ }
45
+ origins.set(root, repository);
46
+ return repository;
47
+ }
48
+ /** One `gh api` call. `gh` brings the user's own auth, so no token passes through Hunch.
49
+ * Resolves null when GitHub answers but has no usable PR (404, an issue number);
50
+ * THROWS when gh itself is unusable (missing, offline, timed out, unauthenticated). */
51
+ export const ghPrFetcher = async (repository, pr, cwd) => {
52
+ let out;
53
+ try {
54
+ // --hostname: the origin is github.com even when the user's gh defaults to an Enterprise host.
55
+ ({ stdout: out } = await run("gh", ["api", "--hostname", "github.com", `repos/${repository}/pulls/${pr}`], {
56
+ cwd, encoding: "utf8", timeout: 15_000, maxBuffer: 4 * 1024 * 1024, windowsHide: true,
57
+ }));
58
+ }
59
+ catch (err) {
60
+ // gh exits non-zero on an HTTP error but still prints GitHub's JSON error body.
61
+ const e = err;
62
+ let answer = {};
63
+ try {
64
+ answer = JSON.parse(e.stdout ?? "");
65
+ }
66
+ catch { /* not an API answer */ }
67
+ if (typeof e.code === "number" && (answer.status === "404" || answer.status === "422" || answer.message === "Not Found"))
68
+ return null;
69
+ throw err;
70
+ }
71
+ const raw = JSON.parse(out);
72
+ if (typeof raw.title !== "string")
73
+ return null;
74
+ return {
75
+ title: raw.title,
76
+ body: typeof raw.body === "string" ? raw.body : null,
77
+ merge_commit_sha: typeof raw.merge_commit_sha === "string" ? raw.merge_commit_sha : null,
78
+ merged_at: typeof raw.merged_at === "string" ? raw.merged_at : null,
79
+ };
80
+ };
81
+ // `last:`, not `first:`: a review thread converges at its end, so a long one keeps
82
+ // its newest messages and reports the older ones as truncated.
83
+ const THREAD_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
84
+ repository(owner: $owner, name: $name) { pullRequest(number: $number) {
85
+ comments(last: 40) { pageInfo { hasPreviousPage } nodes { author { login __typename } body createdAt } }
86
+ reviews(last: 30) { pageInfo { hasPreviousPage } nodes { author { login __typename } state body submittedAt
87
+ comments(last: 30) { pageInfo { hasPreviousPage } nodes { author { login __typename } body createdAt } } } }
88
+ } }
89
+ }`;
90
+ /** Does this failed `gh api graphql` run carry GitHub's NOT_FOUND answer? (GraphQL
91
+ * errors exit non-zero but still print the JSON answer on stdout.) */
92
+ export function isNotFoundAnswer(err) {
93
+ const e = err;
94
+ if (typeof e?.code !== "number" || typeof e.stdout !== "string")
95
+ return false;
96
+ try {
97
+ const answer = JSON.parse(e.stdout);
98
+ return Array.isArray(answer.errors) && answer.errors.some((x) => x?.type === "NOT_FOUND");
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ }
104
+ /** The thread out of a successful THREAD_QUERY answer, or null when there is no such PR. */
105
+ export function parseThreadAnswer(stdout) {
106
+ const raw = JSON.parse(stdout);
107
+ const pull = raw.data?.repository?.pullRequest;
108
+ if (!pull)
109
+ return null;
110
+ const message = (n, kind, at) => {
111
+ const login = typeof n.author?.login === "string" ? n.author.login : "";
112
+ return {
113
+ author: login || "ghost",
114
+ bot: !n.author || !login || n.author.__typename === "Bot" || /\[bot\]$/i.test(login),
115
+ kind,
116
+ ...(kind === "review" && typeof n.state === "string" ? { state: n.state } : {}),
117
+ body: typeof n.body === "string" ? n.body : "",
118
+ at: typeof at === "string" ? at : "",
119
+ };
120
+ };
121
+ const older = (page) => page?.pageInfo?.hasPreviousPage === true;
122
+ let truncated = older(pull.comments) || older(pull.reviews);
123
+ const messages = [];
124
+ for (const c of pull.comments?.nodes ?? [])
125
+ if (c)
126
+ messages.push(message(c, "comment", c.createdAt));
127
+ for (const r of pull.reviews?.nodes ?? []) {
128
+ if (!r)
129
+ continue;
130
+ messages.push(message(r, "review", r.submittedAt));
131
+ truncated ||= older(r.comments);
132
+ for (const c of r.comments?.nodes ?? [])
133
+ if (c)
134
+ messages.push(message(c, "inline", c.createdAt));
135
+ }
136
+ return { messages, truncated };
137
+ }
138
+ /** One `gh api graphql` call for the PR's conversation comments, review bodies, and
139
+ * inline review comments. Resolves null when GitHub has no such PR; throws when gh
140
+ * itself is unusable, exactly like ghPrFetcher. */
141
+ export const ghPrThreadFetcher = async (repository, pr, cwd) => {
142
+ const [owner, name] = repository.split("/");
143
+ let out;
144
+ try {
145
+ // `-f` (raw string) for owner/name: `-F` would coerce a repo named "2048" to an Int.
146
+ ({ stdout: out } = await run("gh", [
147
+ "api", "graphql", "--hostname", "github.com",
148
+ "-f", `query=${THREAD_QUERY}`, "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${pr}`,
149
+ ], { cwd, encoding: "utf8", timeout: 15_000, maxBuffer: 4 * 1024 * 1024, windowsHide: true }));
150
+ }
151
+ catch (err) {
152
+ if (isNotFoundAnswer(err))
153
+ return null;
154
+ throw err;
155
+ }
156
+ return parseThreadAnswer(out);
157
+ };
158
+ /** Template comments and runs of blank lines carry no intent; drop them before bounding. */
159
+ function cleanBody(body) {
160
+ return body.replace(/<!--[\s\S]*?-->/g, "").replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
161
+ }
162
+ /** Does this PR belong to this commit? A same-numbered PR in another repo (a fork,
163
+ * a cherry-pick) must never lend its description. Accept the exact squash/merge
164
+ * sha, or GitHub's default squash subject "<PR title> (#N)". */
165
+ function prMatchesCommit(rec, sha, subject, pr) {
166
+ if (!rec.merged_at)
167
+ return false;
168
+ if (rec.merge_commit_sha && rec.merge_commit_sha.toLowerCase() === sha.toLowerCase())
169
+ return true;
170
+ const norm = (s) => s.replace(/\s+/g, " ").trim().toLowerCase();
171
+ return norm(subject) === norm(`${rec.title} (#${pr})`);
172
+ }
173
+ /** Human messages only, oldest first, one `[login …] body` block each. */
174
+ function renderThread(messages) {
175
+ const time = (m) => Date.parse(m.at) || 0;
176
+ return messages
177
+ .filter((m) => !m.bot)
178
+ .map((m) => ({ ...m, body: cleanBody(m.body) }))
179
+ .filter((m) => m.body)
180
+ .sort((a, b) => time(a) - time(b))
181
+ .map((m) => `[${m.author}${m.kind === "review" ? ` review${m.state ? ` ${m.state}` : ""}` : m.kind === "inline" ? " inline" : ""}] ${m.body}`)
182
+ .join("\n\n---\n");
183
+ }
184
+ /** Bound a rendered thread: keep its opening (the question) and its end (where review
185
+ * converges), dropping the middle. */
186
+ function boundThread(thread) {
187
+ if (thread.length <= PR_THREAD_MAX_CHARS)
188
+ return thread;
189
+ const gap = "\n\n[… middle of the review thread omitted …]\n\n";
190
+ const head = Math.floor(PR_THREAD_MAX_CHARS / 4);
191
+ return thread.slice(0, head) + gap + thread.slice(thread.length - (PR_THREAD_MAX_CHARS - head - gap.length));
192
+ }
193
+ const memo = new Map();
194
+ const MAX_CONSECUTIVE_FAILURES = 2;
195
+ let failures = 0;
196
+ // Separate from `failures`: a GraphQL-only problem (its own rate limit, a token REST
197
+ // accepts but GraphQL refuses) must stop thread lookups, not the working descriptions.
198
+ let threadFailures = 0;
199
+ /** The PR context for one commit, or null. One fetch per (repo, PR, sha, thread?) per
200
+ * process. `thread` also fetches the human review thread for a matching PR. */
201
+ export async function prContextFor(root, commit, opts = {}) {
202
+ const env = opts.env ?? process.env;
203
+ if (env.HUNCH_PR_CONTEXT === "0")
204
+ return null;
205
+ const pr = prNumberFromSubject(commit.subject);
206
+ if (pr === undefined)
207
+ return null;
208
+ const repository = opts.repository !== undefined ? opts.repository : await githubRepositoryOf(root);
209
+ if (!repository)
210
+ return null;
211
+ // A description-only answer must never satisfy a thread lookup.
212
+ const key = `${repository.toLowerCase()}#${pr}@${commit.sha}${opts.thread ? "+thread" : ""}`;
213
+ if (memo.has(key))
214
+ return memo.get(key);
215
+ // Offline or without gh, every fetch would wait out its timeout: after a couple of
216
+ // straight lookup failures, stop asking for the rest of this process (e.g. a long
217
+ // backfill). A PR GitHub simply doesn't have is an answer, not a failure.
218
+ if (failures >= MAX_CONSECUTIVE_FAILURES)
219
+ return null;
220
+ let rec;
221
+ try {
222
+ rec = await (opts.fetcher ?? ghPrFetcher)(repository, pr, root);
223
+ failures = 0;
224
+ }
225
+ catch {
226
+ failures++;
227
+ return null;
228
+ }
229
+ let ctx = null;
230
+ if (rec && prMatchesCommit(rec, commit.sha, commit.subject, pr)) {
231
+ const body = cleanBody(rec.body ?? "");
232
+ const described = {
233
+ number: pr,
234
+ title: rec.title.replace(/\s+/g, " ").trim().slice(0, 300),
235
+ body: body.slice(0, PR_BODY_MAX_CHARS),
236
+ truncated: body.length > PR_BODY_MAX_CHARS,
237
+ };
238
+ let thread = "";
239
+ let olderOmitted = false;
240
+ if (opts.thread && threadFailures < MAX_CONSECUTIVE_FAILURES) {
241
+ try {
242
+ const fetched = await (opts.threadFetcher ?? ghPrThreadFetcher)(repository, pr, root);
243
+ threadFailures = 0;
244
+ thread = renderThread(fetched?.messages ?? []);
245
+ olderOmitted = !!fetched?.truncated;
246
+ }
247
+ catch {
248
+ // A failed thread lookup trips only the thread breaker and keeps the description.
249
+ threadFailures++;
250
+ return body ? described : null;
251
+ }
252
+ }
253
+ if (thread) {
254
+ ctx = { ...described, thread: boundThread(thread), threadTruncated: olderOmitted || thread.length > PR_THREAD_MAX_CHARS };
255
+ }
256
+ else if (body) {
257
+ ctx = described;
258
+ }
259
+ }
260
+ memo.set(key, ctx);
261
+ return ctx;
262
+ }
263
+ /** Test seam: forget memoized fetches, origins and the failure count. */
264
+ export function clearPrContextMemo() { memo.clear(); origins.clear(); failures = 0; threadFailures = 0; }
265
+ //# sourceMappingURL=prContext.js.map
@@ -1,5 +1,6 @@
1
1
  import { type DiffAnalysis } from "../extractors/diff.js";
2
2
  import type { Decision } from "../core/types.js";
3
+ import type { PrContext } from "./prContext.js";
3
4
  /**
4
5
  * Run a command, optionally feeding `input` to its stdin, and resolve its
5
6
  * stdout. Uses spawn (not execFile) so we can:
@@ -35,6 +36,7 @@ export interface DecisionDraft {
35
36
  verifyOutcome?: "applied" | "unavailable" | "failed";
36
37
  fellBackTo?: string;
37
38
  fallbackReason?: string;
39
+ fallbackKind?: string;
38
40
  }
39
41
  /** A skeptical audit of a DecisionDraft against the commit it was derived from.
40
42
  * `grounded` is 0..1 (how well decision+consequences are supported by the diff);
@@ -84,6 +86,9 @@ export interface CommitInput {
84
86
  diff: string;
85
87
  /** structured "what changed" — lets even the no-LLM path be informative */
86
88
  analysis?: DiffAnalysis;
89
+ /** the merged pull request's description, already bounded (#562); absent when
90
+ * unavailable and for local-only captures */
91
+ pr?: PrContext;
87
92
  }
88
93
  export interface FailureInput {
89
94
  test: string;
@@ -280,6 +285,39 @@ export declare function applyVerdict(draft: DecisionDraft, v: VerifyVerdict): De
280
285
  * the Critic call stacks after sampling, and a single transient failure on that
281
286
  * extra call shouldn't drop the audit. */
282
287
  export declare function verifyDecisionSafe(verifier: SynthProvider | null, input: CommitInput, draft: DecisionDraft): Promise<DecisionDraft>;
288
+ type FieldSchema = {
289
+ type: string | readonly string[];
290
+ description?: string;
291
+ items?: {
292
+ type: string;
293
+ };
294
+ enum?: readonly string[];
295
+ };
296
+ /** The output contract for a prompt-only provider. A JSON Schema pasted verbatim
297
+ * gets echoed back as the answer: claude-cli on haiku returned the schema itself
298
+ * for about 1 in 5 commit drafts (issue #561), which then fell back to the
299
+ * heuristic. So describe each key in plain words and ask for the filled-in object. */
300
+ export declare function jsonInstruction(schema: {
301
+ properties: Record<string, FieldSchema>;
302
+ }): string;
303
+ declare const UNUSABLE_DETAIL: {
304
+ readonly "echoed-schema": "echoed the schema instead of an answer";
305
+ readonly "no-json": "no JSON object";
306
+ readonly empty: "empty output";
307
+ readonly "missing-fields": "JSON lacked the required fields";
308
+ };
309
+ export type UnusableKind = keyof typeof UNUSABLE_DETAIL;
310
+ /** A provider answered, but its output held no usable record. Distinct from a
311
+ * transport failure (timeout, crash, auth) because a second sample can succeed:
312
+ * the safe wrappers retry this kind only. `kind` names what was wrong without
313
+ * quoting the model output, so the reason is safe to print and to persist. */
314
+ export declare class UnusableOutputError extends Error {
315
+ readonly kind: UnusableKind;
316
+ constructor(provider: string, what: string, kind: UnusableKind);
317
+ }
318
+ /** Why `text` produced no record: no object at all, the schema echoed back, or
319
+ * objects that lack the fields the mapper needs. */
320
+ export declare function classifyUnusableOutput(text: string): UnusableKind;
283
321
  /** Every parseable top-level JSON object in arbitrary model text, in order. */
284
322
  export declare function extractJsonObjects(text: string): Record<string, unknown>[];
285
323
  /** Convenience: the FIRST parseable top-level object, or null. (The mappers below