@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.
- package/LICENSE +105 -201
- package/NOTICE +10 -3
- package/README.md +8 -6
- package/dist/cli/index.js +14 -5
- package/dist/core/format.d.ts +3 -1
- package/dist/core/format.js +14 -2
- package/dist/core/reviewMemory.d.ts +1 -1
- package/dist/extractors/parse.js +20 -30
- package/dist/integrations/hooks.js +10 -8
- package/dist/store/hunchStore.d.ts +10 -6
- package/dist/store/hunchStore.js +96 -27
- package/dist/synthesis/prContext.d.ts +75 -0
- package/dist/synthesis/prContext.js +265 -0
- package/dist/synthesis/provider.d.ts +38 -0
- package/dist/synthesis/provider.js +110 -13
- package/dist/synthesis/synthesize.d.ts +14 -0
- package/dist/synthesis/synthesize.js +46 -6
- package/package.json +2 -2
- package/server.json +2 -2
package/dist/store/hunchStore.js
CHANGED
|
@@ -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 =
|
|
882
|
-
*
|
|
883
|
-
*
|
|
884
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
912
|
-
//
|
|
913
|
-
//
|
|
914
|
-
//
|
|
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
|
-
|
|
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 =
|
|
924
|
-
if (!d
|
|
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
|
-
|
|
927
|
-
if (
|
|
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:
|
|
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
|
-
|
|
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
|