@davesheffer/hunch 1.36.0 → 1.37.1

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.
@@ -0,0 +1,122 @@
1
+ /** Automatic evaluation and the automatic kill rule for task-record ranking
2
+ * (dec_66925aa0ee). Nobody has to run anything:
3
+ *
4
+ * - every time a task record is written, the leave-one-out evaluation is
5
+ * recomputed over the graph's task records (pure, a few hundred records)
6
+ * and cached under .hunch-cache — derived state, safe to delete;
7
+ * - delivery reads the cache to pick its mode: `ranked` until the
8
+ * pre-registered rule says otherwise, `latest` (three most recent on the
9
+ * file) once the ranked selection has LOST to the baseline with a
10
+ * confidence interval excluding zero over at least KILL_MIN_TASKS records;
11
+ * - `hunch now` prints the current line; a verdict change becomes a finding.
12
+ *
13
+ * `.hunch/local.json` `taskRanking: "ranked" | "latest"` overrides the automatic
14
+ * choice for a repository that wants to pin it. */
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { flushCapture } from "../integrations/sync.js";
18
+ import { writeFileAtomic } from "./io.js";
19
+ import { hunchPaths } from "./paths.js";
20
+ import { evaluateTaskRanking } from "./taskRankEval.js";
21
+ import { reportHash } from "./taskReport.js";
22
+ import { FindingSchema } from "./types.js";
23
+ export const RANK_EVAL_CACHE_SCHEMA = "hunch.task-rank-eval-cache/1";
24
+ /** The decision's threshold: the kill rule needs at least this many task records. */
25
+ export const KILL_MIN_TASKS = 200;
26
+ function cachePath(root) {
27
+ return join(root, ".hunch-cache", "task-rank-eval.json");
28
+ }
29
+ export function readRankEvalCache(root) {
30
+ try {
31
+ const parsed = JSON.parse(readFileSync(cachePath(root), "utf8"));
32
+ return parsed && parsed.schema === RANK_EVAL_CACHE_SCHEMA && parsed.report ? parsed : null;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ function corpusHash(store) {
39
+ const records = store.recs("tasks");
40
+ const ids = records.map((r) => `${r.id}:${r.report_hash}`).sort();
41
+ return { hash: reportHash(ids), count: records.length };
42
+ }
43
+ /** Recompute when the corpus changed; otherwise return the cached report. Never throws. */
44
+ export function refreshRankEval(root, store, options = {}) {
45
+ try {
46
+ const { hash, count } = corpusHash(store);
47
+ const cached = readRankEvalCache(root);
48
+ if (!options.force && cached && cached.corpus_hash === hash)
49
+ return cached;
50
+ const report = evaluateTaskRanking(store.recs("tasks"));
51
+ const next = { schema: RANK_EVAL_CACHE_SCHEMA, computed_at: (options.now ?? (() => new Date().toISOString()))(), records: count, corpus_hash: hash, report };
52
+ try {
53
+ const dir = join(root, ".hunch-cache");
54
+ if (!existsSync(dir))
55
+ return next; // no cache dir: still return the fresh report, just do not persist
56
+ writeFileAtomic(cachePath(root), JSON.stringify(next, null, 2));
57
+ }
58
+ catch { /* cache is a convenience */ }
59
+ if (cached && cached.report.verdict !== report.verdict)
60
+ noteVerdictChange(root, store, cached, next);
61
+ return next;
62
+ }
63
+ catch {
64
+ return readRankEvalCache(root);
65
+ }
66
+ }
67
+ /** The rule from the decision, applied to a report. */
68
+ export function modeFromReport(report) {
69
+ if (!report)
70
+ return { mode: "ranked", reason: "no evaluation yet" };
71
+ if (report.verdict === "baseline-better" && report.tasks >= KILL_MIN_TASKS) {
72
+ return { mode: "latest", reason: `ranked selection lost to latest3 on ${report.split.evaluated} cases (Hit@5 ${pct(report.rankers[0]?.hit5)} vs ${pct(report.rankers[1]?.hit5)}, CI [${pts(report.delta_hit5.ci95[0])}, ${pts(report.delta_hit5.ci95[1])}])` };
73
+ }
74
+ return { mode: "ranked", reason: report.verdict === "ranked-better" ? "ranked selection beats latest3" : report.verdict === "baseline-better" ? `latest3 ahead but only ${report.tasks} of ${KILL_MIN_TASKS} task records; not yet decisive` : `evaluation ${report.verdict} (${report.split.evaluated} cases)` };
75
+ }
76
+ /** What delivery should do right now for this repository. */
77
+ export function resolveTaskRankingMode(root, store) {
78
+ let override;
79
+ try {
80
+ override = JSON.parse(readFileSync(join(root, ".hunch", "local.json"), "utf8")).taskRanking;
81
+ }
82
+ catch { /* none */ }
83
+ const cache = refreshRankEval(root, store);
84
+ if (override === "ranked" || override === "latest")
85
+ return { mode: override, reason: `pinned by .hunch/local.json taskRanking`, source: "override", cache };
86
+ const { mode, reason } = modeFromReport(cache?.report);
87
+ return { mode, reason, source: "auto", cache };
88
+ }
89
+ function pct(x) { return x === undefined ? "–" : `${Math.round(x * 100)}%`; }
90
+ function pts(x) { return `${x >= 0 ? "+" : ""}${Math.round(x * 100)}`; }
91
+ /** One line for `hunch now` and the task stats. */
92
+ export function rankingStatusLine(resolved) {
93
+ const r = resolved.cache?.report;
94
+ if (!r)
95
+ return `task ranking: ${resolved.mode} · no evaluation yet (no finished task records)`;
96
+ const ranked = r.rankers.find((x) => x.name === "ranked"), base = r.rankers.find((x) => x.name === "latest3");
97
+ return `task ranking: ${resolved.mode}${resolved.source === "override" ? " (pinned)" : ""} · Hit@5 ranked ${pct(ranked?.hit5)} vs latest3 ${pct(base?.hit5)}, CI [${pts(r.delta_hit5.ci95[0])}, ${pts(r.delta_hit5.ci95[1])}], n=${r.split.evaluated} of ${r.tasks} records · ${r.verdict}${r.tasks < KILL_MIN_TASKS ? ` · kill rule armed at ${KILL_MIN_TASKS} records` : ""}`;
98
+ }
99
+ /** A verdict change is memory: record it once, through the normal capture path. */
100
+ function noteVerdictChange(root, store, prev, next) {
101
+ try {
102
+ const title = `Task ranking evaluation verdict changed: ${prev.report.verdict} → ${next.report.verdict} at ${next.records} task records`;
103
+ const id = `fnd_${reportHash(title).slice(7, 17)}`;
104
+ if (store.getRec("findings", id))
105
+ return;
106
+ const r = next.report;
107
+ const finding = FindingSchema.parse({
108
+ id, title,
109
+ observation: `Automatic leave-one-out evaluation of task-record ranking (dec_66925aa0ee) recomputed after a task record was written. Ranked Hit@5 ${pct(r.rankers[0]?.hit5)} vs latest3 ${pct(r.rankers[1]?.hit5)}, MRR ${r.rankers[0]?.mrr.toFixed(2)} vs ${r.rankers[1]?.mrr.toFixed(2)}, paired bootstrap CI on Hit@5 [${pts(r.delta_hit5.ci95[0])}, ${pts(r.delta_hit5.ci95[1])}] over ${r.split.evaluated} cases (${r.evaluable} evaluable of ${r.tasks}). Delivery mode now: ${modeFromReport(r).mode} — ${modeFromReport(r).reason}.`,
110
+ evidence: [`hunch task rank-eval --json (computed ${next.computed_at})`, `.hunch-cache/task-rank-eval.json corpus ${next.corpus_hash}`],
111
+ severity: next.report.verdict === "baseline-better" ? "high" : "low",
112
+ triage: "open",
113
+ affected_files: ["src/core/taskRanking.ts", "src/core/taskRankingMode.ts"],
114
+ observed_at: next.computed_at,
115
+ provenance: { source: "task_rank_eval", confidence: 0.9, evidence: ["hunch task rank-eval"], last_verified: next.computed_at },
116
+ });
117
+ store.putCapture("findings", finding, false);
118
+ flushCapture(store, hunchPaths(root).hunch, false, `hunch: capture ${id}`);
119
+ }
120
+ catch { /* the cache still carries the verdict; memory of the change is best-effort */ }
121
+ }
122
+ //# sourceMappingURL=taskRankingMode.js.map
@@ -18,6 +18,11 @@ export declare function taskRecordFromReport(report: TaskReport): TaskRecord | n
18
18
  * private save, or a delivered lesson that lives only there — must not be named
19
19
  * in a public record; the store's own routing (unified/shared mode) wins first. */
20
20
  export declare function taskRecordHome(store: HunchStore, record: TaskRecord): TaskRecordHome;
21
+ /** Older records this one verified over: same file, a shared record, and this
22
+ * task's last check passed with no rule violated. Superseded records stay in
23
+ * the graph (append-only) but are never delivered; the newer line carries the
24
+ * verified state. Bounded. */
25
+ export declare function computeSupersedes(record: TaskRecord, others: readonly TaskRecord[], limit?: number): string[];
21
26
  export interface PersistedTaskRecord {
22
27
  record: TaskRecord;
23
28
  home: TaskRecordHome;
@@ -17,6 +17,7 @@ import { hunchPaths } from "./paths.js";
17
17
  import { isEmptyTaskReport, readTaskReport, reportHash } from "./taskReport.js";
18
18
  import { reportSourceSnapshot } from "./taskReportEvidence.js";
19
19
  import { ENTITY_KINDS, TaskRecordSchema } from "./types.js";
20
+ import { refreshRankEval } from "./taskRankingMode.js";
20
21
  function localConfig(root) {
21
22
  try {
22
23
  return JSON.parse(readFileSync(join(root, ".hunch", "local.json"), "utf8"));
@@ -111,6 +112,33 @@ export function taskRecordHome(store, record) {
111
112
  }
112
113
  return "public";
113
114
  }
115
+ /** Older records this one verified over: same file, a shared record, and this
116
+ * task's last check passed with no rule violated. Superseded records stay in
117
+ * the graph (append-only) but are never delivered; the newer line carries the
118
+ * verified state. Bounded. */
119
+ export function computeSupersedes(record, others, limit = 20) {
120
+ const last = record.checks.at(-1);
121
+ if (!last || last.state !== "passed")
122
+ return [];
123
+ if (record.conformance.some((c) => c.outcome === "violated"))
124
+ return [];
125
+ const files = new Set(record.files.map((f) => f.replace(/\\/g, "/")));
126
+ const ids = new Set([...record.lessons.map((l) => l.record_id), ...record.applied.map((a) => a.record_id), ...record.saved.map((s) => s.record_id)]);
127
+ if (!files.size || !ids.size)
128
+ return [];
129
+ const out = [];
130
+ for (const o of others) {
131
+ if (o.id === record.id || o.finished_at >= record.finished_at)
132
+ continue;
133
+ if (!o.files.some((f) => files.has(f.replace(/\\/g, "/"))))
134
+ continue;
135
+ const oids = [...o.lessons.map((l) => l.record_id), ...o.applied.map((a) => a.record_id), ...o.saved.map((s) => s.record_id)];
136
+ if (!oids.some((id) => ids.has(id)))
137
+ continue;
138
+ out.push(o.id);
139
+ }
140
+ return out.sort().slice(0, limit);
141
+ }
114
142
  /** Write (or refresh) the graph record for a finished task. Idempotent on the
115
143
  * report hash. A record never changes home once written. Returns null for an
116
144
  * open task, an empty report, or when task records are disabled locally. */
@@ -118,9 +146,10 @@ export function persistTaskRecord(root, store, taskId, options = {}) {
118
146
  if (!taskRecordsEnabled(root))
119
147
  return null;
120
148
  const report = readTaskReport(root, taskId, reportSourceSnapshot(root).hash);
121
- const record = taskRecordFromReport(report);
122
- if (!record)
149
+ const built = taskRecordFromReport(report);
150
+ if (!built)
123
151
  return null;
152
+ const record = { ...built, supersedes: computeSupersedes(built, store.recs("tasks")) };
124
153
  const inPrivate = store.hasPrivate ? store.getPrivateRec("tasks", record.id) : undefined;
125
154
  const inPublic = store.json.get("tasks", record.id);
126
155
  const home = inPrivate ? "private" : inPublic ? "public" : taskRecordHome(store, record);
@@ -131,6 +160,8 @@ export function persistTaskRecord(root, store, taskId, options = {}) {
131
160
  store.reindex();
132
161
  const flushNow = options.flush ?? taskRecordFlushMode(root) === "each";
133
162
  const flushed = flushNow ? flushCapture(store, hunchPaths(root).hunch, home === "private", `hunch: task ${record.id}`) : null;
163
+ // The ranking evaluation follows the corpus: recomputed here, read at delivery. Never blocks a finish.
164
+ refreshRankEval(root, store);
134
165
  return { record: stored, home, flushed, changed: true };
135
166
  }
136
167
  const GRAPH_SCOPE = reportHash("graph-record");
@@ -0,0 +1,14 @@
1
+ import type { TaskRecord } from "./types.js";
2
+ export interface TaskRecordStats {
3
+ records: number;
4
+ /** Later tasks that had a chance to re-verify (an earlier overlapping task with a check within the window). */
5
+ reverify_candidates: number;
6
+ reverified: number;
7
+ reverification_rate: number | null;
8
+ /** Later tasks that received a rule an earlier overlapping task violated. */
9
+ violation_candidates: number;
10
+ repeated_violations: number;
11
+ repeat_violation_rate: number | null;
12
+ window_hours: number;
13
+ }
14
+ export declare function taskRecordStats(records: readonly TaskRecord[], windowHours?: number): TaskRecordStats;
@@ -0,0 +1,45 @@
1
+ /** Online proxies for whether delivered task history is doing its job,
2
+ * computed from task records alone (no agent claims):
3
+ * - re-verification: a later task on an overlapping file re-ran a check with
4
+ * the same label as an earlier task within a window;
5
+ * - repeat violation: a rule violated in an earlier task was violated again
6
+ * in a later task on an overlapping file.
7
+ * Both should fall if the RECENT TASKS lines are read and acted on. */
8
+ import { normalizePath } from "./taskRanking.js";
9
+ export function taskRecordStats(records, windowHours = 24) {
10
+ const sorted = [...records].filter((r) => r.state === "completed").sort((a, b) => a.finished_at.localeCompare(b.finished_at) || a.id.localeCompare(b.id));
11
+ const windowMs = windowHours * 3_600_000;
12
+ let reverifyCandidates = 0, reverified = 0, violationCandidates = 0, repeated = 0;
13
+ for (let i = 0; i < sorted.length; i++) {
14
+ const t = sorted[i];
15
+ const files = new Set(t.files.map(normalizePath));
16
+ const tEnd = Date.parse(t.finished_at) || 0;
17
+ const overlapping = sorted.slice(0, i).filter((o) => o.files.some((f) => files.has(normalizePath(f))));
18
+ if (!overlapping.length)
19
+ continue;
20
+ const recent = overlapping.filter((o) => tEnd - (Date.parse(o.finished_at) || 0) <= windowMs);
21
+ const earlierLabels = new Set(recent.flatMap((o) => o.checks.map((c) => c.label)));
22
+ if (earlierLabels.size) {
23
+ reverifyCandidates++;
24
+ if (t.checks.some((c) => earlierLabels.has(c.label)))
25
+ reverified++;
26
+ }
27
+ const violatedBefore = new Set(overlapping.flatMap((o) => o.conformance.filter((c) => c.outcome === "violated").map((c) => c.record_id)));
28
+ const received = new Set(t.lessons.map((l) => l.record_id));
29
+ const exposed = [...violatedBefore].filter((id) => received.has(id));
30
+ if (exposed.length) {
31
+ violationCandidates++;
32
+ if (t.conformance.some((c) => c.outcome === "violated" && violatedBefore.has(c.record_id)))
33
+ repeated++;
34
+ }
35
+ }
36
+ return {
37
+ records: sorted.length,
38
+ reverify_candidates: reverifyCandidates, reverified,
39
+ reverification_rate: reverifyCandidates ? reverified / reverifyCandidates : null,
40
+ violation_candidates: violationCandidates, repeated_violations: repeated,
41
+ repeat_violation_rate: violationCandidates ? repeated / violationCandidates : null,
42
+ window_hours: windowHours,
43
+ };
44
+ }
45
+ //# sourceMappingURL=taskRecordStats.js.map
@@ -658,6 +658,7 @@ export declare const TaskRecordSchema: z.ZodObject<{
658
658
  }, z.core.$strip>>>;
659
659
  refusals: z.ZodDefault<z.ZodNumber>;
660
660
  files: z.ZodDefault<z.ZodArray<z.ZodString>>;
661
+ supersedes: z.ZodDefault<z.ZodArray<z.ZodString>>;
661
662
  source_snapshot: z.ZodDefault<z.ZodNullable<z.ZodString>>;
662
663
  report_hash: z.ZodString;
663
664
  provenance: z.ZodObject<{
@@ -1523,6 +1524,7 @@ export declare const SCHEMAS: {
1523
1524
  }, z.core.$strip>>>;
1524
1525
  refusals: z.ZodDefault<z.ZodNumber>;
1525
1526
  files: z.ZodDefault<z.ZodArray<z.ZodString>>;
1527
+ supersedes: z.ZodDefault<z.ZodArray<z.ZodString>>;
1526
1528
  source_snapshot: z.ZodDefault<z.ZodNullable<z.ZodString>>;
1527
1529
  report_hash: z.ZodString;
1528
1530
  provenance: z.ZodObject<{
@@ -502,6 +502,7 @@ export const TaskRecordSchema = z.object({
502
502
  })).default([]).describe("Hunch's deterministic evaluation of each delivered rule against the changed files"),
503
503
  refusals: z.number().int().nonnegative().default(0).describe("edits the native gate denied during the task"),
504
504
  files: z.array(z.string()).default([]).describe("files the task touched: delivery targets, rule-checked changes, denied edits"),
505
+ supersedes: z.array(z.string()).default([]).describe("older task records this task verified over: same file, shared record, passing check; superseded records are not delivered"),
505
506
  source_snapshot: z.string().nullable().default(null).describe("bounded source snapshot hash at the last check, when one ran"),
506
507
  report_hash: z.string().describe("content hash of the full local report this record summarizes"),
507
508
  provenance: ProvenanceSchema,
@@ -32,7 +32,8 @@ import { withWriteLock } from "../serve/writelock.js";
32
32
  import { advertisedTeamRemoteContract, ensureTeamOverlay, overlayMatchesTeamRemote, readTeamConfig, teamRemoteContract, teamSharedRef } from "../integrations/team.js";
33
33
  import { formatSearchHit, formatStructure } from "../core/format.js";
34
34
  import { isStateKind, stateSupplements } from "../core/stateDelivery.js";
35
- import { taskSupplements } from "../core/taskDelivery.js";
35
+ import { taskSelectionSupplements } from "../core/taskDelivery.js";
36
+ import { buildTaskRankingQuery } from "../core/taskQuery.js";
36
37
  import { diagnoseIssueCorrectionStage, formatCorrectionStageDiagnostic } from "../core/correctionStage.js";
37
38
  import { compileVerifiedEvidenceMap, EvidenceExecutionSchema, EvidenceInterventionSchema, EvidenceProbeSchema, formatVerifiedEvidenceMap, VerifiedEvidenceReceiptSchema, } from "../core/evidenceMap.js";
38
39
  import { collectCorrectionStageSources } from "../extractors/correctionSources.js";
@@ -360,19 +361,27 @@ function deliveredContext(root, target, envelope, sessionId) {
360
361
  const state = armExecutionObligations(loadPipelineState(sessionId), structuredContent.obligations, { replaceOrigin: "memory" });
361
362
  savePipelineState(sessionId, state);
362
363
  }
363
- recordServed(root, structuredContent.delivered.map((item) => ({
364
- event: "served",
365
- kind: item.kind,
366
- record_id: item.record_id,
367
- target,
368
- session_id: sessionId,
369
- rank: item.rank,
370
- delivery_reason: item.delivery_reason,
371
- provenance_status: item.provenance_status,
372
- token_cost: item.token_cost,
373
- delivery_profile: structuredContent.profile,
374
- ranking_policy: structuredContent.ranking_policy,
375
- })));
364
+ recordServed(root, [
365
+ ...structuredContent.delivered.map((item) => ({
366
+ event: "served",
367
+ kind: item.kind,
368
+ record_id: item.record_id,
369
+ target,
370
+ session_id: sessionId,
371
+ rank: item.rank,
372
+ delivery_reason: item.delivery_reason,
373
+ provenance_status: item.provenance_status,
374
+ token_cost: item.token_cost,
375
+ delivery_profile: structuredContent.profile,
376
+ ranking_policy: structuredContent.ranking_policy,
377
+ })),
378
+ // Delivered task lines are receipts too: they feed access-based recency.
379
+ ...envelope.supplements.filter((s) => s.kind === "recent-task" && s.delivered).map((s) => ({
380
+ event: "served", kind: "tasks", record_id: s.id, target, session_id: sessionId,
381
+ rank: s.rank, delivery_reason: "supplemental", token_cost: s.token_cost,
382
+ delivery_profile: structuredContent.profile, ranking_policy: structuredContent.ranking_policy,
383
+ })),
384
+ ]);
376
385
  return {
377
386
  content: [{ type: "text", text: structuredContent.text }],
378
387
  structuredContent,
@@ -1104,7 +1113,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1104
1113
  const stateGrounding = asOf ? [] : stateSupplements(store.stateSlice(target), target);
1105
1114
  // Recent finished tasks that touched the target: what earlier agent work did
1106
1115
  // here, from graph memory. Advisory history sharing the brief's budget.
1107
- const recentTasks = asOf ? [] : taskSupplements(store.tasksFor(target, 3), target);
1116
+ const recentTasks = asOf ? [] : taskSelectionSupplements(store.selectTasksAuto(target, buildTaskRankingQuery(root, task_id ?? null, target)), target);
1108
1117
  const options = {
1109
1118
  root,
1110
1119
  symbols: store.recs("symbols"),
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { fileURLToPath } from "node:url";
2
+ import { fileURLToPath, pathToFileURL } from "node:url";
3
3
  import { TaskIdSchema, ReportClaimSchema, LessonReferenceSchema, finishReportTask, listReportTasks, readTaskReport, readLessonHistory, recordReportClaim, reportPresentationEnabled, startReportTask } from "../core/taskReport.js";
4
4
  import { persistTaskRecord } from "../core/taskRecord.js";
5
5
  import { reportSourceSnapshot, runReportConformance } from "../core/taskReportEvidence.js";
@@ -53,7 +53,11 @@ export function boundedTaskReportForHost(report) {
53
53
  function verificationLauncher() {
54
54
  const dev = import.meta.url.endsWith(".ts");
55
55
  const entry = fileURLToPath(new URL(`../cli/index.${dev ? "ts" : "js"}`, import.meta.url));
56
- const argv = [process.execPath, ...(dev ? ["--import", fileURLToPath(import.meta.resolve("tsx"))] : []), entry];
56
+ // `--import` takes a URL. Converting the resolved loader to a path made Node on
57
+ // Windows reject it ("Received protocol 'c:'"), so every verification launched
58
+ // from a source checkout there failed before running and cards showed no check.
59
+ const loader = import.meta.resolve("tsx");
60
+ const argv = [process.execPath, ...(dev ? ["--import", loader.startsWith("file:") ? loader : pathToFileURL(loader).href] : []), entry];
57
61
  const quote = (s) => process.platform === "win32" ? `'${s.replace(/'/g, "''")}'` : `'${s.replace(/'/g, "'\\''")}'`;
58
62
  return { argv, shell: `${process.platform === "win32" ? "& " : ""}${argv.map(quote).join(" ")}` };
59
63
  }
@@ -3,6 +3,7 @@ import { type Component, type Constraint, type Bug, type Decision, type Symbol,
3
3
  import { type DB } from "./db.js";
4
4
  import { type Embedder } from "./embedder.js";
5
5
  import { JsonStore } from "./jsonStore.js";
6
+ import { type RankingContext, type RankingQuery, type RankingWeights, type SlotOptions, type TaskSelection } from "../core/taskRanking.js";
6
7
  import { type VetoTier } from "../core/strictgate.js";
7
8
  import { type DiffAnalysis } from "../extractors/diff.js";
8
9
  import type { CheckReport, CausalWhy, ImpactReport } from "../core/checkreport.js";
@@ -409,6 +410,29 @@ export declare class HunchStore {
409
410
  * denied edits), newest first. Graph memory, so it spans machines and survives
410
411
  * the local ledger's retention window. */
411
412
  tasksFor(scope: string, limit?: number): TaskRecord[];
413
+ /** Every task record that could matter for `target` under the ranking gate:
414
+ * same file (exact or glob), a dependent's file, a co-changed file, or a
415
+ * record sharing one of the current task's own record ids. Bounded; the
416
+ * ranker does the gating and scoring. */
417
+ taskCandidates(target: string, query: RankingQuery, ctx: RankingContext): TaskRecord[];
418
+ /** Every task id some later record verified over. */
419
+ supersededTaskIds(): Set<string>;
420
+ /** Last delivery time per task record from the local receipt ledger, if any. */
421
+ taskLastDelivered(): Map<string, number>;
422
+ /** bm25 of a phrase over task titles and lesson titles, normalized to the top hit. */
423
+ taskLexicalScores(phrase: string | null, limit?: number): Map<string, number>;
424
+ /** Corpus inputs for ranking: dependents' files via the symbol graph, co-change
425
+ * from git history (bounded, cached), record-id document frequencies across
426
+ * task records, lexical scores, and which anchors still exist. */
427
+ taskRankingContext(target: string, query: RankingQuery): RankingContext;
428
+ /** Gate → score → slots for one target and the current task's query (dec_66925aa0ee). */
429
+ selectTasksFor(target: string, query: RankingQuery, options?: SlotOptions & {
430
+ weights?: Readonly<RankingWeights>;
431
+ mode?: "ranked" | "latest";
432
+ }): TaskSelection;
433
+ /** What delivery uses: the mode comes from the automatic evaluation (or a
434
+ * local pin), never from a flag the caller has to remember. */
435
+ selectTasksAuto(target: string, query: RankingQuery): TaskSelection;
412
436
  /** The causal chain behind a constraint — the WHY a diff-only reviewer can't see.
413
437
  * Deterministic graph join: constraint → source_decision (the decision that
414
438
  * motivated the guard) → the bug whose root cause spawned it (via
@@ -20,6 +20,10 @@ import { selectEmbedder } from "./embedder.js";
20
20
  import { JsonStore } from "./jsonStore.js";
21
21
  import { gitCommonDir, gitWorktreeRoot, isolatedHeadSha, sameGitPublication, scopedLastChangeDates, } from "../extractors/git.js";
22
22
  import { pathMatchesGlob, pathsRelated } from "../core/glob.js";
23
+ import { cochangeFor } from "../core/cochange.js";
24
+ import { withServedDatabase } from "../core/served.js";
25
+ import { normalizePath, rankTaskRecords, recordIdsOf, selectLatestTasks, selectTaskSlots } from "../core/taskRanking.js";
26
+ import { resolveTaskRankingMode } from "../core/taskRankingMode.js";
23
27
  import { currentForTopic, isInForce } from "../core/topics.js";
24
28
  import { edgeId } from "../core/ids.js";
25
29
  import { isStrictBlocker, isVetoBlocker } from "../core/strictgate.js";
@@ -1453,6 +1457,122 @@ export class HunchStore {
1453
1457
  .sort((a, b) => b.finished_at.localeCompare(a.finished_at) || a.id.localeCompare(b.id))
1454
1458
  .slice(0, Math.max(1, limit));
1455
1459
  }
1460
+ /** Every task record that could matter for `target` under the ranking gate:
1461
+ * same file (exact or glob), a dependent's file, a co-changed file, or a
1462
+ * record sharing one of the current task's own record ids. Bounded; the
1463
+ * ranker does the gating and scoring. */
1464
+ taskCandidates(target, query, ctx) {
1465
+ const t = normalizePath(toPosixTarget(target));
1466
+ const out = new Map();
1467
+ for (const r of this.tasksFor(t, 200))
1468
+ out.set(r.id, r);
1469
+ if (ctx.dependents.size || ctx.cochange.size || query.recordIds.size) {
1470
+ for (const r of this.recs("tasks")) {
1471
+ if (out.has(r.id))
1472
+ continue;
1473
+ const files = r.files.map(normalizePath);
1474
+ if (files.some((f) => ctx.dependents.has(f) || ctx.cochange.has(f))) {
1475
+ out.set(r.id, r);
1476
+ continue;
1477
+ }
1478
+ if (query.recordIds.size) {
1479
+ const ids = recordIdsOf(r);
1480
+ for (const id of query.recordIds)
1481
+ if (ids.has(id)) {
1482
+ out.set(r.id, r);
1483
+ break;
1484
+ }
1485
+ }
1486
+ }
1487
+ }
1488
+ return [...out.values()];
1489
+ }
1490
+ /** Every task id some later record verified over. */
1491
+ supersededTaskIds() {
1492
+ const out = new Set();
1493
+ for (const r of this.recs("tasks"))
1494
+ for (const id of r.supersedes)
1495
+ out.add(id);
1496
+ return out;
1497
+ }
1498
+ /** Last delivery time per task record from the local receipt ledger, if any. */
1499
+ taskLastDelivered() {
1500
+ const out = new Map();
1501
+ try {
1502
+ withServedDatabase(this.paths.root, (db) => {
1503
+ const rows = db.prepare("SELECT record_id, MAX(at) AS at FROM served WHERE kind = 'tasks' GROUP BY record_id").all();
1504
+ for (const row of rows) {
1505
+ const t = Date.parse(row.at);
1506
+ if (Number.isFinite(t))
1507
+ out.set(row.record_id, t);
1508
+ }
1509
+ });
1510
+ }
1511
+ catch { /* no ledger on this machine: recency falls back to finished_at */ }
1512
+ return out;
1513
+ }
1514
+ /** bm25 of a phrase over task titles and lesson titles, normalized to the top hit. */
1515
+ taskLexicalScores(phrase, limit = 50) {
1516
+ const scores = new Map();
1517
+ if (!phrase || !phrase.trim())
1518
+ return scores;
1519
+ const hits = this.scopedFts(phrase, "tasks", limit).filter((h) => Number.isFinite(h.score));
1520
+ if (!hits.length)
1521
+ return scores;
1522
+ // bm25 from FTS5 is negative, lower is better; normalize magnitude to the best hit.
1523
+ const best = Math.max(...hits.map((h) => Math.abs(h.score)));
1524
+ if (!(best > 0))
1525
+ return scores;
1526
+ for (const h of hits)
1527
+ scores.set(h.ref, Math.max(0, Math.min(1, Math.abs(h.score) / best)));
1528
+ return scores;
1529
+ }
1530
+ /** Corpus inputs for ranking: dependents' files via the symbol graph, co-change
1531
+ * from git history (bounded, cached), record-id document frequencies across
1532
+ * task records, lexical scores, and which anchors still exist. */
1533
+ taskRankingContext(target, query) {
1534
+ const t = normalizePath(toPosixTarget(target));
1535
+ const dependents = new Set();
1536
+ try {
1537
+ const symbolFile = new Map(this.recs("symbols").map((s) => [s.id, normalizePath(s.file)]));
1538
+ for (const sym of this.why(t).symbols) {
1539
+ for (const d of this.getDependents(sym.id)) {
1540
+ const f = symbolFile.get(d.id);
1541
+ if (f && f !== t)
1542
+ dependents.add(f);
1543
+ }
1544
+ }
1545
+ }
1546
+ catch { /* no symbol graph: the dependents term is simply absent */ }
1547
+ const cochange = /[./]/.test(t) && !/\s/.test(t) ? cochangeFor(this.paths.root, t) : new Map();
1548
+ const tasks = this.recs("tasks");
1549
+ const df = new Map();
1550
+ for (const r of tasks)
1551
+ for (const id of recordIdsOf(r))
1552
+ df.set(id, (df.get(id) ?? 0) + 1);
1553
+ const n = Math.max(1, tasks.length);
1554
+ const ruleStats = (id) => { const d = df.get(id) ?? 0; return { df: d, idf: Math.log((n + 1) / (d + 1)) + 1e-6 }; };
1555
+ const lexical = this.taskLexicalScores(query.phrase);
1556
+ const anchorsAlive = (r) => r.files.length ? r.files.filter((f) => existsSync(join(this.paths.root, normalizePath(f)))).length / r.files.length : 1;
1557
+ const superseded = this.supersededTaskIds();
1558
+ const delivered = this.taskLastDelivered();
1559
+ return { dependents, cochange, ruleStats, lexical, anchorsAlive, superseded, lastDelivered: (id) => delivered.get(id) ?? null };
1560
+ }
1561
+ /** Gate → score → slots for one target and the current task's query (dec_66925aa0ee). */
1562
+ selectTasksFor(target, query, options = {}) {
1563
+ const ctx = this.taskRankingContext(target, query);
1564
+ const candidates = this.taskCandidates(target, query, ctx);
1565
+ if (options.mode === "latest")
1566
+ return selectLatestTasks(candidates, query, ctx);
1567
+ const ranked = rankTaskRecords(candidates, query, ctx, options.weights);
1568
+ return selectTaskSlots(ranked, options);
1569
+ }
1570
+ /** What delivery uses: the mode comes from the automatic evaluation (or a
1571
+ * local pin), never from a flag the caller has to remember. */
1572
+ selectTasksAuto(target, query) {
1573
+ const resolved = resolveTaskRankingMode(this.paths.root, this);
1574
+ return this.selectTasksFor(target, query, { mode: resolved.mode });
1575
+ }
1456
1576
  /** The causal chain behind a constraint — the WHY a diff-only reviewer can't see.
1457
1577
  * Deterministic graph join: constraint → source_decision (the decision that
1458
1578
  * motivated the guard) → the bug whose root cause spawned it (via
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.36.0",
3
+ "version": "1.37.1",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://www.hunchmemory.com",
10
- "version": "1.36.0",
10
+ "version": "1.37.1",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@davesheffer/hunch",
16
- "version": "1.36.0",
16
+ "version": "1.37.1",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {