@davesheffer/hunch 1.41.6 → 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.
Files changed (48) hide show
  1. package/README.md +5 -1
  2. package/dist/cli/index.js +489 -167
  3. package/dist/constitution/experiment.d.ts +3 -3
  4. package/dist/constitution/g3.d.ts +1 -1
  5. package/dist/core/delivery.d.ts +12 -0
  6. package/dist/core/delivery.js +4 -4
  7. package/dist/core/footprint.d.ts +15 -0
  8. package/dist/core/footprint.js +167 -0
  9. package/dist/core/groundingLag.d.ts +15 -0
  10. package/dist/core/groundingLag.js +27 -0
  11. package/dist/core/hookText.d.ts +4 -0
  12. package/dist/core/hookText.js +8 -0
  13. package/dist/core/hookcache.d.ts +22 -0
  14. package/dist/core/hookcache.js +53 -2
  15. package/dist/core/pipeline.d.ts +28 -0
  16. package/dist/core/pipeline.js +50 -0
  17. package/dist/core/publication.js +17 -3
  18. package/dist/core/shellwrites.d.ts +7 -0
  19. package/dist/core/shellwrites.js +131 -0
  20. package/dist/core/siblingfix.d.ts +124 -0
  21. package/dist/core/siblingfix.js +814 -0
  22. package/dist/core/taskReportHook.d.ts +1 -1
  23. package/dist/core/taskReportHook.js +20 -4
  24. package/dist/core/taskSelection.d.ts +67 -0
  25. package/dist/core/taskSelection.js +196 -0
  26. package/dist/extractors/git.d.ts +31 -0
  27. package/dist/extractors/git.js +180 -1
  28. package/dist/extractors/nativeTreeSitter.d.ts +2 -1
  29. package/dist/extractors/nativeTreeSitter.js +128 -30
  30. package/dist/integrations/claudemd.d.ts +20 -2
  31. package/dist/integrations/claudemd.js +79 -53
  32. package/dist/integrations/providers.d.ts +9 -5
  33. package/dist/integrations/providers.js +36 -23
  34. package/dist/integrations/scaffold.js +2 -1
  35. package/dist/integrations/team.d.ts +24 -4
  36. package/dist/integrations/team.js +154 -16
  37. package/dist/integrations/worktree.d.ts +3 -2
  38. package/dist/integrations/worktree.js +7 -4
  39. package/dist/mcp/server.d.ts +489 -0
  40. package/dist/mcp/server.js +208 -62
  41. package/dist/mcp/taskReportTools.js +12 -9
  42. package/dist/mcp/toolset.d.ts +11 -1
  43. package/dist/mcp/toolset.js +28 -8
  44. package/dist/store/hunchStore.d.ts +25 -1
  45. package/dist/store/hunchStore.js +146 -21
  46. package/dist/store/jsonStore.js +25 -4
  47. package/package.json +1 -1
  48. package/server.json +2 -2
@@ -59,8 +59,8 @@ export declare const ExperimentCaseBankSchema: z.ZodObject<{
59
59
  id: z.ZodString;
60
60
  content_hash: z.ZodString;
61
61
  experiment: z.ZodEnum<{
62
- "EXP-03": "EXP-03";
63
62
  "EXP-01": "EXP-01";
63
+ "EXP-03": "EXP-03";
64
64
  }>;
65
65
  preregistration_id: z.ZodString;
66
66
  preregistration_hash: z.ZodString;
@@ -137,8 +137,8 @@ export declare const ExperimentRunSchema: z.ZodObject<{
137
137
  id: z.ZodString;
138
138
  content_hash: z.ZodString;
139
139
  experiment: z.ZodEnum<{
140
- "EXP-03": "EXP-03";
141
140
  "EXP-01": "EXP-01";
141
+ "EXP-03": "EXP-03";
142
142
  }>;
143
143
  preregistration_id: z.ZodString;
144
144
  preregistration_hash: z.ZodString;
@@ -210,8 +210,8 @@ export declare const ExperimentOutcomeSchema: z.ZodObject<{
210
210
  run_id: z.ZodString;
211
211
  assignment_id: z.ZodString;
212
212
  experiment: z.ZodEnum<{
213
- "EXP-03": "EXP-03";
214
213
  "EXP-01": "EXP-01";
214
+ "EXP-03": "EXP-03";
215
215
  }>;
216
216
  arm: z.ZodString;
217
217
  status: z.ZodEnum<{
@@ -42,8 +42,8 @@ export declare const ExperimentPreregistrationSchema: z.ZodObject<{
42
42
  id: z.ZodString;
43
43
  content_hash: z.ZodString;
44
44
  experiment: z.ZodEnum<{
45
- "EXP-03": "EXP-03";
46
45
  "EXP-01": "EXP-01";
46
+ "EXP-03": "EXP-03";
47
47
  }>;
48
48
  revision: z.ZodNumber;
49
49
  hypothesis: z.ZodString;
@@ -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
@@ -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)));
@@ -0,0 +1,15 @@
1
+ export interface FootprintSurface {
2
+ id: string;
3
+ chars: number;
4
+ est_tokens: number;
5
+ detail?: Record<string, number>;
6
+ }
7
+ export interface FootprintReport {
8
+ schema: "hunch.footprint/1";
9
+ estimate: "chars/4";
10
+ surfaces: FootprintSurface[];
11
+ unmeasured: string[];
12
+ }
13
+ export declare function measureFootprint(root: string, opts?: {
14
+ target?: string;
15
+ }): Promise<FootprintReport>;
@@ -0,0 +1,167 @@
1
+ // Context footprint (#372): how much text Hunch injects into an agent's
2
+ // context, measured deterministically from the same code paths the product
3
+ // serves. Tokens are an estimate (characters / 4), not a tokenizer.
4
+ import { execFileSync } from "node:child_process";
5
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
9
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
10
+ import { buildServer } from "../mcp/server.js";
11
+ import { renderHunchSection } from "../integrations/claudemd.js";
12
+ import { HunchStore } from "../store/hunchStore.js";
13
+ import { hunchPaths } from "./paths.js";
14
+ import { PIPELINE_LOOP } from "./pipeline.js";
15
+ import { HOOK_REMINDER } from "./hookText.js";
16
+ import { taskInstruction } from "./taskReportHook.js";
17
+ const CONTEXT_BUDGET = 1500;
18
+ const estTokens = (chars) => Math.ceil(chars / 4);
19
+ const surface = (id, chars, detail) => ({ id, chars, est_tokens: estTokens(chars), ...(detail ? { detail } : {}) });
20
+ const jsonChars = (v) => (v === undefined ? 0 : JSON.stringify(v).length);
21
+ /** A host shows one channel of a tool result: Claude Code shows only
22
+ * structuredContent, text-only hosts only content. The larger one is the cost. */
23
+ function hostVisible(id, result) {
24
+ const content = jsonChars(result.content), structured = jsonChars(result.structuredContent);
25
+ return surface(id, Math.max(content, structured), { content_chars: content, structured_chars: structured });
26
+ }
27
+ /** The hunch_task start and finish results for a task with one delivered lesson.
28
+ * Driven in a throwaway store, never `root`: a task writes a ledger row, and
29
+ * `taskRecords: false` keeps finish from writing or committing a graph record. */
30
+ async function measureTaskLifecycle() {
31
+ const dir = mkdtempSync(join(tmpdir(), "hunch-footprint-"));
32
+ try {
33
+ // A real task runs in a git repo; the source snapshot reads git.
34
+ try {
35
+ execFileSync("git", ["init", "-q", dir], { stdio: "ignore" });
36
+ }
37
+ catch { /* measured without git */ }
38
+ const store = new HunchStore(hunchPaths(dir));
39
+ try {
40
+ store.json.ensureDirs();
41
+ writeFileSync(join(dir, ".hunch", "local.json"), JSON.stringify({ taskRecords: false, autoCommit: false }));
42
+ store.json.put("constraints", {
43
+ id: "con_footprint_sample", type: "architecture", statement: "Tool results stay machine-readable.",
44
+ scope: ["src/sample.ts"], severity: "blocking", enforcement: "advisory_v1", match: null, forbids: null,
45
+ rationale: "Orchestrators must not parse prose.", source_decision: null, violations: [], status: "active",
46
+ valid_from: "2026-01-01T00:00:00.000Z", valid_to: null,
47
+ provenance: { source: "human_confirmed", confidence: 1, evidence: [] },
48
+ });
49
+ store.reindex();
50
+ }
51
+ finally {
52
+ store.close();
53
+ }
54
+ const server = buildServer(dir);
55
+ const [ct, st] = InMemoryTransport.createLinkedPair();
56
+ const client = new Client({ name: "hunch-footprint", version: "1" });
57
+ await Promise.all([server.connect(st), client.connect(ct)]);
58
+ try {
59
+ const start = await client.callTool({ name: "hunch_task", arguments: { action: "start", title: "Assistant task" } });
60
+ const taskId = start.structuredContent?.task?.task_id;
61
+ if (start.isError || !taskId)
62
+ throw new Error("hunch_task start failed");
63
+ await client.callTool({ name: "hunch_context", arguments: { target: "src/sample.ts", task_id: taskId } });
64
+ const finish = await client.callTool({ name: "hunch_task", arguments: { action: "finish", task_id: taskId } });
65
+ if (finish.isError)
66
+ throw new Error("hunch_task finish failed");
67
+ return [hostVisible("mcp.hunch_task.start", start), hostVisible("mcp.hunch_task.finish", finish)];
68
+ }
69
+ finally {
70
+ await client.close();
71
+ await server.close();
72
+ }
73
+ }
74
+ finally {
75
+ rmSync(dir, { recursive: true, force: true });
76
+ }
77
+ }
78
+ /** Surfaces built inline from live host/session state; not measurable in-process. */
79
+ const UNMEASURED = [
80
+ "hook.session.orientation — SessionStart text is built inline in the `hook` command action (src/cli/index.ts) from live session state",
81
+ "hook.pre_edit.grounding — PreToolUse grounding is built per edited file and event",
82
+ ];
83
+ /** The file most decisions cite — a FILE target, so the brief carries per-record
84
+ * lines and omissions the way a pre-edit call does. "src" when no decision names one. */
85
+ function busiestFile(store) {
86
+ const counts = new Map();
87
+ for (const d of store.advisoryRecs("decisions")) {
88
+ for (const f of new Set(d.related_files))
89
+ if (!f.includes("*"))
90
+ counts.set(f, (counts.get(f) ?? 0) + 1);
91
+ }
92
+ return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0] ?? "src";
93
+ }
94
+ export async function measureFootprint(root, opts = {}) {
95
+ const surfaces = [];
96
+ let target = opts.target;
97
+ if (!target) {
98
+ const store = new HunchStore(hunchPaths(root));
99
+ try {
100
+ target = busiestFile(store);
101
+ }
102
+ finally {
103
+ store.close();
104
+ }
105
+ }
106
+ const server = buildServer(root);
107
+ const [ct, st] = InMemoryTransport.createLinkedPair();
108
+ const client = new Client({ name: "hunch-footprint", version: "1" });
109
+ await Promise.all([server.connect(st), client.connect(ct)]);
110
+ try {
111
+ const tools = (await client.listTools()).tools;
112
+ let outputChars = 0, inputChars = 0, descriptionChars = 0, largest = 0;
113
+ for (const t of tools) {
114
+ outputChars += jsonChars(t.outputSchema);
115
+ inputChars += jsonChars(t.inputSchema);
116
+ descriptionChars += (t.description ?? "").length;
117
+ largest = Math.max(largest, jsonChars(t));
118
+ }
119
+ surfaces.push(surface("mcp.tools_list", jsonChars(tools), {
120
+ output_schema_chars: outputChars,
121
+ input_schema_chars: inputChars,
122
+ description_chars: descriptionChars,
123
+ tools: tools.length,
124
+ largest_tool_chars: largest,
125
+ }));
126
+ // What hosts that drop outputSchema actually pay.
127
+ const core = tools.map(t => ({ name: t.name, description: t.description, inputSchema: t.inputSchema }));
128
+ surfaces.push(surface("mcp.tools_list.core", jsonChars(core)));
129
+ const result = await client.callTool({ name: "hunch_context", arguments: { target, budget_tokens: CONTEXT_BUDGET } });
130
+ // A host shows the model one channel — structuredContent when it reads it,
131
+ // content otherwise — so the cost it pays is the larger, not the sum.
132
+ const contentChars = jsonChars(result.content);
133
+ const structuredChars = jsonChars(result.structuredContent);
134
+ const chars = Math.max(contentChars, structuredChars);
135
+ surfaces.push(surface("mcp.hunch_context", chars, {
136
+ content_chars: contentChars,
137
+ structured_chars: structuredChars,
138
+ budget_tokens: CONTEXT_BUDGET,
139
+ // est_tokens / budget, ×100 as an integer percentage.
140
+ budget_ratio_pct: Math.round((estTokens(chars) / CONTEXT_BUDGET) * 100),
141
+ }));
142
+ }
143
+ finally {
144
+ await client.close();
145
+ await server.close();
146
+ }
147
+ surfaces.push(...await measureTaskLifecycle());
148
+ const store = new HunchStore(hunchPaths(root));
149
+ try {
150
+ surfaces.push(surface("grounding.block", renderHunchSection(store, root).length));
151
+ }
152
+ finally {
153
+ store.close();
154
+ }
155
+ surfaces.push(surface("hook.session.pipeline_loop", PIPELINE_LOOP.length));
156
+ surfaces.push(surface("hook.prompt.reminder", HOOK_REMINDER.length));
157
+ // Every prompt gets a task instruction: full once per session, compact after.
158
+ // Measured for a host whose stop hook closes the task, with a fixed installed
159
+ // launcher and cwd so the number does not move with the checkout path.
160
+ const sampleTask = { task_id: "htask_" + "0".repeat(24), title: "Assistant task" };
161
+ const sampleCwd = JSON.stringify("/home/user/repo");
162
+ const sampleLauncher = () => ({ shell: "node /home/user/repo/node_modules/@davesheffer/hunch/dist/cli/index.js" });
163
+ surfaces.push(surface("hook.prompt.task_instruction", taskInstruction(sampleTask, sampleCwd, "claude", sampleLauncher).length));
164
+ surfaces.push(surface("hook.prompt.task_instruction.compact", taskInstruction(sampleTask, sampleCwd, "claude", sampleLauncher, "compact").length));
165
+ return { schema: "hunch.footprint/1", estimate: "chars/4", surfaces, unmeasured: [...UNMEASURED] };
166
+ }
167
+ //# sourceMappingURL=footprint.js.map
@@ -25,6 +25,9 @@
25
25
  * run ahead (missing record). Open findings move both ways (a finding resolved on one
26
26
  * branch, a finding recorded on another), so a differing findings count alone is lag.
27
27
  */
28
+ /** The block's prose template version (claudemd.ts GROUNDING_TEMPLATE). A block
29
+ * without the stamp is template 1. */
30
+ export declare function groundingTemplate(block: string): number;
28
31
  export interface GroundingCounts {
29
32
  decisions: number;
30
33
  bugs: number;
@@ -71,11 +74,23 @@ export type GroundingFreshness =
71
74
  committed: GroundingCounts;
72
75
  generated: GroundingCounts;
73
76
  ahead: string[];
77
+ newerTemplate?: {
78
+ committed: number;
79
+ renderer: number;
80
+ };
74
81
  }
75
82
  /** The block differs outside the counts sentence (or a counts sentence is missing). */
76
83
  | {
77
84
  kind: "diverged";
78
85
  reason: string;
86
+ }
87
+ /** The committed block was written by a newer renderer template than this
88
+ * version's: its prose is preserved (preserveNewerTemplate), never a failure. */
89
+ | {
90
+ kind: "newer";
91
+ committedTemplate: number;
92
+ rendererTemplate: number;
93
+ countsReadable: boolean;
79
94
  };
80
95
  /** Classify a committed managed block against the one the graph generates NOW.
81
96
  * Both inputs are block CONTENT (markers stripped, trimmed). */
@@ -25,6 +25,13 @@
25
25
  * run ahead (missing record). Open findings move both ways (a finding resolved on one
26
26
  * branch, a finding recorded on another), so a differing findings count alone is lag.
27
27
  */
28
+ const TEMPLATE_RE = /<!-- hunch:template (\d+) -->/;
29
+ /** The block's prose template version (claudemd.ts GROUNDING_TEMPLATE). A block
30
+ * without the stamp is template 1. */
31
+ export function groundingTemplate(block) {
32
+ const m = TEMPLATE_RE.exec(block);
33
+ return m ? Number(m[1]) : 1;
34
+ }
28
35
  const COUNTS_RE = /\*\*(\d+) decisions?, (\d+) bugs?, (\d+) constraints?, (\d+) components?, (\d+) polic(?:y|ies)(?:, (\d+) open findings?)?\*\*/;
29
36
  /** Record kinds whose committed count may only ever lag behind the store. */
30
37
  export const APPEND_ONLY_COUNT_KINDS = ["decisions", "bugs", "constraints", "components", "policies"];
@@ -64,8 +71,19 @@ export function parseGroundingCounts(block) {
64
71
  export function classifyGroundingBlock(committed, generated) {
65
72
  if (committed === generated)
66
73
  return { kind: "fresh" };
74
+ const committedTemplate = groundingTemplate(committed);
75
+ const rendererTemplate = groundingTemplate(generated);
67
76
  const c = parseGroundingCounts(committed);
68
77
  const g = parseGroundingCounts(generated);
78
+ if (committedTemplate > rendererTemplate) {
79
+ // A newer template may reword the prose, but a count AHEAD of the store still
80
+ // means the doc knows a record this repository does not carry.
81
+ const ahead = c && g ? APPEND_ONLY_COUNT_KINDS.filter((k) => c.counts[k] > g.counts[k]) : [];
82
+ if (c && g && ahead.length) {
83
+ return { kind: "ahead", committed: c.counts, generated: g.counts, ahead, newerTemplate: { committed: committedTemplate, renderer: rendererTemplate } };
84
+ }
85
+ return { kind: "newer", committedTemplate, rendererTemplate, countsReadable: c !== null };
86
+ }
69
87
  if (!c)
70
88
  return { kind: "diverged", reason: "the committed block carries no record-counts sentence" };
71
89
  if (!g)
@@ -88,9 +106,18 @@ export function describeGroundingFreshness(doc, verdict) {
88
106
  case "lagging":
89
107
  return `${doc}: counts lag the store (${delta(verdict.committed, verdict.generated, verdict.behind)}) — records merged in behind the doc; heals on the next capture or \`hunch grounding --refresh\``;
90
108
  case "ahead":
109
+ // A newer Hunch may have written records this version skips as unreadable, and a
110
+ // plain refresh keeps a newer block's prose: only an upgrade or --force settles it.
111
+ if (verdict.newerTemplate) {
112
+ return `${doc}: counts run AHEAD of the store (${delta(verdict.committed, verdict.generated, verdict.ahead)}) in a block written by a newer Hunch (template ${verdict.newerTemplate.committed} > ${verdict.newerTemplate.renderer}) — this version may skip records it cannot read, or the record was removed; upgrade Hunch, or run \`hunch grounding --refresh --force\` and commit`;
113
+ }
91
114
  return `${doc}: counts run AHEAD of the store (${delta(verdict.committed, verdict.generated, verdict.ahead)}) — the doc counted a record this repository does not carry; commit the missing .hunch/ record or regenerate`;
92
115
  case "diverged":
93
116
  return `${doc}: stale — ${verdict.reason}; regenerate with \`hunch grounding --refresh\` and commit`;
117
+ case "newer":
118
+ return `${doc}: written by a newer Hunch (template ${verdict.committedTemplate} > ${verdict.rendererTemplate}); ${verdict.countsReadable
119
+ ? "its prose is kept and a refresh updates only the counts"
120
+ : "this version cannot read its counts sentence, so a refresh leaves the block as written"}. Upgrade Hunch, or run \`hunch grounding --refresh --force\` to re-render with this version`;
94
121
  }
95
122
  }
96
123
  //# sourceMappingURL=groundingLag.js.map
@@ -0,0 +1,4 @@
1
+ /** The UserPromptSubmit reminder `hunch hook` injects once per session (and with
2
+ * every correction). Lives in
3
+ * core so `hunch footprint` measures the exact text the hook sends. */
4
+ export declare const HOOK_REMINDER: string;
@@ -0,0 +1,8 @@
1
+ /** The UserPromptSubmit reminder `hunch hook` injects once per session (and with
2
+ * every correction). Lives in
3
+ * core so `hunch footprint` measures the exact text the hook sends. */
4
+ export const HOOK_REMINDER = "Hunch (engineering memory) is available for this repo. Before editing, call " +
5
+ "hunch_check_constraints(scope) for do-not-break invariants and hunch_why(target) " +
6
+ "for the rationale; use hunch_get_dependents for blast radius and hunch_bug_lineage " +
7
+ "for prior root causes. After a non-trivial choice, record it with hunch_record_decision.";
8
+ //# sourceMappingURL=hookText.js.map
@@ -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;
@@ -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 = join(tmpdir(), "hunch-hookcache");
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(tmpdir(), "hunch-hookcache", `${sessionId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 80)}.json`);
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 {
@@ -150,6 +150,23 @@ export interface PipelineState {
150
150
  /** Activity index and count for bounded mid-flight reminders. */
151
151
  proofReminderActivity: number;
152
152
  proofReminders: number;
153
+ /** Sibling-fix lessons delivered this session (see core/siblingfix.ts). */
154
+ lessons: PendingLesson[];
155
+ }
156
+ /** A delivered lesson the agent has not visibly acted on yet. `hash` is the
157
+ * target function's body at delivery; a different body later means the agent
158
+ * touched it, so the lesson is no longer pending. */
159
+ export interface PendingLesson {
160
+ id: string;
161
+ file: string;
162
+ symbol: string;
163
+ sibling: string;
164
+ siblingFile: string;
165
+ /** "<sha8> <subject>" of the change the sibling received. */
166
+ change: string;
167
+ callers: string[];
168
+ hash: string | null;
169
+ reminded: boolean;
153
170
  }
154
171
  export declare const emptyState: () => PipelineState;
155
172
  /** Validate untrusted MCP/env episode data. Invalid entries are ignored so the
@@ -273,6 +290,17 @@ export declare function proofCheckpoint(before: PipelineState, after: PipelineSt
273
290
  export declare const PIPELINE_LOOP: string;
274
291
  export declare const UNVERIFIED_NAG = "Hunch pipeline: earlier product edits are still UNVERIFIED \u2014 run the relevant test/build/typecheck before claiming anything about them.";
275
292
  export declare function unverifiedNag(state: PipelineState): string;
293
+ /** Record lessons the grounding just delivered (dedup by id; first delivery keeps its baseline). */
294
+ export declare function onLessonsDelivered(state: PipelineState, lessons: readonly Omit<PendingLesson, "reminded">[]): PipelineState;
295
+ /** The one follow-up an advisory lesson gets. It fires when the agent runs a
296
+ * check (test/build/typecheck) while a delivered lesson's function is still
297
+ * untouched: the moment the agent thinks it is done, which is when an ignored
298
+ * lesson would otherwise ship. Once per lesson, never a block. `hashOf`
299
+ * returns the function's current body hash (null: cannot tell, stay silent). */
300
+ export declare function lessonReminder(state: PipelineState, command: string, hashOf: (lesson: PendingLesson) => string | null): {
301
+ state: PipelineState;
302
+ reminder: string;
303
+ };
276
304
  /** Stop-gate verdict. Blocks only at firm/strict, only with unverified product
277
305
  * edits, and at most twice per turn. */
278
306
  export declare function stopVerdict(state: PipelineState, firmness: Firmness): {
@@ -63,6 +63,7 @@ export const emptyState = () => ({
63
63
  proofActivity: 0,
64
64
  proofReminderActivity: 0,
65
65
  proofReminders: 0,
66
+ lessons: [],
66
67
  });
67
68
  const MAX_OBLIGATIONS = 12;
68
69
  const MAX_ALTERNATIVES = 6;
@@ -1033,6 +1034,51 @@ export function unverifiedNag(state) {
1033
1034
  return generic;
1034
1035
  return `${generic} Controller obligations still pending: ${pending.slice(0, 4).map((item) => `[${item.category}] ${item.description}`).join("; ")}.`;
1035
1036
  }
1037
+ const MAX_LESSONS = 6;
1038
+ /** Any profile's check shape: the reminder fires when the agent starts proving
1039
+ * its work, whatever the domain. */
1040
+ const CHECK_SHAPE = new RegExp([...Object.values(DEFAULT_PROFILES).map((p) => p.verify.source), "node (--test|-e\\b)"].join("|"), "i");
1041
+ /** Record lessons the grounding just delivered (dedup by id; first delivery keeps its baseline). */
1042
+ export function onLessonsDelivered(state, lessons) {
1043
+ const known = new Set(state.lessons.map((l) => l.id));
1044
+ const added = lessons.filter((l) => !known.has(l.id)).map((l) => ({ ...l, reminded: false }));
1045
+ return added.length ? { ...state, lessons: [...state.lessons, ...added].slice(-MAX_LESSONS) } : state;
1046
+ }
1047
+ /** The one follow-up an advisory lesson gets. It fires when the agent runs a
1048
+ * check (test/build/typecheck) while a delivered lesson's function is still
1049
+ * untouched: the moment the agent thinks it is done, which is when an ignored
1050
+ * lesson would otherwise ship. Once per lesson, never a block. `hashOf`
1051
+ * returns the function's current body hash (null: cannot tell, stay silent). */
1052
+ export function lessonReminder(state, command, hashOf) {
1053
+ if (!CHECK_SHAPE.test(command) || !state.lessons.some((l) => !l.reminded))
1054
+ return { state, reminder: "" };
1055
+ const due = [];
1056
+ const lessons = state.lessons.map((l) => {
1057
+ if (l.reminded)
1058
+ return l;
1059
+ const now = hashOf(l);
1060
+ if (now === null || l.hash === null)
1061
+ return l;
1062
+ if (now !== l.hash)
1063
+ return { ...l, reminded: true };
1064
+ due.push(l);
1065
+ return { ...l, reminded: true };
1066
+ });
1067
+ if (!due.length)
1068
+ return { state: { ...state, lessons }, reminder: "" };
1069
+ const lines = due.map((l) => {
1070
+ const via = l.callers.length ? ` ${l.callers.slice(0, 3).map((c) => `\`${c}\``).join(", ")} call${l.callers.length === 1 ? "s" : ""} it, so your change runs through the copy that lacks the fix.` : "";
1071
+ return `- \`${l.symbol}\` (${l.file}) is unchanged since Hunch showed you the change its same-shaped sibling \`${l.sibling}\` (${l.siblingFile}) received: ${l.change}.${via}`;
1072
+ });
1073
+ return {
1074
+ state: { ...state, lessons },
1075
+ reminder: [
1076
+ "Hunch — before you finish: a lesson delivered earlier is still open.",
1077
+ ...lines,
1078
+ "The checks you are running were written before this lesson; unless one feeds this function the input the sibling's change handles, they do not show it is covered. Carry the change with a test. It does not apply only if that input cannot reach the function or is already handled another way; then say which in your final answer. \"Pre-existing\" or \"outside this task\" does not count: your change runs through this copy, so leaving it ships the gap again inside your change.",
1079
+ ].join("\n"),
1080
+ };
1081
+ }
1036
1082
  /** Stop-gate verdict. Blocks only at firm/strict, only with unverified product
1037
1083
  * edits, and at most twice per turn. */
1038
1084
  export function stopVerdict(state, firmness) {
@@ -1071,6 +1117,10 @@ export function loadPipelineState(sessionId) {
1071
1117
  state.proofReminderActivity = Number.isSafeInteger(raw.proofReminderActivity) && raw.proofReminderActivity >= 0 ? raw.proofReminderActivity : 0;
1072
1118
  state.proofReminders = Number.isSafeInteger(raw.proofReminders) && raw.proofReminders >= 0 ? raw.proofReminders : 0;
1073
1119
  state.probeBlocks = Number.isSafeInteger(raw.probeBlocks) && raw.probeBlocks >= 0 ? raw.probeBlocks : 0;
1120
+ state.lessons = (Array.isArray(raw.lessons) ? raw.lessons : []).filter((l) => !!l && typeof l === "object"
1121
+ && typeof l.id === "string" && typeof l.file === "string" && typeof l.symbol === "string" && typeof l.sibling === "string"
1122
+ && typeof l.siblingFile === "string" && typeof l.change === "string" && Array.isArray(l.callers)
1123
+ && (l.hash === null || typeof l.hash === "string") && typeof l.reminded === "boolean").slice(-MAX_LESSONS);
1074
1124
  const specs = normalizeExecutionObligations(raw.obligations);
1075
1125
  const tracked = new Map((Array.isArray(raw.obligations) ? raw.obligations : []).map((item) => {
1076
1126
  const candidate = item;
@@ -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]:[\\/]Users[\\/]([^\\/"'\s,)\]]+)/g,
38
- /(?:^|[\s"'(])\/(?:Users|home)\/([^/"'\s,)\]]+)/g,
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
  }
@@ -0,0 +1,7 @@
1
+ /** Record the working tree as it stands, so later shell writes are measured
2
+ * from here (a prompt, or any tool call that is not a shell command). */
3
+ export declare function refreshShellBaseline(root: string, sessionId: string | undefined, agentId?: string): void;
4
+ /** Repo-relative files the shell command that just ran wrote (created or
5
+ * modified), and the baseline moves forward. Empty without a baseline: a
6
+ * session's first observation cannot tell its own writes from earlier ones. */
7
+ export declare function shellWrittenFiles(root: string, sessionId: string | undefined, agentId?: string): string[];