@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.
- package/dist/cli/index.js +32 -17
- package/dist/cli/taskReport.js +37 -0
- package/dist/constitution/renderEvaluations.d.ts +7 -1
- package/dist/constitution/renderEvaluations.js +21 -1
- package/dist/constitution/schema.d.ts +2 -2
- package/dist/constitution/scorecard.d.ts +2 -2
- package/dist/core/cochange.d.ts +15 -0
- package/dist/core/cochange.js +113 -0
- package/dist/core/outcomeExperience.d.ts +1 -1
- package/dist/core/stateContract.d.ts +3 -3
- package/dist/core/taskDelivery.d.ts +4 -0
- package/dist/core/taskDelivery.js +32 -0
- package/dist/core/taskQuery.d.ts +7 -0
- package/dist/core/taskQuery.js +44 -0
- package/dist/core/taskRankEval.d.ts +70 -0
- package/dist/core/taskRankEval.js +195 -0
- package/dist/core/taskRanking.d.ts +118 -0
- package/dist/core/taskRanking.js +214 -0
- package/dist/core/taskRankingMode.d.ts +36 -0
- package/dist/core/taskRankingMode.js +122 -0
- package/dist/core/taskRecord.d.ts +5 -0
- package/dist/core/taskRecord.js +33 -2
- package/dist/core/taskRecordStats.d.ts +14 -0
- package/dist/core/taskRecordStats.js +45 -0
- package/dist/core/types.d.ts +2 -0
- package/dist/core/types.js +1 -0
- package/dist/mcp/server.js +24 -15
- package/dist/mcp/taskReportTools.js +6 -2
- package/dist/store/hunchStore.d.ts +24 -0
- package/dist/store/hunchStore.js +120 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -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;
|
package/dist/core/taskRecord.js
CHANGED
|
@@ -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
|
|
122
|
-
if (!
|
|
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
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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<{
|
package/dist/core/types.js
CHANGED
|
@@ -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,
|
package/dist/mcp/server.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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 ? [] :
|
|
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
|
-
|
|
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
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -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
package/server.json
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
9
|
"websiteUrl": "https://www.hunchmemory.com",
|
|
10
|
-
"version": "1.
|
|
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.
|
|
16
|
+
"version": "1.37.1",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|