@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.
- package/LICENSE +105 -201
- package/NOTICE +10 -3
- package/README.md +8 -6
- package/dist/cli/index.js +31 -14
- package/dist/core/correction.js +4 -3
- 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/core/subagentrole.d.ts +6 -0
- package/dist/core/subagentrole.js +32 -0
- 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/extractors/parse.js
CHANGED
|
@@ -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
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
613
|
-
* hook
|
|
614
|
-
* invocation
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
618
|
-
*
|
|
619
|
-
*
|
|
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 =
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
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
|
|
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
|
|
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
|
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;
|