@davesheffer/hunch 1.18.1 → 1.19.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 CHANGED
@@ -18,6 +18,23 @@ strict enforcement.
18
18
  **Memory is the input. The product boundary is the receipt:** relevant evidence before an edit,
19
19
  then a deterministic check of the change against the rules your team has explicitly trusted.
20
20
 
21
+ > **New in v1.19.0:** correction search now keeps the flat top five but adds
22
+ > transfer-tested semantic declaration families and an efficiency-tested progressive queue.
23
+ > Evidence receipts are read-only annotations: they never reorder candidates or claim an exact
24
+ > owner. Install the stable release with `npm i -g @davesheffer/hunch`.
25
+
26
+ The measured improvement is deliberately scoped to fresh, preregistered transfer cases:
27
+
28
+ | Measure | Flat/baseline | v1.19 bounded view | Change |
29
+ | --- | ---: | ---: | ---: |
30
+ | Changed declaration found | 3/12 (25%) | 6/12 (50%) | +25 percentage points |
31
+ | Correct file found | 8/12 (66.7%) | 10/12 (83.3%) | +16.6 percentage points |
32
+ | Declarations inspected for the same five hits | 18.9 average | 11 average | 41.9% less |
33
+
34
+ These are bounded diagnostic and inspection results, not a claim that Hunch is universally twice as
35
+ accurate. Failed evidence and causal rerankers remain disabled; the receipts annotate the shortlist
36
+ without changing its order or asserting an exact owner.
37
+
21
38
  > **New in v1.18.0:** YAML and Helm now enter the same dependency graph as application code.
22
39
  > Anchors and aliases become reference edges; chart-scoped `define`, `include`, and `template`
23
40
  > relationships survive pre-render syntax without weakening fail-closed handling for ordinary
@@ -72,10 +89,50 @@ Most memory work happens automatically after commits. These commands cover the c
72
89
  | `hunch why <file>` | Decisions, bugs, constraints, and blast radius behind a file |
73
90
  | `hunch query "<question>"` | Search project memory |
74
91
  | `hunch check --working` | Review all current changes against recorded intent |
92
+ | `hunch shortlist --issue "..."` | Experimental flat shortlist plus a progressive, file-anchored semantic inspection plan; add `--evidence receipt.json` for read-only evidence annotations |
93
+ | `hunch evidence-map receipt.json` | Compile observed probe/execution receipts without guessing an exact owner |
75
94
  | `hunch log` | See the memory timeline and its reversible moves |
76
95
  | `hunch escalations` | See the rare decisions only a human can make |
77
96
  | `hunch doctor` | Diagnose setup, provider, index, or private-overlay problems |
78
97
 
98
+ `hunch evidence-map` accepts a bounded JSON receipt containing a red target, a distinct green
99
+ control, optional execution counts, and optional intervention outcomes. It reports target-only and
100
+ shared execution plus behavior-sensitive files. It does not run the probes, edit the repository, or
101
+ claim that behaviorally influential code owns the correction. Use `--json` for the machine-readable
102
+ map; MCP clients can submit the same receipt through `hunch_evidence_map`.
103
+
104
+ `hunch shortlist --evidence` attaches authenticated observations to the relevant candidates but does
105
+ not reorder them. Three fresh transfer experiments failed to prove that execution or intervention
106
+ influence identifies the correction owner, so the production path converts that result into a hard
107
+ safeguard: no candidate is promoted or displaced by evidence. JSON output still includes a
108
+ deterministic receipt and the explicit `exact_owner_enabled: false` policy.
109
+
110
+ Every shortlist also preserves its flat top five and adds a deterministic hierarchical inspection
111
+ view anchored to those files: at most five files, two semantic declaration families per file, and
112
+ three declarations per family. On a preregistered 12-case fresh transfer, the preserved union found
113
+ 6/12 changed declarations versus 3/12 for the flat top five (**+25 percentage points**, three
114
+ rescues), while correct-file coverage improved from 8/12 to 10/12. The view averaged 18.8 unique
115
+ declarations and never exceeded 24. This promotes the clusters as a supplemental diagnostic, not as
116
+ a top-five accuracy claim; exact-owner output and per-case confidence remain disabled. JSON output
117
+ includes the deterministic cluster receipt and the transfer calibration.
118
+
119
+ The default output also turns those clusters into a progressive inspection queue. It preserves the
120
+ flat shortlist, adds only the strongest members of already-selected semantic families, stops at ten
121
+ when the behavior is explained, and permits one final fallback declaration before reporting
122
+ uncertainty. Development replay retained all 21/36 combined hits from the full cluster view while
123
+ reducing the hard inspection ceiling to 11 from an average of 19.8 declarations (44% less). On a
124
+ separate preregistered 12-case ArkType transfer it retained all 5 full-cluster hits with zero losses
125
+ and reduced mean inspection from 18.9 declarations to 11 (41.9% less). It found no additional fresh
126
+ hit, so the queue is retained as an efficiency advisory rather than promoted as an accuracy gain.
127
+
128
+ Follow-up optimization attempts stay out of production. Replacing cluster slots with same-file
129
+ declarations produced four development rescues but also three losses. Appending two same-file slots
130
+ removed those development losses, but a second blind 12-case ArkType transfer produced 3/12 hits for
131
+ both the existing and expanded plans, with zero rescues. Product-source filtering lost one prior hit,
132
+ one-hop relationship expansion added none, and evidence/causal rerankers also failed their frozen
133
+ transfer gates. The receipts remain in `bench/external/results`; rejected mechanisms cannot silently
134
+ change the production ordering.
135
+
79
136
  Corrections can become scoped rules, but captured memory cannot hard-block on its own. Enforcement is
80
137
  deterministic and opt-in:
81
138
 
@@ -92,7 +149,7 @@ Git repo that every teammate can access, install the Matrix release on team mach
92
149
  have one maintainer run:
93
150
 
94
151
  ```bash
95
- npm i -g @davesheffer/hunch@1.18.1
152
+ npm i -g @davesheffer/hunch@1.19.0
96
153
  hunch shared --repo git@github.com:acme/project-hunch-memory.git
97
154
  git add .gitignore .hunch/team.json
98
155
  git commit -m "chore: connect shared Hunch memory"
@@ -107,7 +164,7 @@ printed by Hunch. Omit `--migrate` for a new setup.
107
164
  After the pointer commit lands, teammates need Hunch installed and Git access to the memory repo:
108
165
 
109
166
  ```bash
110
- npm i -g @davesheffer/hunch@1.18.1
167
+ npm i -g @davesheffer/hunch@1.19.0
111
168
  git pull
112
169
  hunch init
113
170
  hunch doctor
package/dist/cli/index.js CHANGED
@@ -52,6 +52,9 @@ import { writeMcpJson, writeSlashCommands, installClaudeHooks } from "../integra
52
52
  import { scaffoldProviders, regenerateGrounding, refreshExistingGrounding, refreshCommittableGrounding } from "../integrations/providers.js";
53
53
  import { healClaudeConfigCaseSplit } from "../integrations/claudeConfig.js";
54
54
  import { formatContext, formatStructure } from "../core/format.js";
55
+ import { diagnoseIssueCorrectionStage, formatCorrectionStageDiagnostic } from "../core/correctionStage.js";
56
+ import { compileVerifiedEvidenceMap, formatVerifiedEvidenceMap } from "../core/evidenceMap.js";
57
+ import { collectCorrectionStageSources } from "../extractors/correctionSources.js";
55
58
  import { buildDeliveryEnvelope } from "../core/delivery.js";
56
59
  import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
57
60
  import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
@@ -61,7 +64,7 @@ import { computeStats, formatStats } from "../core/stats.js";
61
64
  import { injectionMode, resetSessionInjections } from "../core/hookcache.js";
62
65
  import { recordServed, servedSummary } from "../core/served.js";
63
66
  import { contextHookOutput, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
64
- import { PIPELINE_LOOP, UNVERIFIED_NAG, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, savePipelineState, stopVerdict, } from "../core/pipeline.js";
67
+ import { PIPELINE_LOOP, armExecutionObligations, beforeEditProbeVerdict, compileExecutableProbes, environmentExecutableProbes, environmentExecutionObligations, executionObligationBrief, isProductPath, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, proofCheckpoint, savePipelineState, stopVerdict, unverifiedNag, } from "../core/pipeline.js";
65
68
  import { draftDuplicateOf, isAcceptedDuplicateAnchor } from "../core/dupdetect.js";
66
69
  import { planAutoReview, planMutations } from "../core/autoreview.js";
67
70
  import { loadGoldenSet, evaluateRetrieval, evaluateTraversalLift } from "../eval/harness.js";
@@ -3636,6 +3639,69 @@ program
3636
3639
  console.log(`· ${rel(root, r.path)} already exists — left untouched. Delete it to regenerate.`);
3637
3640
  }
3638
3641
  });
3642
+ // ---- shortlist (experimental correction-stage diagnostic) -----------------
3643
+ program
3644
+ .command("shortlist")
3645
+ .description("Experimental repository-adaptive diagnostic: preserve a flat top five, add file-anchored semantic clusters, and emit an efficiency-tested advisory inspection plan capped at eleven. --evidence is annotation-only; no exact-owner claim.")
3646
+ .argument("<issue...>", "issue report or reproduction prose")
3647
+ .option("--limit <n>", "candidate count (1-5)", "5")
3648
+ .option("--evidence <receipt>", "verified-evidence JSON receipt path, or - for stdin")
3649
+ .option("--json", "emit the diagnostic as JSON")
3650
+ .action((issueParts, opts) => {
3651
+ const issue = issueParts.join(" ").trim();
3652
+ if (!issue)
3653
+ return fail("issue text must not be empty");
3654
+ const rawLimit = Number(opts.limit);
3655
+ if (!Number.isSafeInteger(rawLimit) || rawLimit < 1 || rawLimit > 5)
3656
+ return fail("--limit must be an integer from 1 to 5");
3657
+ const root = findRoot();
3658
+ try {
3659
+ let evidence = undefined;
3660
+ if (opts.evidence) {
3661
+ const raw = opts.evidence === "-" ? readFileSync(0, "utf8") : readFileSync(resolve(opts.evidence), "utf8");
3662
+ if (Buffer.byteLength(raw, "utf8") > 1_000_000)
3663
+ return fail("evidence receipt exceeds the 1 MB safety limit");
3664
+ evidence = JSON.parse(raw);
3665
+ }
3666
+ const collection = collectCorrectionStageSources(root, issue);
3667
+ const diagnostic = diagnoseIssueCorrectionStage(issue, collection.sources, rawLimit, evidence);
3668
+ if (opts.json) {
3669
+ console.log(JSON.stringify({
3670
+ ...diagnostic,
3671
+ scan: {
3672
+ files_read: collection.files_read,
3673
+ bytes_read: collection.bytes_read,
3674
+ files_skipped: collection.files_skipped,
3675
+ },
3676
+ }, null, 2));
3677
+ }
3678
+ else {
3679
+ console.log(formatCorrectionStageDiagnostic(diagnostic));
3680
+ console.log(`Scan: ${collection.files_read} source file(s), ${collection.files_skipped} skipped by safety/budget limits.`);
3681
+ }
3682
+ }
3683
+ catch (error) {
3684
+ fail(`could not build the correction-stage shortlist: ${error.message}`);
3685
+ }
3686
+ });
3687
+ // ---- evidence-map (compile supplied behavioral receipts) ------------------
3688
+ program
3689
+ .command("evidence-map")
3690
+ .description("Compile authenticated probe/execution/intervention receipts into a read-only evidence map. Runs no code and never claims an exact owner.")
3691
+ .argument("<receipt>", "JSON receipt path, or - to read stdin")
3692
+ .option("--json", "emit the evidence map as JSON")
3693
+ .action((receiptFile, opts) => {
3694
+ try {
3695
+ const raw = receiptFile === "-" ? readFileSync(0, "utf8") : readFileSync(resolve(receiptFile), "utf8");
3696
+ if (Buffer.byteLength(raw, "utf8") > 1_000_000)
3697
+ return fail("evidence receipt exceeds the 1 MB safety limit");
3698
+ const map = compileVerifiedEvidenceMap(JSON.parse(raw));
3699
+ console.log(opts.json ? JSON.stringify(map, null, 2) : formatVerifiedEvidenceMap(map));
3700
+ }
3701
+ catch (error) {
3702
+ fail(`could not compile the verified evidence map: ${error.message}`);
3703
+ }
3704
+ });
3639
3705
  // ---- context (surgical retrieval) -----------------------------------------
3640
3706
  program
3641
3707
  .command("context")
@@ -3939,18 +4005,33 @@ program
3939
4005
  // Verification pipeline (delivery enforced, not hoped for — see core/pipeline.ts).
3940
4006
  // PostToolUse records facts; Stop gates on them. Both are pipeline-only events,
3941
4007
  // handled before the grounding dispatch below.
3942
- if (evt.hook_event_name === "PostToolUse" && evt.session_id && pipelineEnabled()) {
4008
+ if ((evt.hook_event_name === "PostToolUse" || evt.hook_event_name === "PostToolUseFailure") && evt.session_id && pipelineEnabled()) {
3943
4009
  let st = loadPipelineState(evt.session_id);
4010
+ const before = st;
4011
+ let activity = null;
3944
4012
  if (/^(Edit|Write|MultiEdit)$/.test(evt.tool_name ?? "")) {
3945
4013
  const p = evt.tool_input?.file_path;
3946
- if (p)
4014
+ if (p) {
3947
4015
  st = onEdit(st, toRepoRel(root, p));
4016
+ activity = { kind: "edit" };
4017
+ }
3948
4018
  }
3949
4019
  else if (evt.tool_name === "Bash" || evt.tool_name === "PowerShell") {
3950
- st = onCommand(st, String(evt.tool_input?.command ?? ""));
4020
+ const command = String(evt.tool_input?.command ?? "");
4021
+ st = onCommand(st, command, evt.tool_outcome);
4022
+ activity = { kind: "command", command };
3951
4023
  }
3952
4024
  else if (evt.tool_name === "Skill") {
3953
4025
  st = onSkill(st, String(evt.tool_input?.skill ?? ""));
4026
+ activity = { kind: "skill" };
4027
+ }
4028
+ if (activity) {
4029
+ const checkpoint = proofCheckpoint(before, st, activity);
4030
+ st = checkpoint.state;
4031
+ savePipelineState(evt.session_id, st);
4032
+ if (checkpoint.reminder)
4033
+ emitContext(provider, evt.hook_event_name, checkpoint.reminder);
4034
+ return;
3954
4035
  }
3955
4036
  savePipelineState(evt.session_id, st);
3956
4037
  return;
@@ -3982,8 +4063,8 @@ program
3982
4063
  if (evt.session_id && pipelineEnabled()) {
3983
4064
  const st = onPrompt(loadPipelineState(evt.session_id));
3984
4065
  savePipelineState(evt.session_id, st);
3985
- if (!st.verifyAfterEdit) {
3986
- text += `\n\n${UNVERIFIED_NAG}`;
4066
+ if (!st.verifyAfterEdit || st.obligations.some((item) => item.status !== "satisfied")) {
4067
+ text += `\n\n${unverifiedNag(st)}`;
3987
4068
  mustDeliver = true;
3988
4069
  }
3989
4070
  }
@@ -4097,6 +4178,15 @@ program
4097
4178
  // is instead of pulling (or worse, grepping) for it. Cheap reads only
4098
4179
  // (no reindex, no drift walk); public store only — session transcripts
4099
4180
  // travel further than a terminal. Union view: `hunch now --private`.
4181
+ let controllerBrief = "";
4182
+ if (evt.session_id && pipelineEnabled()) {
4183
+ const state = armExecutionObligations(loadPipelineState(evt.session_id), [
4184
+ ...compileExecutableProbes(environmentExecutableProbes()),
4185
+ ...environmentExecutionObligations(),
4186
+ ], { replaceOrigin: "episode" });
4187
+ savePipelineState(evt.session_id, state);
4188
+ controllerBrief = executionObligationBrief(state);
4189
+ }
4100
4190
  const s = new HunchStore(paths);
4101
4191
  try {
4102
4192
  // Mode-aware: in unified ("shared") mode the public `.hunch/` is only a routing
@@ -4109,7 +4199,7 @@ program
4109
4199
  if (!decisions.length) {
4110
4200
  // Fresh graph: nothing to orient on, but the operating loop still ships.
4111
4201
  if (pipelineEnabled())
4112
- emitContext(provider, "SessionStart", PIPELINE_LOOP);
4202
+ emitContext(provider, "SessionStart", [PIPELINE_LOOP, controllerBrief].filter(Boolean).join("\n\n"));
4113
4203
  return;
4114
4204
  }
4115
4205
  const L = [];
@@ -4142,6 +4232,8 @@ program
4142
4232
  // (the zod bench showed ambient skills are read in ~0% of sessions).
4143
4233
  if (pipelineEnabled())
4144
4234
  L.push("", PIPELINE_LOOP);
4235
+ if (controllerBrief)
4236
+ L.push("", controllerBrief);
4145
4237
  const orientation = L.join("\n");
4146
4238
  // Antigravity's nearest equivalent is PreInvocation, which can fire
4147
4239
  // repeatedly in one conversation. Deduplicate it just like edit
@@ -4165,6 +4257,17 @@ program
4165
4257
  // → nothing for Hunch to say.
4166
4258
  if (!target || target.startsWith("..") || /^[a-zA-Z]:/.test(target))
4167
4259
  return;
4260
+ // A compiled red→green probe is only meaningful if its red receipt exists
4261
+ // before implementation. Firm/strict may deny two edits per prompt, then
4262
+ // fail open so a malformed or unavailable probe can never deadlock work.
4263
+ if ((firmness === "firm" || firmness === "strict") && evt.session_id && pipelineEnabled() && isProductPath(target)) {
4264
+ const baseline = beforeEditProbeVerdict(loadPipelineState(evt.session_id));
4265
+ if (baseline.block) {
4266
+ savePipelineState(evt.session_id, baseline.state);
4267
+ emitDeny(provider, baseline.reason ?? "Hunch evidence gate — establish the pre-edit probe baseline first.");
4268
+ return;
4269
+ }
4270
+ }
4168
4271
  // Pre-edit grounding must resolve the same advertised graph as every CLI
4169
4272
  // and MCP consumer. Any unavailable/mismatched team route falls through to
4170
4273
  // the outer fail-open catch and emits nothing, preserving the hook's
@@ -68,6 +68,42 @@ function normalizeToolInput(value) {
68
68
  };
69
69
  return Object.values(out).some((v) => v !== undefined) ? out : undefined;
70
70
  }
71
+ const MAX_TOOL_OUTPUT = 200_000;
72
+ function toolOutput(value) {
73
+ if (typeof value === "string")
74
+ return value.slice(0, MAX_TOOL_OUTPUT);
75
+ const raw = obj(value);
76
+ if (raw) {
77
+ const parts = ["stdout", "stderr", "output", "content", "error", "message"]
78
+ .map((key) => raw[key])
79
+ .flatMap((item) => typeof item === "string" && item ? [item] : []);
80
+ if (parts.length)
81
+ return parts.join("\n").slice(0, MAX_TOOL_OUTPUT);
82
+ }
83
+ try {
84
+ return JSON.stringify(value ?? "").slice(0, MAX_TOOL_OUTPUT);
85
+ }
86
+ catch {
87
+ return "";
88
+ }
89
+ }
90
+ function normalizeToolOutcome(input, event) {
91
+ if (event !== "PostToolUse" && event !== "PostToolUseFailure")
92
+ return undefined;
93
+ const response = input.tool_response ?? input.toolResponse ?? input.tool_result ?? input.toolResult;
94
+ return {
95
+ // Claude Code splits successful and failed calls into separate lifecycle
96
+ // events. Providers without that split may expose an explicit result flag.
97
+ status: event === "PostToolUseFailure"
98
+ ? "failure"
99
+ : obj(response)?.success === false || obj(response)?.is_error === true || obj(response)?.isError === true
100
+ ? "failure"
101
+ : "success",
102
+ output: event === "PostToolUseFailure"
103
+ ? toolOutput(input.error ?? response)
104
+ : toolOutput(response),
105
+ };
106
+ }
71
107
  function eventName(value, provider) {
72
108
  if (typeof value !== "string")
73
109
  return undefined;
@@ -75,6 +111,7 @@ function eventName(value, provider) {
75
111
  const map = {
76
112
  pretooluse: "PreToolUse",
77
113
  posttooluse: "PostToolUse",
114
+ posttoolusefailure: "PostToolUseFailure",
78
115
  userpromptsubmit: "UserPromptSubmit",
79
116
  sessionstart: "SessionStart",
80
117
  subagentstart: "SubagentStart",
@@ -139,11 +176,13 @@ export function normalizeHookEvent(raw, provider) {
139
176
  return null;
140
177
  const info = obj(input.tool_info) ?? obj(input.toolInput) ?? obj(input.tool_input);
141
178
  const toolInput = normalizeToolInput(info);
179
+ const toolOutcome = normalizeToolOutcome(input, event);
142
180
  return {
143
181
  hook_event_name: event,
144
182
  session_id: stringAt(input, "trajectory_id", "session_id", "sessionId"),
145
183
  tool_name: hunchToolName(stringAt(input, "agent_action_name", "tool_name", "toolName"), toolInput ?? {}),
146
184
  tool_input: toolInput,
185
+ ...(toolOutcome ? { tool_outcome: toolOutcome } : {}),
147
186
  prompt: stringAt(input, "prompt", "user_prompt", "userPrompt"),
148
187
  };
149
188
  }
@@ -151,11 +190,13 @@ export function normalizeHookEvent(raw, provider) {
151
190
  if (!event)
152
191
  return null;
153
192
  const toolInput = normalizeToolInput(input.tool_input ?? input.toolInput);
193
+ const toolOutcome = normalizeToolOutcome(input, event);
154
194
  return {
155
195
  hook_event_name: event,
156
196
  session_id: stringAt(input, "session_id", "sessionId", "conversation_id", "conversationId"),
157
197
  tool_name: hunchToolName(stringAt(input, "tool_name", "toolName"), toolInput ?? {}),
158
198
  tool_input: toolInput,
199
+ ...(toolOutcome ? { tool_outcome: toolOutcome } : {}),
159
200
  prompt: stringAt(input, "prompt", "user_prompt", "userPrompt"),
160
201
  source: stringAt(input, "source"),
161
202
  agent_type: stringAt(input, "agent_type", "agentType", "subagent_type", "subagentType"),