@davesheffer/hunch 1.18.0 → 1.19.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,11 +18,19 @@ 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
+ > **Release candidate v1.19.0-rc.1:** 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 candidate explicitly with `npm i -g @davesheffer/hunch@1.19.0-rc.1`.
25
+
21
26
  > **New in v1.18.0:** YAML and Helm now enter the same dependency graph as application code.
22
27
  > Anchors and aliases become reference edges; chart-scoped `define`, `include`, and `template`
23
28
  > relationships survive pre-render syntax without weakening fail-closed handling for ordinary
24
29
  > invalid YAML.
25
30
 
31
+ Read the [v1.18 release story](https://hunch-pi.vercel.app/blog/post?slug=configuration-joins-the-graph)
32
+ for the graph semantics, chart boundaries, honest limits, and Oliver Sampson's contribution.
33
+
26
34
  See the public [roadmap](ROADMAP.md) for what is next and what is deliberately out of scope.
27
35
 
28
36
  ## Start in five minutes
@@ -69,10 +77,50 @@ Most memory work happens automatically after commits. These commands cover the c
69
77
  | `hunch why <file>` | Decisions, bugs, constraints, and blast radius behind a file |
70
78
  | `hunch query "<question>"` | Search project memory |
71
79
  | `hunch check --working` | Review all current changes against recorded intent |
80
+ | `hunch shortlist --issue "..."` | Experimental flat shortlist plus a progressive, file-anchored semantic inspection plan; add `--evidence receipt.json` for read-only evidence annotations |
81
+ | `hunch evidence-map receipt.json` | Compile observed probe/execution receipts without guessing an exact owner |
72
82
  | `hunch log` | See the memory timeline and its reversible moves |
73
83
  | `hunch escalations` | See the rare decisions only a human can make |
74
84
  | `hunch doctor` | Diagnose setup, provider, index, or private-overlay problems |
75
85
 
86
+ `hunch evidence-map` accepts a bounded JSON receipt containing a red target, a distinct green
87
+ control, optional execution counts, and optional intervention outcomes. It reports target-only and
88
+ shared execution plus behavior-sensitive files. It does not run the probes, edit the repository, or
89
+ claim that behaviorally influential code owns the correction. Use `--json` for the machine-readable
90
+ map; MCP clients can submit the same receipt through `hunch_evidence_map`.
91
+
92
+ `hunch shortlist --evidence` attaches authenticated observations to the relevant candidates but does
93
+ not reorder them. Three fresh transfer experiments failed to prove that execution or intervention
94
+ influence identifies the correction owner, so the production path converts that result into a hard
95
+ safeguard: no candidate is promoted or displaced by evidence. JSON output still includes a
96
+ deterministic receipt and the explicit `exact_owner_enabled: false` policy.
97
+
98
+ Every shortlist also preserves its flat top five and adds a deterministic hierarchical inspection
99
+ view anchored to those files: at most five files, two semantic declaration families per file, and
100
+ three declarations per family. On a preregistered 12-case fresh transfer, the preserved union found
101
+ 6/12 changed declarations versus 3/12 for the flat top five (**+25 percentage points**, three
102
+ rescues), while correct-file coverage improved from 8/12 to 10/12. The view averaged 18.8 unique
103
+ declarations and never exceeded 24. This promotes the clusters as a supplemental diagnostic, not as
104
+ a top-five accuracy claim; exact-owner output and per-case confidence remain disabled. JSON output
105
+ includes the deterministic cluster receipt and the transfer calibration.
106
+
107
+ The default output also turns those clusters into a progressive inspection queue. It preserves the
108
+ flat shortlist, adds only the strongest members of already-selected semantic families, stops at ten
109
+ when the behavior is explained, and permits one final fallback declaration before reporting
110
+ uncertainty. Development replay retained all 21/36 combined hits from the full cluster view while
111
+ reducing the hard inspection ceiling to 11 from an average of 19.8 declarations (44% less). On a
112
+ separate preregistered 12-case ArkType transfer it retained all 5 full-cluster hits with zero losses
113
+ and reduced mean inspection from 18.9 declarations to 11 (41.9% less). It found no additional fresh
114
+ hit, so the queue is retained as an efficiency advisory rather than promoted as an accuracy gain.
115
+
116
+ Follow-up optimization attempts stay out of production. Replacing cluster slots with same-file
117
+ declarations produced four development rescues but also three losses. Appending two same-file slots
118
+ removed those development losses, but a second blind 12-case ArkType transfer produced 3/12 hits for
119
+ both the existing and expanded plans, with zero rescues. Product-source filtering lost one prior hit,
120
+ one-hop relationship expansion added none, and evidence/causal rerankers also failed their frozen
121
+ transfer gates. The receipts remain in `bench/external/results`; rejected mechanisms cannot silently
122
+ change the production ordering.
123
+
76
124
  Corrections can become scoped rules, but captured memory cannot hard-block on its own. Enforcement is
77
125
  deterministic and opt-in:
78
126
 
@@ -89,7 +137,7 @@ Git repo that every teammate can access, install the Matrix release on team mach
89
137
  have one maintainer run:
90
138
 
91
139
  ```bash
92
- npm i -g @davesheffer/hunch@1.18.0
140
+ npm i -g @davesheffer/hunch@1.18.1
93
141
  hunch shared --repo git@github.com:acme/project-hunch-memory.git
94
142
  git add .gitignore .hunch/team.json
95
143
  git commit -m "chore: connect shared Hunch memory"
@@ -104,7 +152,7 @@ printed by Hunch. Omit `--migrate` for a new setup.
104
152
  After the pointer commit lands, teammates need Hunch installed and Git access to the memory repo:
105
153
 
106
154
  ```bash
107
- npm i -g @davesheffer/hunch@1.18.0
155
+ npm i -g @davesheffer/hunch@1.18.1
108
156
  git pull
109
157
  hunch init
110
158
  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"),