@davesheffer/hunch 1.42.0 → 1.43.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/README.md +4 -0
- package/dist/cli/index.js +92 -18
- package/dist/core/delivery.d.ts +12 -0
- package/dist/core/delivery.js +4 -4
- package/dist/core/hookcache.d.ts +22 -0
- package/dist/core/hookcache.js +53 -2
- package/dist/core/publication.js +17 -3
- package/dist/core/taskReportHook.js +1 -1
- package/dist/core/taskSelection.d.ts +67 -0
- package/dist/core/taskSelection.js +196 -0
- package/dist/integrations/providers.js +2 -1
- package/dist/integrations/scaffold.js +2 -1
- package/dist/mcp/taskReportTools.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -12,6 +12,10 @@ Hunch keeps that record in Git and makes the relevant parts available to your ag
|
|
|
12
12
|
|
|
13
13
|
The goal is simple: agents working from the same maintained record, with sources they can inspect. Hunch supplies memory and checks; the assistant still does the work.
|
|
14
14
|
|
|
15
|
+
[](https://www.youtube.com/watch?v=m2PM6pV7D9Y)
|
|
16
|
+
|
|
17
|
+
▶ **[Watch Hunch Memory in 2 minutes](https://www.youtube.com/watch?v=m2PM6pV7D9Y)**, then the [7-episode series](https://www.youtube.com/playlist?list=PLIXqtdE0LcNI) on setup, hooks, memory capture, delivery, rules, drift and teams.
|
|
18
|
+
|
|
15
19
|
## Start with your coding assistant
|
|
16
20
|
|
|
17
21
|
Requires **Node 22.13+** and a Git repository.
|
package/dist/cli/index.js
CHANGED
|
@@ -91,12 +91,13 @@ import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpol
|
|
|
91
91
|
import { isHumanConfirmed } from "../core/strictgate.js";
|
|
92
92
|
import { appendEvent, readEvents } from "../core/events.js";
|
|
93
93
|
import { computeStats, formatStats } from "../core/stats.js";
|
|
94
|
-
import { injectionMode, resetSessionInjections } from "../core/hookcache.js";
|
|
94
|
+
import { clearTaskSelection, injectionMode, loadTaskSelection, resetSessionInjections, saveTaskSelection, taskSelectionEnabled } from "../core/hookcache.js";
|
|
95
|
+
import { isBareFollowUp, isFilterableSelectionId, liveSelectionRecords, selectForTask } from "../core/taskSelection.js";
|
|
95
96
|
import { recordServed, servedSummary } from "../core/served.js";
|
|
96
|
-
import { recordTaskDelivery, reportActivity, reportHash, reportPresentationEnabled, unseenLessons } from "../core/taskReport.js";
|
|
97
|
+
import { readTaskReport, recordTaskDelivery, reportActivity, reportHash, reportPresentationEnabled, unseenLessons } from "../core/taskReport.js";
|
|
97
98
|
import { snapshotDeliveredRecords } from "../core/taskReportEvidence.js";
|
|
98
99
|
import { renderRecalledLine } from "../core/taskReportRender.js";
|
|
99
|
-
import { closeHookTask, hookReportTaskId, nativeHookCwd, settleHookSession, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
|
|
100
|
+
import { closeHookTask, hookReportTaskId, isNotificationPrompt, nativeHookCwd, settleHookSession, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
|
|
100
101
|
import { persistTaskRecord } from "../core/taskRecord.js";
|
|
101
102
|
import { recordHookObservation } from "../core/hookObservations.js";
|
|
102
103
|
import { contextHookOutput, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
|
|
@@ -5100,8 +5101,13 @@ program
|
|
|
5100
5101
|
// Reporting failure must not suppress the existing correction/policy reminder.
|
|
5101
5102
|
try {
|
|
5102
5103
|
const report = startHookReport(root, provider, evt);
|
|
5103
|
-
if (report)
|
|
5104
|
+
if (report) {
|
|
5104
5105
|
parts.push(report);
|
|
5106
|
+
store ??= new HunchStore(paths);
|
|
5107
|
+
const selected = promptTaskSelection(root, store, provider, evt);
|
|
5108
|
+
if (selected)
|
|
5109
|
+
parts.push(selected);
|
|
5110
|
+
}
|
|
5105
5111
|
}
|
|
5106
5112
|
catch { /* passive reporting remains fail-open */ }
|
|
5107
5113
|
// A task an earlier prompt of this session left open (interrupted before
|
|
@@ -5275,7 +5281,7 @@ program
|
|
|
5275
5281
|
// in the overlay. Private mode stays public-only: session transcripts travel
|
|
5276
5282
|
// further than a terminal.
|
|
5277
5283
|
const decisions = s.advisoryRecs("decisions");
|
|
5278
|
-
const {
|
|
5284
|
+
const { pendingReview } = nowData(decisions, 3);
|
|
5279
5285
|
const escalations = pendingEscalations(decisions);
|
|
5280
5286
|
escalations.push(...premiseEscalations(decisions, { now: new Date().toISOString(), exists: (p) => existsSync(join(paths.root, p)) }));
|
|
5281
5287
|
// liveness checked against the full store even in private mode — a
|
|
@@ -5312,17 +5318,6 @@ program
|
|
|
5312
5318
|
}
|
|
5313
5319
|
const L = [];
|
|
5314
5320
|
L.push(`🧠 Hunch orientation — ${decisions.length} decision(s) in the graph.`);
|
|
5315
|
-
if (recent.length) {
|
|
5316
|
-
L.push("Recent:");
|
|
5317
|
-
for (const r of recent)
|
|
5318
|
-
L.push(` ${r.date} [${r.status}] ${r.title} (${r.id})`);
|
|
5319
|
-
}
|
|
5320
|
-
if (roadmap.length) {
|
|
5321
|
-
L.push(`Roadmap (${roadmap.length} live proposed): ${roadmap.slice(0, 3).map((r) => (r.unconfirmed ? `${r.title} [unconfirmed, ${r.id}]` : r.title)).join(" · ")}${roadmap.length > 3 ? " · …" : ""}`);
|
|
5322
|
-
const unconfirmed = roadmap.filter((r) => r.unconfirmed).length;
|
|
5323
|
-
if (unconfirmed)
|
|
5324
|
-
L.push(`${unconfirmed} roadmap item(s) are unconfirmed agent testimony — the human confirms each with \`hunch review --confirm <id>${s.unified ? " --private" : ""}\`.`);
|
|
5325
|
-
}
|
|
5326
5321
|
if (pendingReview > 0)
|
|
5327
5322
|
L.push(`${pendingReview} legacy un-vouched draft(s) — adopt as advisory memory with \`hunch adopt-drafts\` (new captures auto-trust).`);
|
|
5328
5323
|
if (actionableEsc.length) {
|
|
@@ -5350,6 +5345,13 @@ program
|
|
|
5350
5345
|
}
|
|
5351
5346
|
if (evt.hook_event_name !== "PreToolUse")
|
|
5352
5347
|
return;
|
|
5348
|
+
// A shell command's writes are measured from its own start: a file written
|
|
5349
|
+
// before it (a parallel tool call, another process, a host that skipped the
|
|
5350
|
+
// last PostToolUse) is not this command's to be blamed for.
|
|
5351
|
+
if (evt.tool_name === "Bash" || evt.tool_name === "PowerShell") {
|
|
5352
|
+
refreshShellBaseline(root, evt.session_id, evt.agent_id);
|
|
5353
|
+
return;
|
|
5354
|
+
}
|
|
5353
5355
|
const targets = editTargets(root, evt.tool_input);
|
|
5354
5356
|
// Nothing inside the repo → nothing for Hunch to say.
|
|
5355
5357
|
if (!targets.length)
|
|
@@ -7522,6 +7524,62 @@ function shellWriteGrounding(root, store, provider, evt, written) {
|
|
|
7522
7524
|
const sibling = parts.some((p) => p.startsWith(SIBLING_HEADING)) ? " It starts with a fix a same-shaped function elsewhere received and this file's copy never did: resolve it before you finish." : "";
|
|
7523
7525
|
return `Hunch: this shell command wrote ${grounded.join(", ")}${more}. Edits made outside the Edit/Write tools skip the pre-edit grounding, so it arrives now: re-check the change against it before relying on it.${sibling}\n\n${parts.join("\n\n")}`;
|
|
7524
7526
|
}
|
|
7527
|
+
/** Score the store against this prompt once per task (taskSelection.ts) and keep
|
|
7528
|
+
* the selected ids — never the prompt — for the task's file grounding. Returns
|
|
7529
|
+
* the short prompt-time list, or "" when nothing clears the threshold (silence).
|
|
7530
|
+
* A follow-up prompt widens the qualifying set rather than replacing it, so a
|
|
7531
|
+
* "go" cannot unselect the task's memory. */
|
|
7532
|
+
const TASK_SELECTION_TITLE_CHARS = 80;
|
|
7533
|
+
function promptTaskSelection(root, store, provider, evt) {
|
|
7534
|
+
// A host notification is not a task prompt: the earlier selection stands untouched.
|
|
7535
|
+
if (!taskSelectionEnabled() || isNotificationPrompt(evt.prompt))
|
|
7536
|
+
return "";
|
|
7537
|
+
const taskId = hookReportTaskId(root, provider, evt);
|
|
7538
|
+
if (!taskId)
|
|
7539
|
+
return "";
|
|
7540
|
+
const selection = selectForTask(liveSelectionRecords({
|
|
7541
|
+
decisions: store.recs("decisions"),
|
|
7542
|
+
bugs: store.recs("bugs"),
|
|
7543
|
+
constraints: store.recs("constraints"),
|
|
7544
|
+
findings: store.recs("findings"),
|
|
7545
|
+
}), evt.prompt ?? "", { root, pathExists: (p) => existsSync(join(root, p)) });
|
|
7546
|
+
// Same task id again (a notification alias) keeps its own earlier selection; a
|
|
7547
|
+
// new task that CONTINUES the session's previous one inherits that task's
|
|
7548
|
+
// selection only on a bare follow-up ("continue", "go on"), so it is not left
|
|
7549
|
+
// ungrounded. A substantive prompt names its own task: a same-session prompt
|
|
7550
|
+
// inside the continuation window must not hide memory behind an old selection.
|
|
7551
|
+
let previous = loadTaskSelection(taskId);
|
|
7552
|
+
if (!previous && isBareFollowUp(evt.prompt ?? "")) {
|
|
7553
|
+
try {
|
|
7554
|
+
const continues = readTaskReport(root, taskId).task.continues;
|
|
7555
|
+
previous = continues ? loadTaskSelection(continues) : null;
|
|
7556
|
+
}
|
|
7557
|
+
catch { /* no continuity; the prompt's own selection stands */ }
|
|
7558
|
+
}
|
|
7559
|
+
const qualifying = [...new Set([...(previous?.qualifying ?? []), ...selection.qualifying])];
|
|
7560
|
+
// An empty selection is no selection: the task keeps the unfiltered grounding.
|
|
7561
|
+
// Emptiness counts only the kinds grounding filters (decisions, bugs,
|
|
7562
|
+
// findings): constraints always pass, so a constraint-only set would hide every
|
|
7563
|
+
// other record anchored to the file. The prompt-time list may still print.
|
|
7564
|
+
if (!qualifying.some(isFilterableSelectionId)) {
|
|
7565
|
+
clearTaskSelection(taskId);
|
|
7566
|
+
}
|
|
7567
|
+
else {
|
|
7568
|
+
saveTaskSelection({
|
|
7569
|
+
task_id: taskId,
|
|
7570
|
+
qualifying,
|
|
7571
|
+
top: selection.qualifying.length ? selection.top.map((item) => item.id) : previous?.top ?? [],
|
|
7572
|
+
});
|
|
7573
|
+
}
|
|
7574
|
+
// An inherited selection was listed when its own prompt ran: not again.
|
|
7575
|
+
if (!selection.top.length)
|
|
7576
|
+
return "";
|
|
7577
|
+
const clip = (text) => {
|
|
7578
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
7579
|
+
return flat.length > TASK_SELECTION_TITLE_CHARS ? `${flat.slice(0, TASK_SELECTION_TITLE_CHARS - 1).trimEnd()}…` : flat;
|
|
7580
|
+
};
|
|
7581
|
+
return `Hunch memory for this task: ${selection.top.map((item) => `${item.id} — ${clip(item.title)}`).join(" · ")} (hunch_why(id) for detail)`;
|
|
7582
|
+
}
|
|
7525
7583
|
/** Memory budget beside a sibling lesson (the default is 1500 tokens). */
|
|
7526
7584
|
const SIBLING_MEMORY_BUDGET_TOKENS = 800;
|
|
7527
7585
|
/** The advisory grounding for one repo-relative file: the ranked memory slice,
|
|
@@ -7540,7 +7598,21 @@ function fileGrounding(root, store, provider, evt, target, abs) {
|
|
|
7540
7598
|
}
|
|
7541
7599
|
catch { /* unreadable / not yet created — no doc grounding */ }
|
|
7542
7600
|
}
|
|
7543
|
-
|
|
7601
|
+
// A task whose prompt was scored (promptTaskSelection) gets the file's
|
|
7602
|
+
// decisions, bugs and findings conditioned on it. Constraints always pass (any
|
|
7603
|
+
// severity): they are scoped rules, including agent-recorded corrections capped
|
|
7604
|
+
// at warning, not relevance guesses. No selection (a host without a prompt
|
|
7605
|
+
// hook, a legacy session, a prompt that selected nothing) keeps the unfiltered
|
|
7606
|
+
// grounding.
|
|
7607
|
+
const selection = loadTaskSelection(hookReportTaskId(root, provider, evt));
|
|
7608
|
+
const assembled = store.assembleContext(target);
|
|
7609
|
+
const selected = selection ? new Set(selection.qualifying) : null;
|
|
7610
|
+
const ctx = selected ? {
|
|
7611
|
+
...assembled,
|
|
7612
|
+
decisions: assembled.decisions.filter((d) => selected.has(d.id)),
|
|
7613
|
+
bugs: assembled.bugs.filter((b) => selected.has(b.id)),
|
|
7614
|
+
findings: assembled.findings.filter((f) => selected.has(f.id)),
|
|
7615
|
+
} : assembled;
|
|
7544
7616
|
// Sibling fixes: a same-shaped function elsewhere was fixed and this copy
|
|
7545
7617
|
// never was — the concrete lesson a scoped constraint cannot carry.
|
|
7546
7618
|
const siblings = siblingGrounding(root, target, store.recs("symbols"), ctx.constraints.filter((c) => c.severity === "blocking" && c.scope.some((g) => pathMatchesGlob(target, g))).map((c) => ({ id: c.id, statement: c.statement })));
|
|
@@ -7565,7 +7637,9 @@ function fileGrounding(root, store, provider, evt, target, abs) {
|
|
|
7565
7637
|
// from this file. No diff exists yet, so this is context — "don't re-add X" —
|
|
7566
7638
|
// not a block; the commit-time `hunch check` does the actual gating.
|
|
7567
7639
|
const retired = store.retiredForFile(target).filter((r) => r.symbols.length || r.deps.length);
|
|
7568
|
-
|
|
7640
|
+
// Recent-task history is advisory; under a task selection it is dropped
|
|
7641
|
+
// (`hunch task list` still has it).
|
|
7642
|
+
const recentTasks = selected ? [] : taskSelectionSupplements(store.selectTasksAuto(target, buildTaskRankingQuery(root, hookReportTaskId(root, provider, evt), target, { excludeTargetDeliveries: true })), target);
|
|
7569
7643
|
const hasContent = ctx.constraints.length ||
|
|
7570
7644
|
ctx.decisions.length ||
|
|
7571
7645
|
ctx.bugs.length ||
|
package/dist/core/delivery.d.ts
CHANGED
|
@@ -112,6 +112,18 @@ export interface DeliveryOptions {
|
|
|
112
112
|
/** Injectable for deterministic tests. Omit to use the local Git graph. */
|
|
113
113
|
commitReachability?: (commit: string) => CommitReachability;
|
|
114
114
|
}
|
|
115
|
+
export declare const SEVERITY: {
|
|
116
|
+
readonly advisory: 1;
|
|
117
|
+
readonly warning: 2;
|
|
118
|
+
readonly blocking: 3;
|
|
119
|
+
readonly low: 1;
|
|
120
|
+
readonly medium: 2;
|
|
121
|
+
readonly high: 3;
|
|
122
|
+
readonly critical: 4;
|
|
123
|
+
};
|
|
124
|
+
export declare const PROFILE_BASE_SCORE: Record<DeliveryProfile, Record<DeliveryKind, number>>;
|
|
125
|
+
export declare const TASK_STOP_WORDS: Set<string>;
|
|
126
|
+
export declare function lexicalTokens(value: string): Set<string>;
|
|
115
127
|
/** Validate the public receipt without trusting a caller-supplied identity. */
|
|
116
128
|
export declare function assertDeliveryEnvelope(envelope: DeliveryEnvelope): void;
|
|
117
129
|
/** The string a hash-based injection dedup (`injectionMode`'s `hashInput`) must
|
package/dist/core/delivery.js
CHANGED
|
@@ -16,7 +16,7 @@ import { LANDSCAPE_FRAGMENT_SCHEMA_VERSION, assertLandscapeDeliveryFragment, cre
|
|
|
16
16
|
export const DELIVERY_ENVELOPE_SCHEMA_VERSION = "hunch.delivery-envelope/1";
|
|
17
17
|
export const DELIVERY_PROFILE_POLICY_VERSION = "hunch.delivery-profile/1";
|
|
18
18
|
export const DELIVERY_PROFILES = ["builder", "reviewer", "architect"];
|
|
19
|
-
const SEVERITY = { advisory: 1, warning: 2, blocking: 3, low: 1, medium: 2, high: 3, critical: 4 };
|
|
19
|
+
export const SEVERITY = { advisory: 1, warning: 2, blocking: 3, low: 1, medium: 2, high: 3, critical: 4 };
|
|
20
20
|
const MIN_ADVISORY_CONFIDENCE = 0.5;
|
|
21
21
|
const MIN_UNCONDITIONED_CONFIDENCE = 0.7;
|
|
22
22
|
const MAX_ACTIONABLE_HYPOTHESES = 2;
|
|
@@ -24,7 +24,7 @@ const MAX_PROFILE_HEADLINES = 8;
|
|
|
24
24
|
/** How far a supplement's text is clipped in the rendered line. Shared so
|
|
25
25
|
* `deliveryDedupeInput` can reconstruct that exact line to project over it. */
|
|
26
26
|
const SUPPLEMENT_HEADLINE_CHARS = 700;
|
|
27
|
-
const PROFILE_BASE_SCORE = {
|
|
27
|
+
export const PROFILE_BASE_SCORE = {
|
|
28
28
|
builder: {
|
|
29
29
|
constraints: 900,
|
|
30
30
|
decisions: 800,
|
|
@@ -50,7 +50,7 @@ const PROFILE_BASE_SCORE = {
|
|
|
50
50
|
relationships: 825,
|
|
51
51
|
},
|
|
52
52
|
};
|
|
53
|
-
const TASK_STOP_WORDS = new Set([
|
|
53
|
+
export const TASK_STOP_WORDS = new Set([
|
|
54
54
|
"a", "an", "and", "are", "as", "at", "be", "been", "but", "by", "can", "does", "for", "from",
|
|
55
55
|
"has", "have", "in", "into", "is", "it", "its", "of", "on", "or", "that", "the", "this", "to",
|
|
56
56
|
"use", "uses", "using", "was", "when", "where", "which", "while", "with", "without",
|
|
@@ -91,7 +91,7 @@ function stemToken(token) {
|
|
|
91
91
|
return token.slice(0, -1);
|
|
92
92
|
return token;
|
|
93
93
|
}
|
|
94
|
-
function lexicalTokens(value) {
|
|
94
|
+
export function lexicalTokens(value) {
|
|
95
95
|
const expanded = value.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").toLowerCase();
|
|
96
96
|
const words = expanded.match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
97
97
|
return new Set(words.map(stemToken).filter((token) => token.length >= 3 && !TASK_STOP_WORDS.has(token)));
|
package/dist/core/hookcache.d.ts
CHANGED
|
@@ -15,3 +15,25 @@ export declare function injectionMode(sessionId: string | undefined, key: string
|
|
|
15
15
|
* reset, or post-compact edits get delta one-liners against grounding the
|
|
16
16
|
* agent no longer has. Never throws (same posture as injectionMode). */
|
|
17
17
|
export declare function resetSessionInjections(sessionId: string | undefined): void;
|
|
18
|
+
/** The machine-local directory every hook cache lives in (OS tmpdir). */
|
|
19
|
+
export declare function hookCacheDir(): string;
|
|
20
|
+
/** A task's prompt-time memory selection (taskSelection.ts): record ids only —
|
|
21
|
+
* never the prompt text it was scored from. Kill switch: HUNCH_TASK_SELECTION=0
|
|
22
|
+
* (no selection is written or read, so file grounding stays unfiltered). */
|
|
23
|
+
export interface TaskSelectionFile {
|
|
24
|
+
task_id: string;
|
|
25
|
+
qualifying: string[];
|
|
26
|
+
top: string[];
|
|
27
|
+
}
|
|
28
|
+
export declare function taskSelectionPath(taskId: string): string;
|
|
29
|
+
/** Persist a task's selection (atomic temp+rename). Throws; callers fail open. */
|
|
30
|
+
export declare function saveTaskSelection(selection: TaskSelectionFile): void;
|
|
31
|
+
export declare function taskSelectionEnabled(): boolean;
|
|
32
|
+
/** Remove a task's selection file (an empty selection is no selection). Never throws. */
|
|
33
|
+
export declare function clearTaskSelection(taskId: string): void;
|
|
34
|
+
/** The task's selection, or null when none was written (a host without a prompt
|
|
35
|
+
* hook, a legacy session), it holds no decision/bug/finding id (constraints
|
|
36
|
+
* are never filtered, so a constraint-only selection would only hide memory),
|
|
37
|
+
* it is unreadable, or selection is switched off — callers then keep today's
|
|
38
|
+
* unfiltered grounding. Never throws. */
|
|
39
|
+
export declare function loadTaskSelection(taskId: string | null | undefined): TaskSelectionFile | null;
|
package/dist/core/hookcache.js
CHANGED
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
import { createHash } from "node:crypto";
|
|
19
19
|
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, rmSync } from "node:fs";
|
|
20
20
|
import { join } from "node:path";
|
|
21
|
+
import { isFilterableSelectionId } from "./taskSelection.js";
|
|
21
22
|
import { tmpdir } from "node:os";
|
|
23
|
+
import { writeFileAtomic } from "./io.js";
|
|
22
24
|
const MAX_KEYS = 300;
|
|
23
25
|
const SWEEP_AGE_MS = 48 * 3600 * 1000;
|
|
24
26
|
/** Decide whether this injection should be the FULL grounding block or a delta
|
|
@@ -35,7 +37,7 @@ export function injectionMode(sessionId, key, content, hashInput = content) {
|
|
|
35
37
|
try {
|
|
36
38
|
if (!sessionId || process.env.HUNCH_HOOK_DEDUP === "0")
|
|
37
39
|
return "full";
|
|
38
|
-
const dir =
|
|
40
|
+
const dir = hookCacheDir();
|
|
39
41
|
mkdirSync(dir, { recursive: true });
|
|
40
42
|
sweep(dir);
|
|
41
43
|
const file = join(dir, `${sessionId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 80)}.json`);
|
|
@@ -71,13 +73,62 @@ export function resetSessionInjections(sessionId) {
|
|
|
71
73
|
try {
|
|
72
74
|
if (!sessionId)
|
|
73
75
|
return;
|
|
74
|
-
const file = join(
|
|
76
|
+
const file = join(hookCacheDir(), `${sessionId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 80)}.json`);
|
|
75
77
|
rmSync(file, { force: true });
|
|
76
78
|
}
|
|
77
79
|
catch {
|
|
78
80
|
/* unwritable tmpdir — next injectionMode call falls back to "full" anyway */
|
|
79
81
|
}
|
|
80
82
|
}
|
|
83
|
+
/** The machine-local directory every hook cache lives in (OS tmpdir). */
|
|
84
|
+
export function hookCacheDir() {
|
|
85
|
+
return join(tmpdir(), "hunch-hookcache");
|
|
86
|
+
}
|
|
87
|
+
export function taskSelectionPath(taskId) {
|
|
88
|
+
return join(hookCacheDir(), `task-${taskId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 80)}.json`);
|
|
89
|
+
}
|
|
90
|
+
/** Persist a task's selection (atomic temp+rename). Throws; callers fail open. */
|
|
91
|
+
export function saveTaskSelection(selection) {
|
|
92
|
+
const dir = hookCacheDir();
|
|
93
|
+
mkdirSync(dir, { recursive: true });
|
|
94
|
+
sweep(dir);
|
|
95
|
+
writeFileAtomic(taskSelectionPath(selection.task_id), JSON.stringify({
|
|
96
|
+
task_id: selection.task_id, qualifying: [...selection.qualifying], top: [...selection.top],
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
export function taskSelectionEnabled() {
|
|
100
|
+
return process.env.HUNCH_TASK_SELECTION !== "0";
|
|
101
|
+
}
|
|
102
|
+
/** Remove a task's selection file (an empty selection is no selection). Never throws. */
|
|
103
|
+
export function clearTaskSelection(taskId) {
|
|
104
|
+
try {
|
|
105
|
+
rmSync(taskSelectionPath(taskId), { force: true });
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
/* fail open: a stale file is read back as whatever it holds */
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** The task's selection, or null when none was written (a host without a prompt
|
|
112
|
+
* hook, a legacy session), it holds no decision/bug/finding id (constraints
|
|
113
|
+
* are never filtered, so a constraint-only selection would only hide memory),
|
|
114
|
+
* it is unreadable, or selection is switched off — callers then keep today's
|
|
115
|
+
* unfiltered grounding. Never throws. */
|
|
116
|
+
export function loadTaskSelection(taskId) {
|
|
117
|
+
try {
|
|
118
|
+
if (!taskId || !taskSelectionEnabled())
|
|
119
|
+
return null;
|
|
120
|
+
const raw = JSON.parse(readFileSync(taskSelectionPath(taskId), "utf8"));
|
|
121
|
+
if (!raw || raw.task_id !== taskId || !Array.isArray(raw.qualifying) || !Array.isArray(raw.top))
|
|
122
|
+
return null;
|
|
123
|
+
const qualifying = raw.qualifying.filter((id) => typeof id === "string");
|
|
124
|
+
if (!qualifying.some(isFilterableSelectionId))
|
|
125
|
+
return null;
|
|
126
|
+
return { task_id: taskId, qualifying, top: raw.top.filter((id) => typeof id === "string") };
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
81
132
|
/** Drop session caches from long-gone sessions (best effort, bounded dir). */
|
|
82
133
|
function sweep(dir) {
|
|
83
134
|
try {
|
package/dist/core/publication.js
CHANGED
|
@@ -33,9 +33,23 @@ export function isStructural(hit) {
|
|
|
33
33
|
* `/Users/me/repo` appears in src/integrations/claudeConfig.ts and its test as an
|
|
34
34
|
* illustration; flagging those would train everyone to ignore the scanner. */
|
|
35
35
|
const PLACEHOLDER_USER = /^(me|you|user|username|<[^>]+>|\$\{[^}]+\}|example|test|foo|bar)$/i;
|
|
36
|
+
/** A regex quoted in a record (`\/home\/([^/]+)`, `C:\\Users\\([^\\]+)`) names a
|
|
37
|
+
* pattern, not a user: its capture starts with a regex metacharacter and, past a
|
|
38
|
+
* `(?<name>` label, holds no word. A group that lists names (`(alice|bob)`,
|
|
39
|
+
* `{alice,bob}`) still names users. */
|
|
40
|
+
const PATTERN_USER = /^[([*^{|+?]/;
|
|
41
|
+
function isPatternUser(who) {
|
|
42
|
+
return PATTERN_USER.test(who) && !/[A-Za-z]{3,}/.test(who.replace(/^\(\?<\w+>/, ""));
|
|
43
|
+
}
|
|
44
|
+
/** Home directories in the forms tools print them: a drive path (also as a URI's
|
|
45
|
+
* `c%3A`), a POSIX path after any separator (`file:///Users/x`, `cwd=/home/x`,
|
|
46
|
+
* `git -C /Users/x`) but not after a drive letter's colon, which the drive rule
|
|
47
|
+
* reports, and Git Bash's `/c/Users/x` or WSL's `/mnt/c/Users/x`. A letter right
|
|
48
|
+
* before the colon makes it a scheme (`file:/Users/x`), not a drive. */
|
|
36
49
|
const MACHINE_PATH = [
|
|
37
|
-
/[A-Za-z]
|
|
38
|
-
/(?:^|[
|
|
50
|
+
/(?<![A-Za-z])[A-Z](?::|%3A)[\\/]Users[\\/]([^\\/"'\s,)\]]+)/gi,
|
|
51
|
+
/(?:^|[^A-Za-z0-9_.~])(?<!(?<![A-Za-z])[A-Za-z]:)\/(?:Users|home)\/([^/"'\s,)\]]+)/g,
|
|
52
|
+
/(?:^|[^A-Za-z0-9_.~])\/(?:mnt\/)?[A-Za-z]\/Users\/([^/"'\s,)\]]+)/g,
|
|
39
53
|
];
|
|
40
54
|
/** A path INTO the overlay (dir + file), not a bare mention of the feature. The
|
|
41
55
|
* gitignore entry and the CLAUDE.md description name `.hunch-private` legitimately;
|
|
@@ -151,7 +165,7 @@ export function scanRecord(record, opts = {}) {
|
|
|
151
165
|
for (const re of MACHINE_PATH) {
|
|
152
166
|
for (const m of text.matchAll(re)) {
|
|
153
167
|
const who = m[1] ?? "";
|
|
154
|
-
if (PLACEHOLDER_USER.test(who))
|
|
168
|
+
if (PLACEHOLDER_USER.test(who) || isPatternUser(who))
|
|
155
169
|
continue;
|
|
156
170
|
hits.push({ kind: "machine-path", field, excerpt: clip(m[0]) });
|
|
157
171
|
}
|
|
@@ -200,7 +200,7 @@ export function taskInstruction(task, cwdLiteral, provider, launcher = verificat
|
|
|
200
200
|
const finishRule = HOST_CLOSES_TASK.has(provider) ? "finish only if this task used Hunch" : `finish it yourself with hunch_task(action: "finish", task_id, cwd)`;
|
|
201
201
|
return `Hunch report for this prompt: ${task.task_id} (cwd: ${cwdLiteral}). Use it instead of any earlier ID. Never call hunch_task start. Checks: ${l.shell} task verify ${task.task_id} -- <command> [arguments]${l.note ?? ""}. Same rules as this session's first report; ${finishRule}.`;
|
|
202
202
|
}
|
|
203
|
-
verify = ` Never call hunch_task start for it. For checks, run: ${l.shell} task verify ${task.task_id} -- <command> [arguments]${l.note ?? ""}.
|
|
203
|
+
verify = ` Never call hunch_task start for it. For checks, run: ${l.shell} task verify ${task.task_id} -- <command> [arguments]${l.note ?? ""}. Verify with the tests that cover your change (the files you edited and their tests); the full suite is CI's job. The default budget is 15 min; add --timeout <seconds> before -- only if the whole suite is really needed.`;
|
|
204
204
|
}
|
|
205
205
|
catch {
|
|
206
206
|
return `${head} Call hunch_task(action: "start", task_id: "${task.task_id}", title: ${JSON.stringify(task.title)}, cwd: ${cwdLiteral}) to obtain verification_argv, and finish with hunch_task(action: "finish", task_id, cwd) before responding and show its card.`;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task-scored memory selection (memory-selection design, point 1-3; Gate A v5).
|
|
3
|
+
*
|
|
4
|
+
* The pre-edit hook used to deliver every record anchored to a file, so a hub
|
|
5
|
+
* file filled the headline cap with memory unrelated to the task. This selector
|
|
6
|
+
* scores the live store against the prompt ONCE per task; file grounding then
|
|
7
|
+
* keeps only the decisions, bugs and findings that qualified (constraints are
|
|
8
|
+
* exempt: they are scoped rules, not relevance guesses).
|
|
9
|
+
*
|
|
10
|
+
* PURE on purpose: no fs, no store. Inputs are passed in, so an offline replay
|
|
11
|
+
* can run it against an old store snapshot. The rule is fixed before
|
|
12
|
+
* measurement (no vectors in this step): a record qualifies on >= 2 distinct
|
|
13
|
+
* task terms in its title/rationale, or on a repo path the prompt names that
|
|
14
|
+
* matches the record's files or scope. Below that, nothing (silence, no filler).
|
|
15
|
+
* A term common to many live records (document frequency above the cap) names
|
|
16
|
+
* no task and does not count toward the two.
|
|
17
|
+
*/
|
|
18
|
+
import type { Bug, Constraint, Decision, Finding } from "./types.js";
|
|
19
|
+
export interface TaskSelectionRecords {
|
|
20
|
+
decisions: readonly Decision[];
|
|
21
|
+
bugs: readonly Bug[];
|
|
22
|
+
constraints: readonly Constraint[];
|
|
23
|
+
findings: readonly Finding[];
|
|
24
|
+
}
|
|
25
|
+
export type TaskSelectionKind = "decision" | "bug" | "constraint" | "finding";
|
|
26
|
+
export interface TaskSelection {
|
|
27
|
+
/** Every record clearing the threshold (ids only). */
|
|
28
|
+
qualifying: string[];
|
|
29
|
+
/** At most k, ordered; blocking constraints are never listed (they arrive at edit time). */
|
|
30
|
+
top: Array<{
|
|
31
|
+
id: string;
|
|
32
|
+
kind: TaskSelectionKind;
|
|
33
|
+
title: string;
|
|
34
|
+
}>;
|
|
35
|
+
}
|
|
36
|
+
export declare function isFilterableSelectionId(id: string): boolean;
|
|
37
|
+
export declare function isBareFollowUp(prompt: string): boolean;
|
|
38
|
+
export interface TaskSelectionOptions {
|
|
39
|
+
/** Prompt-time list size (design: K = 3). */
|
|
40
|
+
k?: number;
|
|
41
|
+
/** Optional existence check for a repo-relative path the prompt names. */
|
|
42
|
+
pathExists?: (path: string) => boolean;
|
|
43
|
+
/** Repository root, only to strip it from absolute paths in the prompt (string work, no fs). */
|
|
44
|
+
root?: string;
|
|
45
|
+
}
|
|
46
|
+
/** Document-frequency cap, fixed before the pilot replay: a prompt term counts
|
|
47
|
+
* only when at most max(MIN_DF_CAP, ceil(DF_CAP_RATIO × live records)) records
|
|
48
|
+
* contain it. */
|
|
49
|
+
export declare const MIN_DF_CAP = 3;
|
|
50
|
+
export declare const DF_CAP_RATIO = 0.05;
|
|
51
|
+
/** The live slice the pre-edit grounding would ever deliver at HEAD: decisions and
|
|
52
|
+
* constraints still in force (the store's why() window plus delivery's retired
|
|
53
|
+
* test), every bug (why() keeps fixed ones as lessons), and findings whose triage
|
|
54
|
+
* liveFindingsFor() keeps. */
|
|
55
|
+
export declare function liveSelectionRecords(all: TaskSelectionRecords): TaskSelectionRecords;
|
|
56
|
+
/** Repo paths the prompt names: a markdown link `[label](target)` reads as its
|
|
57
|
+
* target, then a whitespace token containing `/` or ending in a file extension,
|
|
58
|
+
* stripped of quoting/punctuation/unmatched brackets, a trailing `:line[:col]`
|
|
59
|
+
* or `:start-end` and a possessive `'s`, forward-slashed, repo-relative. A drive-letter root compares
|
|
60
|
+
* case-insensitively (Windows paths are case-insensitive on every host). */
|
|
61
|
+
export declare function promptPaths(prompt: string, root?: string): string[];
|
|
62
|
+
/** Score live records against one prompt. Text fields per kind (title + rationale):
|
|
63
|
+
* decision title/context/decision; bug title/symptom/root_cause; constraint
|
|
64
|
+
* statement/rationale; finding title/observation. Path anchors: decision
|
|
65
|
+
* related_files, bug/finding affected_files, constraint scope globs. Priority
|
|
66
|
+
* ties break on the builder profile's kind/severity score in delivery.ts. */
|
|
67
|
+
export declare function selectForTask(records: TaskSelectionRecords, promptText: string, opts?: TaskSelectionOptions): TaskSelection;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { lexicalTokens, PROFILE_BASE_SCORE, SEVERITY } from "./delivery.js";
|
|
2
|
+
import { pathMatchesGlob } from "./glob.js";
|
|
3
|
+
/** Id prefixes of the kinds file grounding filters by a selection (ids.ts:
|
|
4
|
+
* decisionId/bugId/findingId). Constraints (con_) always pass, so a selection
|
|
5
|
+
* holding no id with one of these prefixes filters nothing useful — it would
|
|
6
|
+
* only hide every decision, bug and finding — and counts as no selection. */
|
|
7
|
+
const FILTERABLE_ID_PREFIXES = ["dec_", "bug_", "fnd_"];
|
|
8
|
+
export function isFilterableSelectionId(id) {
|
|
9
|
+
return FILTERABLE_ID_PREFIXES.some((prefix) => id.startsWith(prefix));
|
|
10
|
+
}
|
|
11
|
+
/** Words a bare follow-up is made of ("continue", "go on", "yes do it", "ok,
|
|
12
|
+
* next step please"). A prompt of only these names no task of its own and
|
|
13
|
+
* inherits the continued task's selection; any other word — a verb and an
|
|
14
|
+
* object ("fix sampler"), a path, another language — is a task of its own
|
|
15
|
+
* (fail-safe: its own selection, or unfiltered grounding). */
|
|
16
|
+
const CONTINUATION_WORDS = new Set([
|
|
17
|
+
"continue", "continuing", "go", "on", "ahead", "proceed", "resume", "carry", "keep", "going",
|
|
18
|
+
"yes", "yep", "yeah", "y", "ok", "okay", "sure", "please", "pls", "do", "it", "that", "this",
|
|
19
|
+
"next", "step", "again", "lgtm", "sounds", "good", "fine", "and", "the", "with", "now",
|
|
20
|
+
]);
|
|
21
|
+
export function isBareFollowUp(prompt) {
|
|
22
|
+
const words = (prompt ?? "").toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
23
|
+
return words.length > 0 && words.every((word) => CONTINUATION_WORDS.has(word));
|
|
24
|
+
}
|
|
25
|
+
const BRACKET_PAIRS = { "(": ")", "[": "]", "{": "}" };
|
|
26
|
+
const CLOSING_BRACKETS = { ")": "(", "]": "[", "}": "{" };
|
|
27
|
+
/** Every `close` in text[from, to) follows its `open` (properly nested, none left open). */
|
|
28
|
+
function balanced(text, from, to, open, close) {
|
|
29
|
+
let depth = 0;
|
|
30
|
+
for (let i = from; i < to; i++) {
|
|
31
|
+
if (text[i] === open)
|
|
32
|
+
depth++;
|
|
33
|
+
else if (text[i] === close && --depth < 0)
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return depth === 0;
|
|
37
|
+
}
|
|
38
|
+
/** Strip quoting, sentence punctuation, unmatched or wrapping brackets and a
|
|
39
|
+
* possessive `'s` from a prompt token until stable. A bracket the path itself
|
|
40
|
+
* uses (`(auth)/x.ts`, `[id]/page.tsx`, `src/app/(auth)`) is balanced and stays.
|
|
41
|
+
* Works on [a, b) indices with bracket counts kept current, so trimming a run of
|
|
42
|
+
* n unmatched brackets is linear, not n rescans of the token. */
|
|
43
|
+
const MAX_PATH_TOKEN_CHARS = 1024;
|
|
44
|
+
/** Wrapping pairs unwrapped per token; each unwrap rescans the token, and prose never nests deeper. */
|
|
45
|
+
const MAX_UNWRAPS = 8;
|
|
46
|
+
function trimPathToken(raw) {
|
|
47
|
+
const counts = { "(": 0, ")": 0, "[": 0, "]": 0, "{": 0, "}": 0 };
|
|
48
|
+
for (let i = 0; i < raw.length; i++)
|
|
49
|
+
if (raw[i] in counts)
|
|
50
|
+
counts[raw[i]]++;
|
|
51
|
+
const n = (ch) => counts[ch];
|
|
52
|
+
const dropAt = (i) => { if (raw[i] in counts)
|
|
53
|
+
counts[raw[i]]--; };
|
|
54
|
+
let a = 0;
|
|
55
|
+
let b = raw.length;
|
|
56
|
+
let unwraps = 0;
|
|
57
|
+
for (let before = -1; a < b && before !== b - a;) {
|
|
58
|
+
before = b - a;
|
|
59
|
+
// A trailing opening bracket can never close, so it goes first (keeps `[id]/x.ts[` from losing its head).
|
|
60
|
+
const lastOpen = raw[b - 1];
|
|
61
|
+
if (BRACKET_PAIRS[lastOpen] && n(lastOpen) > n(BRACKET_PAIRS[lastOpen]))
|
|
62
|
+
dropAt(--b);
|
|
63
|
+
if (a >= b)
|
|
64
|
+
break;
|
|
65
|
+
const head = raw[a];
|
|
66
|
+
const close = BRACKET_PAIRS[head];
|
|
67
|
+
const headOpen = CLOSING_BRACKETS[head];
|
|
68
|
+
if ("`'\"<\u2018\u201c".includes(head))
|
|
69
|
+
dropAt(a++);
|
|
70
|
+
else if (close && unwraps < MAX_UNWRAPS && b - a >= 2 && raw[b - 1] === close && n(head) === n(close) && balanced(raw, a + 1, b - 1, head, close)) {
|
|
71
|
+
unwraps++;
|
|
72
|
+
dropAt(a++);
|
|
73
|
+
dropAt(--b);
|
|
74
|
+
}
|
|
75
|
+
else if (close && n(head) > n(close))
|
|
76
|
+
dropAt(a++);
|
|
77
|
+
else if (headOpen && n(head) > n(headOpen))
|
|
78
|
+
dropAt(a++);
|
|
79
|
+
if (a >= b)
|
|
80
|
+
break;
|
|
81
|
+
const tail = raw[b - 1];
|
|
82
|
+
const open = CLOSING_BRACKETS[tail];
|
|
83
|
+
if ("`'\">,.;:!?\u2019\u201d".includes(tail))
|
|
84
|
+
dropAt(--b);
|
|
85
|
+
else if (open && n(open) < n(tail))
|
|
86
|
+
dropAt(--b);
|
|
87
|
+
if (b - a >= 2 && "sS".includes(raw[b - 1]) && "'\u2019".includes(raw[b - 2]))
|
|
88
|
+
b -= 2;
|
|
89
|
+
}
|
|
90
|
+
return raw.slice(a, b);
|
|
91
|
+
}
|
|
92
|
+
const MIN_DISTINCT_TERMS = 2;
|
|
93
|
+
/** Document-frequency cap, fixed before the pilot replay: a prompt term counts
|
|
94
|
+
* only when at most max(MIN_DF_CAP, ceil(DF_CAP_RATIO × live records)) records
|
|
95
|
+
* contain it. */
|
|
96
|
+
export const MIN_DF_CAP = 3;
|
|
97
|
+
export const DF_CAP_RATIO = 0.05;
|
|
98
|
+
const DEFAULT_K = 3;
|
|
99
|
+
const LIVE_FINDING_TRIAGE = new Set(["open", "accepted-risk", "scheduled"]);
|
|
100
|
+
/** The live slice the pre-edit grounding would ever deliver at HEAD: decisions and
|
|
101
|
+
* constraints still in force (the store's why() window plus delivery's retired
|
|
102
|
+
* test), every bug (why() keeps fixed ones as lessons), and findings whose triage
|
|
103
|
+
* liveFindingsFor() keeps. */
|
|
104
|
+
export function liveSelectionRecords(all) {
|
|
105
|
+
return {
|
|
106
|
+
decisions: all.decisions.filter((d) => d.status !== "rejected" && d.status !== "superseded" && !d.superseded_by && d.valid_to == null),
|
|
107
|
+
bugs: [...all.bugs],
|
|
108
|
+
constraints: all.constraints.filter((c) => c.status !== "retired" && c.valid_to == null),
|
|
109
|
+
findings: all.findings.filter((f) => LIVE_FINDING_TRIAGE.has(f.triage)),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** Repo paths the prompt names: a markdown link `[label](target)` reads as its
|
|
113
|
+
* target, then a whitespace token containing `/` or ending in a file extension,
|
|
114
|
+
* stripped of quoting/punctuation/unmatched brackets, a trailing `:line[:col]`
|
|
115
|
+
* or `:start-end` and a possessive `'s`, forward-slashed, repo-relative. A drive-letter root compares
|
|
116
|
+
* case-insensitively (Windows paths are case-insensitive on every host). */
|
|
117
|
+
export function promptPaths(prompt, root) {
|
|
118
|
+
const out = new Set();
|
|
119
|
+
const rootPrefix = root ? `${root.replace(/\\/g, "/").replace(/\/+$/, "")}/` : null;
|
|
120
|
+
// Label and target may each hold one level of brackets (`[app/[id]/x.ts](app/[id]/x.ts)`,
|
|
121
|
+
// a linked `src/app/(auth)/page.tsx`); the label never spans a `[`, so a run of `[` fails fast.
|
|
122
|
+
const links = prompt.replace(/\[(?:[^[\]\n]|\[[^[\]\n]{0,256}\]){0,256}\]\(((?:[^()\s]|\([^()\s]{0,256}\)){1,1024})\)/g, " $1 ");
|
|
123
|
+
for (const raw of links.split(/\s+/)) {
|
|
124
|
+
// No repo path is this long; trimming pasted junk one bracket at a time would stall the prompt hook.
|
|
125
|
+
if (raw.length > MAX_PATH_TOKEN_CHARS)
|
|
126
|
+
continue;
|
|
127
|
+
let token = trimPathToken(raw).replace(/\\/g, "/").replace(/:\d+(?:[:-]\d+)?$/, "");
|
|
128
|
+
if (!token || /^[a-z][a-z0-9+.-]*:\/\//i.test(token))
|
|
129
|
+
continue;
|
|
130
|
+
if (!token.includes("/") && !/\.[a-z0-9]{1,8}$/i.test(token))
|
|
131
|
+
continue;
|
|
132
|
+
if (rootPrefix) {
|
|
133
|
+
const foldCase = process.platform === "win32" || /^[a-z]:\//i.test(token);
|
|
134
|
+
if (foldCase ? token.toLowerCase().startsWith(rootPrefix.toLowerCase()) : token.startsWith(rootPrefix))
|
|
135
|
+
token = token.slice(rootPrefix.length);
|
|
136
|
+
}
|
|
137
|
+
token = token.replace(/^(?:\.\/)+/, "");
|
|
138
|
+
// Absolute (outside the root) or parent-relative paths name nothing in this repo.
|
|
139
|
+
if (!token || token.startsWith("/") || /^[a-z]:/i.test(token) || token.split("/").includes(".."))
|
|
140
|
+
continue;
|
|
141
|
+
out.add(token);
|
|
142
|
+
}
|
|
143
|
+
return [...out];
|
|
144
|
+
}
|
|
145
|
+
/** Score live records against one prompt. Text fields per kind (title + rationale):
|
|
146
|
+
* decision title/context/decision; bug title/symptom/root_cause; constraint
|
|
147
|
+
* statement/rationale; finding title/observation. Path anchors: decision
|
|
148
|
+
* related_files, bug/finding affected_files, constraint scope globs. Priority
|
|
149
|
+
* ties break on the builder profile's kind/severity score in delivery.ts. */
|
|
150
|
+
export function selectForTask(records, promptText, opts = {}) {
|
|
151
|
+
const k = Math.max(0, opts.k ?? DEFAULT_K);
|
|
152
|
+
const taskTerms = lexicalTokens(promptText ?? "");
|
|
153
|
+
// Without an existence check (pure/offline) only a slashed token is trusted as a
|
|
154
|
+
// path: "e.g.", "Node.js" or "v1.2" would otherwise match a broad glob.
|
|
155
|
+
const paths = promptPaths(promptText ?? "", opts.root).filter((p) => opts.pathExists ? opts.pathExists(p) : p.includes("/"));
|
|
156
|
+
if (!taskTerms.size && !paths.length)
|
|
157
|
+
return { qualifying: [], top: [] };
|
|
158
|
+
const base = PROFILE_BASE_SCORE.builder;
|
|
159
|
+
// Tokenize every record once: the same tokens feed the document frequency and the score.
|
|
160
|
+
const candidates = [];
|
|
161
|
+
const consider = (id, kind, title, text, anchors, priority, listable) => {
|
|
162
|
+
candidates.push({ id, kind, title, tokens: lexicalTokens(text.join(" ")), anchors, priority, listable });
|
|
163
|
+
};
|
|
164
|
+
for (const c of records.constraints) {
|
|
165
|
+
consider(c.id, "constraint", c.statement, [c.statement, c.rationale], c.scope, base.constraints + SEVERITY[c.severity] * 10 + (c.provenance.confidence ?? 0), c.severity !== "blocking");
|
|
166
|
+
}
|
|
167
|
+
for (const d of records.decisions) {
|
|
168
|
+
consider(d.id, "decision", d.title, [d.title, d.context, d.decision], d.related_files, base.decisions + (d.status === "accepted" ? 20 : 0) + (d.provenance.confidence ?? 0), true);
|
|
169
|
+
}
|
|
170
|
+
for (const b of records.bugs) {
|
|
171
|
+
consider(b.id, "bug", b.title, [b.title, b.symptom, b.root_cause], b.affected_files, base.bugs + SEVERITY[b.severity] * 10 + (b.status === "open" || b.status === "regressed" ? 10 : 0), true);
|
|
172
|
+
}
|
|
173
|
+
for (const f of records.findings) {
|
|
174
|
+
consider(f.id, "finding", f.title, [f.title, f.observation], f.affected_files, base.findings + SEVERITY[f.severity] * 10, true);
|
|
175
|
+
}
|
|
176
|
+
const dfCap = Math.max(MIN_DF_CAP, Math.ceil(DF_CAP_RATIO * candidates.length));
|
|
177
|
+
const rareTerms = [...taskTerms].filter((term) => candidates.filter((c) => c.tokens.has(term)).length <= dfCap);
|
|
178
|
+
const scored = [];
|
|
179
|
+
for (const { tokens, anchors, ...rest } of candidates) {
|
|
180
|
+
let terms = 0;
|
|
181
|
+
for (const term of rareTerms)
|
|
182
|
+
if (tokens.has(term))
|
|
183
|
+
terms++;
|
|
184
|
+
const pathHits = paths.filter((p) => anchors.some((anchor) => pathMatchesGlob(p, anchor))).length;
|
|
185
|
+
if (pathHits > 0 || terms >= MIN_DISTINCT_TERMS)
|
|
186
|
+
scored.push({ ...rest, pathHits, terms });
|
|
187
|
+
}
|
|
188
|
+
// A record anchored to more of the files the prompt names outranks one that
|
|
189
|
+
// shares a single (often hub) file with it; rare terms break the remaining ties.
|
|
190
|
+
scored.sort((a, b) => b.pathHits - a.pathHits || b.terms - a.terms || b.priority - a.priority || a.id.localeCompare(b.id));
|
|
191
|
+
return {
|
|
192
|
+
qualifying: scored.map((s) => s.id),
|
|
193
|
+
top: scored.filter((s) => s.listable).slice(0, k).map(({ id, kind, title }) => ({ id, kind, title })),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
//# sourceMappingURL=taskSelection.js.map
|
|
@@ -364,7 +364,8 @@ export function writeCodexHooks(root, inv) {
|
|
|
364
364
|
return writeHookConfig(file, {
|
|
365
365
|
SessionStart: [entry()],
|
|
366
366
|
UserPromptSubmit: [entry()],
|
|
367
|
-
|
|
367
|
+
// The shell entries take the baseline a shell write is measured from.
|
|
368
|
+
PreToolUse: [entry("apply_patch|Bash|PowerShell|shell|local_shell")],
|
|
368
369
|
// Codex's native command tool arrives as `Bash` (or `PowerShell` on
|
|
369
370
|
// Windows), while older hosts may expose shell/local_shell names.
|
|
370
371
|
PostToolUse: [entry("apply_patch|Bash|PowerShell|shell|local_shell")],
|
|
@@ -180,9 +180,10 @@ export function installClaudeHooks(root, hookCmd) {
|
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
182
|
const keep = (arr) => (Array.isArray(arr) ? arr.map((entry) => withoutHunchCommands(entry, hookCmd)).filter((e) => e !== null) : []);
|
|
183
|
+
// Shell tools too: their PreToolUse takes the baseline a shell write is measured from.
|
|
183
184
|
json.hooks.PreToolUse = [
|
|
184
185
|
...keep(json.hooks.PreToolUse),
|
|
185
|
-
{ matcher: "Edit|Write|MultiEdit", hooks: [{ type: "command", command: hookCmd }] },
|
|
186
|
+
{ matcher: "Edit|Write|MultiEdit|Bash|PowerShell", hooks: [{ type: "command", command: hookCmd }] },
|
|
186
187
|
];
|
|
187
188
|
json.hooks.UserPromptSubmit = [
|
|
188
189
|
...keep(json.hooks.UserPromptSubmit),
|
|
@@ -90,7 +90,7 @@ export function registerTaskReportTools(server, getRoot, getStore) {
|
|
|
90
90
|
const launcher = verificationLauncher();
|
|
91
91
|
// Compact by design (#370): the rules an agent needs to act, and exact
|
|
92
92
|
// identities; scope, title and timestamps stay in `hunch report <id> --json`.
|
|
93
|
-
return { content: [{ type: "text", text: `Task ${task.task_id} · ${task.state}. Pass task_id to hunch_context and every decision/correction/finding capture (a capture is not proof of a commit or push). To claim an application, copy occurrence_id, record_id and content_hash exactly from hunch_report(task_id) application_references, with the action you took; omit applications you did not make. Before the final response, finish with hunch_task (outcome "interrupted" if cut short) and include its contribution card verbatim, Evidence line and agent-reported label included, unless presentation_enabled is false. Never rerun an expensive check only for reporting; missing evidence stays unverified. Checks, with this exact installation (a global hunch may be stale): ${launcher.shell} task verify ${task.task_id} -- <command> [arguments]${launcher.note} (15
|
|
93
|
+
return { content: [{ type: "text", text: `Task ${task.task_id} · ${task.state}. Pass task_id to hunch_context and every decision/correction/finding capture (a capture is not proof of a commit or push). To claim an application, copy occurrence_id, record_id and content_hash exactly from hunch_report(task_id) application_references, with the action you took; omit applications you did not make. Before the final response, finish with hunch_task (outcome "interrupted" if cut short) and include its contribution card verbatim, Evidence line and agent-reported label included, unless presentation_enabled is false. Never rerun an expensive check only for reporting; missing evidence stays unverified. Checks, with this exact installation (a global hunch may be stale): ${launcher.shell} task verify ${task.task_id} -- <command> [arguments]${launcher.note} Verify with the tests that cover your change (the files you edited and their tests); the full suite is CI's job. The default budget is 15 minutes; --timeout <seconds> before -- is only for when the whole suite is really needed.` }], structuredContent: { task: { task_id: task.task_id, state: task.state }, verification_argv: [...launcher.argv, "task", "verify", task.task_id, "--"] } };
|
|
94
94
|
}
|
|
95
95
|
if (!task_id)
|
|
96
96
|
throw new Error("finish requires the exact task_id");
|
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.43.0",
|
|
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.43.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|