@davesheffer/hunch 1.13.0 → 1.14.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 -4
- package/dist/cli/index.js +10 -6
- package/dist/eval/harness.js +18 -1
- package/dist/integrations/claudemd.js +1 -0
- package/dist/mcp/server.js +166 -18
- package/dist/store/hunchStore.js +97 -29
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -16,8 +16,8 @@ strict enforcement.
|
|
|
16
16
|
**Memory is the input. The product boundary is the receipt:** relevant evidence before an edit,
|
|
17
17
|
then a deterministic check of the change against the rules your team has explicitly trusted.
|
|
18
18
|
|
|
19
|
-
> **New in v1.13.
|
|
20
|
-
>
|
|
19
|
+
> **New in v1.13.1:** `hunch_context` exposes that provenance-checked, hard-budgeted delivery
|
|
20
|
+
> envelope as MCP structured output and records exactly which returned items reached the client.
|
|
21
21
|
|
|
22
22
|
See the public [roadmap](ROADMAP.md) for what is next and what is deliberately out of scope.
|
|
23
23
|
|
|
@@ -82,7 +82,7 @@ Git repo that every teammate can access, install the Matrix release on team mach
|
|
|
82
82
|
have one maintainer run:
|
|
83
83
|
|
|
84
84
|
```bash
|
|
85
|
-
npm i -g @davesheffer/hunch@1.13.
|
|
85
|
+
npm i -g @davesheffer/hunch@1.13.1
|
|
86
86
|
hunch shared --repo git@github.com:acme/project-hunch-memory.git
|
|
87
87
|
git add .gitignore .hunch/team.json
|
|
88
88
|
git commit -m "chore: connect shared Hunch memory"
|
|
@@ -97,7 +97,7 @@ printed by Hunch. Omit `--migrate` for a new setup.
|
|
|
97
97
|
After the pointer commit lands, teammates need Hunch installed and Git access to the memory repo:
|
|
98
98
|
|
|
99
99
|
```bash
|
|
100
|
-
npm i -g @davesheffer/hunch@1.13.
|
|
100
|
+
npm i -g @davesheffer/hunch@1.13.1
|
|
101
101
|
git pull
|
|
102
102
|
hunch init
|
|
103
103
|
hunch doctor
|
package/dist/cli/index.js
CHANGED
|
@@ -64,7 +64,7 @@ import { contextHookOutput, denyHookOutput, hookProvider, normalizeHookEvent, st
|
|
|
64
64
|
import { PIPELINE_LOOP, UNVERIFIED_NAG, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, savePipelineState, stopVerdict, } from "../core/pipeline.js";
|
|
65
65
|
import { draftDuplicateOf, isAcceptedDuplicateAnchor } from "../core/dupdetect.js";
|
|
66
66
|
import { planAutoReview, planMutations } from "../core/autoreview.js";
|
|
67
|
-
import { loadGoldenSet,
|
|
67
|
+
import { loadGoldenSet, evaluateRetrieval, evaluateTraversalLift } from "../eval/harness.js";
|
|
68
68
|
import { loadGuardCases, evalGuards, generateGuardCases } from "../eval/guards.js";
|
|
69
69
|
import { computeDrift } from "../core/drift.js";
|
|
70
70
|
import { renderCompilerScorecard, scoreCompilerCaseBank } from "../constitution/scorecard.js";
|
|
@@ -1349,16 +1349,20 @@ program
|
|
|
1349
1349
|
// Default is deterministic (FTS + graph, no model). --semantic only adds the
|
|
1350
1350
|
// semantic leg when embeddings actually exist; otherwise it's still FTS + graph.
|
|
1351
1351
|
const embedder = opts.semantic ? await selectEmbedder() : undefined;
|
|
1352
|
-
const
|
|
1352
|
+
const evalOpts = { k, embedder, kind: opts.kind };
|
|
1353
|
+
const off = await evaluateRetrieval(store, cases, { ...evalOpts, graphWeight: 0 });
|
|
1354
|
+
const traversal = await evaluateTraversalLift(store, cases, evalOpts);
|
|
1353
1355
|
const pct = (x) => `${(x * 100).toFixed(1)}%`;
|
|
1354
1356
|
const dpt = (x) => `${x >= 0 ? "+" : ""}${(x * 100).toFixed(1)}pt`;
|
|
1355
1357
|
const dnum = (x) => `${x >= 0 ? "+" : ""}${x.toFixed(3)}`;
|
|
1356
1358
|
console.log(`Eval over ${cases.length} case(s), k=${k}${opts.semantic ? " (semantic + graph + FTS)" : " (FTS + graph)"}\n`);
|
|
1357
1359
|
console.log(` Recall@${k} MRR hit-rate`);
|
|
1358
|
-
console.log(` graph OFF ${pct(
|
|
1359
|
-
console.log(` graph
|
|
1360
|
-
console.log(` graph
|
|
1361
|
-
|
|
1360
|
+
console.log(` graph OFF ${pct(off.recallAtK).padStart(7)} ${off.mrr.toFixed(3)} ${pct(off.hitRate)}`);
|
|
1361
|
+
console.log(` graph 1-HOP ${pct(traversal.oneHop.recallAtK).padStart(7)} ${traversal.oneHop.mrr.toFixed(3)} ${pct(traversal.oneHop.hitRate)}`);
|
|
1362
|
+
console.log(` graph BOUNDED ${pct(traversal.bounded.recallAtK).padStart(7)} ${traversal.bounded.mrr.toFixed(3)} ${pct(traversal.bounded.hitRate)}`);
|
|
1363
|
+
console.log(` graph LIFT ${dpt(traversal.bounded.recallAtK - off.recallAtK).padStart(7)} ${dnum(traversal.bounded.mrr - off.mrr)}`);
|
|
1364
|
+
console.log(` depth LIFT ${dpt(traversal.recallDelta).padStart(7)} ${dnum(traversal.mrrDelta)}`);
|
|
1365
|
+
const misses = traversal.bounded.perCase.filter((c) => c.found === 0);
|
|
1362
1366
|
if (misses.length) {
|
|
1363
1367
|
console.log(`\n ${misses.length} case(s) with no expected hit — curate or tune:`);
|
|
1364
1368
|
for (const m of misses.slice(0, 10))
|
package/dist/eval/harness.js
CHANGED
|
@@ -8,7 +8,13 @@ export async function evaluateRetrieval(store, cases, opts = {}) {
|
|
|
8
8
|
// buries terse records before any filter.
|
|
9
9
|
const hits = opts.kind
|
|
10
10
|
? await store.searchScoped(c.query, opts.kind, k, { embedder: opts.embedder })
|
|
11
|
-
: await store.hybridSearch(c.query, k, {
|
|
11
|
+
: await store.hybridSearch(c.query, k, {
|
|
12
|
+
embedder: opts.embedder,
|
|
13
|
+
graphWeight: opts.graphWeight,
|
|
14
|
+
graphDepth: opts.graphDepth,
|
|
15
|
+
graphNodeCap: opts.graphNodeCap,
|
|
16
|
+
graphTokenCap: opts.graphTokenCap,
|
|
17
|
+
});
|
|
12
18
|
const top = hits.slice(0, k).map((h) => h.ref);
|
|
13
19
|
const expected = new Set(c.expected);
|
|
14
20
|
let found = 0;
|
|
@@ -46,6 +52,17 @@ export async function evaluateGraphLift(store, cases, opts = {}) {
|
|
|
46
52
|
const on = await evaluateRetrieval(store, cases, opts);
|
|
47
53
|
return { off, on, recallDelta: on.recallAtK - off.recallAtK, mrrDelta: on.mrr - off.mrr };
|
|
48
54
|
}
|
|
55
|
+
/** Compare the historical 1-hop graph with the shipped bounded traversal. */
|
|
56
|
+
export async function evaluateTraversalLift(store, cases, opts = {}) {
|
|
57
|
+
const oneHop = await evaluateRetrieval(store, cases, { ...opts, graphDepth: 1 });
|
|
58
|
+
const bounded = await evaluateRetrieval(store, cases, opts);
|
|
59
|
+
return {
|
|
60
|
+
oneHop,
|
|
61
|
+
bounded,
|
|
62
|
+
recallDelta: bounded.recallAtK - oneHop.recallAtK,
|
|
63
|
+
mrrDelta: bounded.mrr - oneHop.mrr,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
49
66
|
/** Parse + validate a golden-set JSON string (array of {query, expected[]}). */
|
|
50
67
|
export function loadGoldenSet(raw) {
|
|
51
68
|
const data = JSON.parse(raw);
|
|
@@ -24,6 +24,7 @@ export function stripManagedSection(text) {
|
|
|
24
24
|
export function renderHunchSection(store, root) {
|
|
25
25
|
const constraints = store.json
|
|
26
26
|
.loadAll("constraints")
|
|
27
|
+
.filter((c) => c.status === "active" && !c.valid_to)
|
|
27
28
|
.sort((a, b) => sev(b.severity) - sev(a.severity))
|
|
28
29
|
.slice(0, 8);
|
|
29
30
|
const counts = {
|
package/dist/mcp/server.js
CHANGED
|
@@ -18,10 +18,12 @@ import { decisionId, findingId } from "../core/ids.js";
|
|
|
18
18
|
import { buildCorrectionConstraint } from "../core/correction.js";
|
|
19
19
|
import { knownRepoDeps } from "../synthesis/tripwires.js";
|
|
20
20
|
import { refreshExistingGrounding } from "../integrations/providers.js";
|
|
21
|
-
import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, workingFiles, workingDiff, pullHunchStatus, sameRemoteUrl } from "../extractors/git.js";
|
|
21
|
+
import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, workingFiles, workingDiff, pullHunchStatus, sameRemoteUrl, currentBranch } from "../extractors/git.js";
|
|
22
22
|
import { flushCapture, flushMemoryHome, pinSharedRemote } from "../integrations/sync.js";
|
|
23
23
|
import { advertisedTeamRemoteContract, ensureTeamOverlay, overlayMatchesTeamRemote, readTeamConfig, teamRemoteContract, teamSharedRef } from "../integrations/team.js";
|
|
24
|
-
import {
|
|
24
|
+
import { formatStructure } from "../core/format.js";
|
|
25
|
+
import { buildDeliveryEnvelope } from "../core/delivery.js";
|
|
26
|
+
import { recordServed } from "../core/served.js";
|
|
25
27
|
import { compareCandidates } from "../core/compare.js";
|
|
26
28
|
import { checkConformance } from "../core/conformance.js";
|
|
27
29
|
import { ConstitutionService, policyEvaluationEnvelope } from "../constitution/service.js";
|
|
@@ -41,6 +43,23 @@ import { existsSync } from "node:fs";
|
|
|
41
43
|
import { join } from "node:path";
|
|
42
44
|
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
43
45
|
const err = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
46
|
+
/** Shared by every auto-committing write tool (issue #20): the MCP `roots` protocol
|
|
47
|
+
* cannot see an agent-driven `cd`/EnterWorktree, so a stdio server's cached root
|
|
48
|
+
* never moves on its own — this is the client-agnostic fallback, resolved fresh on
|
|
49
|
+
* every call by the generic tool wrapper below (see extractCwdHint). */
|
|
50
|
+
const cwdHintField = z.string().optional().describe("Your ACTUAL current working directory for THIS call. Pass it whenever it differs from where this MCP " +
|
|
51
|
+
"session started — most commonly after entering a git worktree (EnterWorktree) or `cd`-ing to a different " +
|
|
52
|
+
"checkout — so the write commits to that repo/branch instead of silently landing on the server's original " +
|
|
53
|
+
"root. Omit only when you are still in the session's starting directory.");
|
|
54
|
+
/** Pull `cwd` out of a tool call's already-parsed input without assuming any one
|
|
55
|
+
* tool's exact input shape — every write tool spreads the same cwdHintField in,
|
|
56
|
+
* but the wrapper below runs for every tool, read or write. */
|
|
57
|
+
function extractCwdHint(input) {
|
|
58
|
+
if (!input || typeof input !== "object")
|
|
59
|
+
return undefined;
|
|
60
|
+
const cwd = input.cwd;
|
|
61
|
+
return typeof cwd === "string" && cwd.trim() ? cwd : undefined;
|
|
62
|
+
}
|
|
44
63
|
/** Honest auto-commit suffix: reports only what flushCapture ACTUALLY did. A skipped
|
|
45
64
|
* commit (backstop/lock/nothing staged) says nothing — the record is on disk and the
|
|
46
65
|
* next flush sweeps it up; claiming "auto-committed" there would be a lie. */
|
|
@@ -71,6 +90,21 @@ const publicHomeNote = (home, hasPrivate, record, hunchDir) => {
|
|
|
71
90
|
return risk;
|
|
72
91
|
return "\nℹ Landed in the COMMITTED PUBLIC store (publishes with the repo). For sensitive/strategy content, re-record with private:true — the overlay store." + risk;
|
|
73
92
|
};
|
|
93
|
+
/** Self-diagnosing destination report (issue #17/#20): the exact failure mode this
|
|
94
|
+
* guards against is silent — a capture landing in the wrong repo/branch with no
|
|
95
|
+
* sign of it short of a manual `git log` audit. Every auto-committing write tool
|
|
96
|
+
* appends this so the destination is always visible in the response, whether or
|
|
97
|
+
* not a cwd hint was involved in choosing it. */
|
|
98
|
+
const destinationNote = (destRoot) => {
|
|
99
|
+
const branch = currentBranch(destRoot);
|
|
100
|
+
return ` [captured${branch ? ` on branch ${branch}` : ""} in ${destRoot}]`;
|
|
101
|
+
};
|
|
102
|
+
/** Where a capture keyed to `home` actually lands: the private overlay directory when
|
|
103
|
+
* one is configured, else the public repo root. Centralizes the branch used at every
|
|
104
|
+
* destination-reporting call site below — `hunch_policy_upgrade_correction` once
|
|
105
|
+
* diverged from this (computing its own `artifactHome` but reporting the public root
|
|
106
|
+
* regardless), silently misreporting the destination for a private-homed proof. */
|
|
107
|
+
const resolveDestRoot = (home, store, root) => home === "private" && store.privateDir ? store.privateDir : root;
|
|
74
108
|
// Read-side token budgets: every tool result is injected into a Claude Code
|
|
75
109
|
// session, so an uncapped list pollutes the context window. Cap each list to its
|
|
76
110
|
// highest-signal head (records are pre-sorted by severity/confidence) and tell the
|
|
@@ -82,6 +116,61 @@ const FINDINGS_CAP = 12; // hunch_findings listing
|
|
|
82
116
|
const SEV_CONSTRAINT = { blocking: 3, warning: 2, advisory: 1 };
|
|
83
117
|
const SEV_BUG = { critical: 4, high: 3, medium: 2, low: 1 };
|
|
84
118
|
const more = (total, cap, hint = "") => total > cap ? `\n …(+${total - cap} more${hint ? ` — ${hint}` : ""})` : "";
|
|
119
|
+
/** Public MCP shape for the canonical delivery envelope. Keeping the schema on
|
|
120
|
+
* the tool means orchestrators can consume receipt facts without scraping the
|
|
121
|
+
* backward-compatible text block. */
|
|
122
|
+
const DELIVERY_OUTPUT_SCHEMA = z.object({
|
|
123
|
+
text: z.string(),
|
|
124
|
+
delivered: z.array(z.object({
|
|
125
|
+
kind: z.enum(["constraints", "decisions", "bugs", "findings"]),
|
|
126
|
+
record_id: z.string(),
|
|
127
|
+
rank: z.number().int().positive(),
|
|
128
|
+
delivery_reason: z.enum(["ranked", "blocking-reserved"]),
|
|
129
|
+
provenance_status: z.enum(["current", "unverified", "stale"]),
|
|
130
|
+
token_cost: z.number().int().nonnegative(),
|
|
131
|
+
})),
|
|
132
|
+
supplements: z.array(z.object({
|
|
133
|
+
id: z.string(),
|
|
134
|
+
kind: z.string(),
|
|
135
|
+
delivered: z.boolean(),
|
|
136
|
+
reason: z.enum(["supplemental", "budget", "empty"]),
|
|
137
|
+
rank: z.number().int().positive(),
|
|
138
|
+
token_cost: z.number().int().nonnegative(),
|
|
139
|
+
})),
|
|
140
|
+
omitted: z.array(z.object({
|
|
141
|
+
kind: z.enum(["constraints", "decisions", "bugs", "findings"]),
|
|
142
|
+
record_id: z.string(),
|
|
143
|
+
reason: z.enum(["budget", "stale-provenance", "retired"]),
|
|
144
|
+
detail: z.string(),
|
|
145
|
+
})),
|
|
146
|
+
budget_tokens: z.number().int().nonnegative(),
|
|
147
|
+
used_chars: z.number().int().nonnegative(),
|
|
148
|
+
blocking_overflow: z.boolean(),
|
|
149
|
+
});
|
|
150
|
+
/** Return the same human-readable brief older clients consume plus the exact
|
|
151
|
+
* machine-readable envelope. Receipt recording is deliberately best-effort:
|
|
152
|
+
* recordServed never throws, so telemetry can never cost a delivery. */
|
|
153
|
+
function deliveredContext(root, target, envelope, sessionId) {
|
|
154
|
+
// Validate before recording: if a future envelope change drifts from the
|
|
155
|
+
// advertised MCP contract, the SDK will reject the call and the local ledger
|
|
156
|
+
// must not claim that response was served.
|
|
157
|
+
const structuredContent = DELIVERY_OUTPUT_SCHEMA.parse(envelope);
|
|
158
|
+
recordServed(root, structuredContent.delivered.map((item) => ({
|
|
159
|
+
event: "served",
|
|
160
|
+
kind: item.kind,
|
|
161
|
+
record_id: item.record_id,
|
|
162
|
+
target,
|
|
163
|
+
session_id: sessionId,
|
|
164
|
+
rank: item.rank,
|
|
165
|
+
delivery_reason: item.delivery_reason,
|
|
166
|
+
provenance_status: item.provenance_status,
|
|
167
|
+
token_cost: item.token_cost,
|
|
168
|
+
})));
|
|
169
|
+
return {
|
|
170
|
+
content: [{ type: "text", text: structuredContent.text }],
|
|
171
|
+
structuredContent,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
85
174
|
// Capture-session tokens live in src/core/capturetoken.ts (pure + testable). These
|
|
86
175
|
// thin wrappers bind the process clock and id source at the call site (§5 Stage 1).
|
|
87
176
|
const issueCaptureToken = () => issueToken(randomUUID, Date.now());
|
|
@@ -378,6 +467,30 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
378
467
|
};
|
|
379
468
|
const registerTool = server.registerTool.bind(server);
|
|
380
469
|
server.registerTool = ((name, config, callback) => registerTool(name, config, async (...args) => {
|
|
470
|
+
// Claude Code CLI never advertises `roots`/`roots/list_changed` for an agent-driven
|
|
471
|
+
// `cd` or EnterWorktree (issue #20) — the cached `root` above just never moves, so a
|
|
472
|
+
// write silently lands wherever the process was spawned. Write tools accept an
|
|
473
|
+
// optional `cwd` argument (see cwdHintField) as a client-agnostic fallback: resolved
|
|
474
|
+
// fresh on EVERY call instead of trusted from a cache, and re-homing this stdio
|
|
475
|
+
// process the same way a `roots` notification would. Only safe when this is the
|
|
476
|
+
// sole in-flight request — re-homing under a concurrent request would tear its
|
|
477
|
+
// root/store out from under it, so that case is refused rather than risked.
|
|
478
|
+
const cwdHint = extractCwdHint(args[0]);
|
|
479
|
+
if (cwdHint !== undefined) {
|
|
480
|
+
const target = canonicalRootPath(findRoot(cwdHint));
|
|
481
|
+
if (target !== canonicalRootPath(root)) {
|
|
482
|
+
if (activeRequests) {
|
|
483
|
+
return err(`Hunch is mid-request against ${root} and cannot safely switch to the working directory you passed ` +
|
|
484
|
+
`(resolves to ${target}) while another call is in flight. Retry this call once the other one completes.`);
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
setRoot(cwdHint);
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
return err(`Failed to switch Hunch to your working directory (${cwdHint}): ${error.message}`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
381
494
|
activeRequests++;
|
|
382
495
|
try {
|
|
383
496
|
// Routing is live state, not a startup constant. A branch switch or
|
|
@@ -589,11 +702,19 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
589
702
|
budget_tokens: z.number().optional().describe("Rough token budget for the brief (default 1500)."),
|
|
590
703
|
as_of: z.string().optional().describe("Time-travel ref (commit/tag/branch): assemble the slice as it stood then."),
|
|
591
704
|
},
|
|
592
|
-
|
|
705
|
+
outputSchema: DELIVERY_OUTPUT_SCHEMA,
|
|
706
|
+
}, async ({ target, budget_tokens, as_of }, extra) => {
|
|
593
707
|
const asOf = as_of ? asOfDate(as_of, root) : undefined;
|
|
594
708
|
if (as_of && !asOf)
|
|
595
709
|
return err(`Could not resolve as_of "${as_of}" to a commit.`);
|
|
596
710
|
const ctx = store.assembleContext(target, budget_tokens ?? 1500, { asOf });
|
|
711
|
+
const options = {
|
|
712
|
+
root,
|
|
713
|
+
symbols: store.recs("symbols"),
|
|
714
|
+
components: store.recs("components"),
|
|
715
|
+
decisionCorpus: store.recs("decisions"),
|
|
716
|
+
historical: !!asOf,
|
|
717
|
+
};
|
|
597
718
|
// Task-phrase input ("improve retrieval ranking") resolves no file/symbol and
|
|
598
719
|
// used to return an empty brief while the graph held the answer — fall back to
|
|
599
720
|
// FTS so the assistant always leaves with the closest matches, not a shrug.
|
|
@@ -601,17 +722,30 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
601
722
|
if (empty && !asOf) {
|
|
602
723
|
const hits = store.search(target, 8);
|
|
603
724
|
if (hits.length) {
|
|
604
|
-
const
|
|
605
|
-
|
|
725
|
+
const resolved = hits.map((hit) => ({ hit, record: store.resolve(hit.ref)?.record }));
|
|
726
|
+
const fallback = {
|
|
727
|
+
...ctx,
|
|
728
|
+
constraints: resolved.filter(({ hit, record }) => hit.kind === "constraints" && !!record).map(({ record }) => record),
|
|
729
|
+
decisions: resolved.filter(({ hit, record }) => hit.kind === "decisions" && !!record).map(({ record }) => record),
|
|
730
|
+
bugs: resolved.filter(({ hit, record }) => hit.kind === "bugs" && !!record).map(({ record }) => record),
|
|
731
|
+
findings: resolved.filter(({ hit, record }) => hit.kind === "findings" && !!record).map(({ record }) => record),
|
|
732
|
+
};
|
|
733
|
+
const envelope = buildDeliveryEnvelope(fallback, {
|
|
734
|
+
...options,
|
|
735
|
+
supplements: hits
|
|
736
|
+
.filter((hit) => !["constraints", "decisions", "bugs", "findings"].includes(hit.kind))
|
|
737
|
+
.map((hit, index) => ({
|
|
738
|
+
id: hit.ref,
|
|
739
|
+
kind: `search-${hit.kind}`,
|
|
740
|
+
text: `${hit.ref} — ${hit.title}: ${hit.snippet}`,
|
|
741
|
+
priority: 100 - index,
|
|
742
|
+
})),
|
|
743
|
+
});
|
|
744
|
+
return deliveredContext(root, target, envelope, extra.sessionId);
|
|
606
745
|
}
|
|
607
746
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
symbols: store.recs("symbols"),
|
|
611
|
-
components: store.recs("components"),
|
|
612
|
-
decisionCorpus: store.recs("decisions"),
|
|
613
|
-
historical: !!asOf,
|
|
614
|
-
}));
|
|
747
|
+
const envelope = buildDeliveryEnvelope(ctx, options);
|
|
748
|
+
return deliveredContext(root, as_of ? `${target} (as_of:${as_of})` : target, envelope, extra.sessionId);
|
|
615
749
|
});
|
|
616
750
|
// -- hunch_now (the hot view: recent activity + roadmap) --------------------
|
|
617
751
|
// PUBLIC store only, per dec_29eff08c69's jurisdiction rule: an assistant may
|
|
@@ -779,6 +913,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
779
913
|
private: z.boolean().optional().describe("write into the PRIVATE overlay store (HUNCH_PRIVATE_DIR) instead of the committed repo — for sensitive decisions kept out of a public repo. Errors if no private store is configured."),
|
|
780
914
|
}),
|
|
781
915
|
capture_token: z.string().optional().describe("token from hunch_capture_decision — proves this write is the tail of a grilling interview. Omit only for a quick manual record (a deprecation nudge is returned)."),
|
|
916
|
+
cwd: cwdHintField,
|
|
782
917
|
},
|
|
783
918
|
}, async ({ decision, capture_token }) => {
|
|
784
919
|
try {
|
|
@@ -951,7 +1086,8 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
951
1086
|
const where = decision.private
|
|
952
1087
|
? ` [PRIVATE overlay — not committed to this repo]${flushed}`
|
|
953
1088
|
: home === "private" ? ` [SHARED store — one source of truth for the whole team]${flushed}` : flushed;
|
|
954
|
-
|
|
1089
|
+
const dest = destinationNote(resolveDestRoot(home, store, root));
|
|
1090
|
+
return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${where}${dest}${supNote}${note}${captureNote}${quality}`);
|
|
955
1091
|
}
|
|
956
1092
|
catch (e) {
|
|
957
1093
|
return err(`Failed to record decision: ${e.message}`);
|
|
@@ -971,6 +1107,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
971
1107
|
source_decision: z.string().optional().describe("id of a decision this correction derives from."),
|
|
972
1108
|
private: z.boolean().optional().describe("write into the PRIVATE overlay store (HUNCH_PRIVATE_DIR) instead of the committed repo — a sensitive rule enforced locally (pre-edit hook + local check) but never exposed in a public PR comment. Errors if no private store is configured."),
|
|
973
1109
|
capture_token: z.string().optional().describe("token from hunch_capture_decision. The rule is recorded and enforced either way — the token only decides whether it may DENY: without one it lands as advisory testimony capped at severity 'warning'."),
|
|
1110
|
+
cwd: cwdHintField,
|
|
974
1111
|
},
|
|
975
1112
|
}, async (input) => {
|
|
976
1113
|
try {
|
|
@@ -1025,7 +1162,8 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1025
1162
|
: `
|
|
1026
1163
|
|
|
1027
1164
|
⚠ Recorded WITHOUT a capture interview — this rule is agent_recorded TESTIMONY${input.severity === "blocking" ? ' and was capped from "blocking" to "warning"' : ""}. It IS enforced: the pre-edit hook and CI surface it on every matching edit from now on. What it cannot do is DENY an edit — only a rule a human countersigned may block. Countersign it by re-recording through hunch_capture_decision → hunch_record_correction(capture_token).`;
|
|
1028
|
-
|
|
1165
|
+
const dest = destinationNote(resolveDestRoot(home, store, root));
|
|
1166
|
+
return ok(`${existing ? "Updated" : "Recorded"} ${rec.severity} constraint ${rec.id}: "${rec.statement}" (scope: ${rec.scope.join(", ")}).${where}${dest} It now ${enforce}.${reviewNote}${tierNote}`);
|
|
1029
1167
|
}
|
|
1030
1168
|
catch (e) {
|
|
1031
1169
|
return err(`Failed to record correction: ${e.message}`);
|
|
@@ -1050,6 +1188,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1050
1188
|
resolved_commit: z.string().optional().describe("the commit that fixed it (with triage:'resolved')"),
|
|
1051
1189
|
private: z.boolean().optional().describe("write into the PRIVATE overlay store instead of the committed repo. Errors if no private store is configured."),
|
|
1052
1190
|
}),
|
|
1191
|
+
cwd: cwdHintField,
|
|
1053
1192
|
},
|
|
1054
1193
|
}, async ({ finding }) => {
|
|
1055
1194
|
try {
|
|
@@ -1094,7 +1233,8 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1094
1233
|
? `\n\n△ violates_constraint ${rec.violates_constraint} resolves to no known constraint — if the rule isn't recorded yet, hunch_record_correction it and re-record this finding with the real id.`
|
|
1095
1234
|
: "";
|
|
1096
1235
|
const noEvidence = rec.evidence.length ? "" : "\n\n△ No evidence attached — a finding without the query/output that produced it is an opinion. Re-record with evidence when you have it.";
|
|
1097
|
-
|
|
1236
|
+
const dest = destinationNote(resolveDestRoot(home, store, root));
|
|
1237
|
+
return ok(`${existing ? "Updated" : "Recorded"} finding ${id}: "${rec.title}" (${rec.triage}/${rec.severity}, observed ${rec.observed_at.slice(0, 10)}).${where}${dest} It now grounds edits to: ${[...rec.affected_files, ...rec.affected_symbols].join(", ") || "(nothing — add affected_files/symbols so it surfaces at edit time)"}.${danglingCon}${noEvidence}`);
|
|
1098
1238
|
}
|
|
1099
1239
|
catch (e) {
|
|
1100
1240
|
return err(`Failed to record finding: ${e.message}`);
|
|
@@ -1129,6 +1269,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1129
1269
|
public_only: z.boolean().optional().describe("Read and write only the public correction home."),
|
|
1130
1270
|
private_only: z.boolean().optional().describe("Keep correction-derived evidence/policy/proof artifacts in the configured private overlay; the public source-code graph is refreshed before proof."),
|
|
1131
1271
|
include_artifacts: z.boolean().optional().describe("Include the complete Policy IR, proof plan, proof receipts, and evidence object. Default output is a concise review envelope."),
|
|
1272
|
+
cwd: cwdHintField,
|
|
1132
1273
|
},
|
|
1133
1274
|
}, async ({ constraint_id, public_only, private_only, include_artifacts }) => {
|
|
1134
1275
|
try {
|
|
@@ -1166,8 +1307,10 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1166
1307
|
if (artifactHome === "private") {
|
|
1167
1308
|
flushMemoryHome(store, hunchPaths(root).hunch, "private", `hunch: prove correction ${constraint_id}`, startupTeamRoute ?? undefined);
|
|
1168
1309
|
}
|
|
1310
|
+
const destRoot = resolveDestRoot(artifactHome, store, root);
|
|
1311
|
+
const destination = { root: destRoot, branch: currentBranch(destRoot) };
|
|
1169
1312
|
if (include_artifacts)
|
|
1170
|
-
return ok(JSON.stringify(upgrade, null, 2));
|
|
1313
|
+
return ok(JSON.stringify({ ...upgrade, destination }, null, 2));
|
|
1171
1314
|
return ok(JSON.stringify({
|
|
1172
1315
|
status: upgrade.status,
|
|
1173
1316
|
correction_id: upgrade.correction_id,
|
|
@@ -1180,6 +1323,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1180
1323
|
authority: upgrade.authority,
|
|
1181
1324
|
effects: upgrade.effects,
|
|
1182
1325
|
activation: upgrade.activation,
|
|
1326
|
+
destination,
|
|
1183
1327
|
}, null, 2));
|
|
1184
1328
|
}
|
|
1185
1329
|
catch (e) {
|
|
@@ -1338,6 +1482,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1338
1482
|
inputSchema: {
|
|
1339
1483
|
policy_id: z.string().describe("Policy id (pol_*)."),
|
|
1340
1484
|
public_only: z.boolean().optional().describe("Exclude private-overlay policy and evidence records."),
|
|
1485
|
+
cwd: cwdHintField,
|
|
1341
1486
|
},
|
|
1342
1487
|
}, async ({ policy_id, public_only }) => {
|
|
1343
1488
|
try {
|
|
@@ -1347,7 +1492,8 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1347
1492
|
if (!home)
|
|
1348
1493
|
throw new Error(`policy ${policy_id} has no exact storage home`);
|
|
1349
1494
|
flushMemoryHome(store, hunchPaths(root).hunch, home, `hunch: plan policy ${policy_id}`, startupTeamRoute ?? undefined);
|
|
1350
|
-
|
|
1495
|
+
const destRoot = resolveDestRoot(home, store, root);
|
|
1496
|
+
return ok(JSON.stringify({ ...plan, destination: { root: destRoot, branch: currentBranch(destRoot) } }, null, 2));
|
|
1351
1497
|
}
|
|
1352
1498
|
catch (e) {
|
|
1353
1499
|
return err(`Failed to generate policy proof plan: ${e.message}`);
|
|
@@ -1614,6 +1760,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1614
1760
|
limit: z.number().int().min(1).max(100).optional().describe("Item limit used by the exact review packet (default 30)."),
|
|
1615
1761
|
allow_install_scripts: z.array(z.string().min(1).max(214)).max(20).optional().describe("Exact dependency package names allowed to run lifecycle scripts while provisioning snapshots."),
|
|
1616
1762
|
dependency_timeout_ms: z.number().int().min(1).max(900000).optional().describe("Timeout for each exact dependency snapshot operation (default 300000ms)."),
|
|
1763
|
+
cwd: cwdHintField,
|
|
1617
1764
|
},
|
|
1618
1765
|
}, async ({ decision_id, since, max_commits, limit, allow_install_scripts, dependency_timeout_ms }) => {
|
|
1619
1766
|
try {
|
|
@@ -1626,7 +1773,8 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1626
1773
|
dependencyTimeoutMs: dependency_timeout_ms ?? 300_000,
|
|
1627
1774
|
});
|
|
1628
1775
|
flushMemoryHome(store, hunchPaths(root).hunch, "private", "hunch: materialize G2 behavior policies", startupTeamRoute ?? undefined);
|
|
1629
|
-
|
|
1776
|
+
const destRoot = resolveDestRoot("private", store, root);
|
|
1777
|
+
return ok(JSON.stringify({ ...materialized, destination: { root: destRoot, branch: currentBranch(destRoot) } }, null, 2));
|
|
1630
1778
|
}
|
|
1631
1779
|
catch (e) {
|
|
1632
1780
|
return err(`Failed to materialize G2 behavior policies: ${e.message}`);
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -662,7 +662,11 @@ export class HunchStore {
|
|
|
662
662
|
// The graph stream is model-free, so it contributes even on a lean (no-embeddings)
|
|
663
663
|
// install. With neither semantic nor graph signal, return pure FTS so the
|
|
664
664
|
// zero-fusion-overhead fast path is preserved.
|
|
665
|
-
const graph = this.graphExpand([...fts, ...sem],
|
|
665
|
+
const graph = this.graphExpand([...fts, ...sem], {
|
|
666
|
+
maxDepth: boundedWhole(opts.graphDepth, GRAPH_MAX_DEPTH, GRAPH_DEPTH_HARD_MAX),
|
|
667
|
+
nodeCap: boundedWhole(opts.graphNodeCap, GRAPH_NODE_CAP, GRAPH_NODE_HARD_MAX),
|
|
668
|
+
tokenCap: boundedWhole(opts.graphTokenCap, GRAPH_TOKEN_CAP, GRAPH_TOKEN_HARD_MAX),
|
|
669
|
+
}, gw);
|
|
666
670
|
if (!sem.length && !graph.length)
|
|
667
671
|
return this.rerankByPriors(fts, limit, query);
|
|
668
672
|
// Fuse with headroom so the prior rerank can promote from below the cut line.
|
|
@@ -764,47 +768,91 @@ export class HunchStore {
|
|
|
764
768
|
add(graph, graphWeight);
|
|
765
769
|
return [...acc.values()].sort((a, b) => b.score - a.score).slice(0, limit).map((e) => ({ ...e.hit, score: e.score }));
|
|
766
770
|
}
|
|
767
|
-
/**
|
|
768
|
-
*
|
|
769
|
-
*
|
|
770
|
-
*
|
|
771
|
-
*
|
|
772
|
-
*
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
if (weight <= 0)
|
|
771
|
+
/** Bounded relevance traversal over the dependency graph. Lexical/semantic symbol
|
|
772
|
+
* and component hits seed a small number of depth layers; support decays per hop
|
|
773
|
+
* and adds across multiple useful paths. Each frontier and the returned context
|
|
774
|
+
* obey a hard node cap, while hydration obeys a separate token cap. Only records
|
|
775
|
+
* present in the indexed symbol/component tables can enter the frontier, so
|
|
776
|
+
* shared external-package hubs never become context or bridge unrelated symbols. */
|
|
777
|
+
graphExpand(seeds, opts, weight = RRF_W_GRAPH) {
|
|
778
|
+
if (weight <= 0 || GRAPH_GAMMA <= 0 || opts.maxDepth <= 0 || opts.nodeCap <= 0 || opts.tokenCap <= 0)
|
|
776
779
|
return [];
|
|
777
|
-
const
|
|
778
|
-
|
|
780
|
+
const seedRefs = new Set(seeds.map((h) => h.ref)); // never re-surface a seed
|
|
781
|
+
let frontier = new Map();
|
|
782
|
+
seeds.forEach((hit, rank) => {
|
|
783
|
+
if (!isGraphContextRef(hit.ref))
|
|
784
|
+
return;
|
|
785
|
+
frontier.set(hit.ref, (frontier.get(hit.ref) ?? 0) + 1 / (RRF_K + rank + 1));
|
|
786
|
+
});
|
|
787
|
+
if (!frontier.size)
|
|
779
788
|
return [];
|
|
780
|
-
const seen = new Set(seeds.map((h) => h.ref)); // never re-surface a seed
|
|
781
789
|
const nbStmt = this.db.prepare(
|
|
782
790
|
/* sql */ `
|
|
783
|
-
SELECT e."to"
|
|
791
|
+
SELECT e."to" AS nb
|
|
792
|
+
FROM edges e
|
|
793
|
+
WHERE e."from" = ? AND e.type IN ('calls','depends_on','imports','contains')
|
|
794
|
+
AND (EXISTS (SELECT 1 FROM symbols s WHERE s.id = e."to")
|
|
795
|
+
OR EXISTS (SELECT 1 FROM components c WHERE c.id = e."to"))
|
|
784
796
|
UNION
|
|
785
|
-
SELECT e."from" AS nb
|
|
797
|
+
SELECT e."from" AS nb
|
|
798
|
+
FROM edges e
|
|
799
|
+
WHERE e."to" = ? AND e.type IN ('calls','depends_on','imports','contains')
|
|
800
|
+
AND (EXISTS (SELECT 1 FROM symbols s WHERE s.id = e."from")
|
|
801
|
+
OR EXISTS (SELECT 1 FROM components c WHERE c.id = e."from"))
|
|
802
|
+
ORDER BY nb`);
|
|
803
|
+
const expanded = new Set();
|
|
786
804
|
const score = new Map();
|
|
787
|
-
|
|
788
|
-
const
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
805
|
+
for (let depth = 1; depth <= opts.maxDepth && frontier.size; depth++) {
|
|
806
|
+
const layer = new Map();
|
|
807
|
+
const rankedFrontier = [...frontier.entries()]
|
|
808
|
+
.sort((a, b) => b[1] - a[1] || compareRefs(a[0], b[0]))
|
|
809
|
+
.slice(0, opts.nodeCap)
|
|
810
|
+
.filter(([ref]) => !expanded.has(ref));
|
|
811
|
+
// Mark the whole frontier visited before walking it. Otherwise an edge
|
|
812
|
+
// between peers in this layer credits whichever peer sorts second as if
|
|
813
|
+
// it were deeper context, making scores depend on ref ordering.
|
|
814
|
+
for (const [ref] of rankedFrontier)
|
|
815
|
+
expanded.add(ref);
|
|
816
|
+
for (const [ref, support] of rankedFrontier) {
|
|
817
|
+
const contribution = support * GRAPH_GAMMA;
|
|
818
|
+
for (const row of nbStmt.all(ref, ref)) {
|
|
819
|
+
if (seedRefs.has(row.nb) || expanded.has(row.nb))
|
|
820
|
+
continue;
|
|
821
|
+
layer.set(row.nb, (layer.get(row.nb) ?? 0) + contribution);
|
|
822
|
+
}
|
|
793
823
|
}
|
|
794
|
-
|
|
824
|
+
const rankedLayer = [...layer.entries()]
|
|
825
|
+
.sort((a, b) => b[1] - a[1] || compareRefs(a[0], b[0]))
|
|
826
|
+
.slice(0, opts.nodeCap);
|
|
827
|
+
for (const [ref, support] of rankedLayer)
|
|
828
|
+
score.set(ref, (score.get(ref) ?? 0) + support);
|
|
829
|
+
frontier = new Map(rankedLayer);
|
|
830
|
+
}
|
|
795
831
|
if (!score.size)
|
|
796
832
|
return [];
|
|
797
|
-
const top = [...score.entries()]
|
|
833
|
+
const top = [...score.entries()]
|
|
834
|
+
.sort((a, b) => b[1] - a[1] || compareRefs(a[0], b[0]))
|
|
835
|
+
.slice(0, opts.nodeCap);
|
|
798
836
|
// Hydrate title/snippet from the FTS table in ONE query (mirrors cosineRank).
|
|
799
837
|
const placeholders = top.map(() => "?").join(",");
|
|
800
838
|
const meta = new Map();
|
|
801
|
-
for (const row of this.db.prepare(`SELECT ref, title, body FROM search WHERE ref IN (${placeholders})`).all(...top.map(([ref]) => ref))) {
|
|
802
|
-
meta.set(row.ref, { title: row.title, body: row.body });
|
|
839
|
+
for (const row of this.db.prepare(`SELECT ref, kind, title, body FROM search WHERE ref IN (${placeholders})`).all(...top.map(([ref]) => ref))) {
|
|
840
|
+
meta.set(row.ref, { kind: row.kind, title: row.title, body: row.body });
|
|
803
841
|
}
|
|
804
|
-
|
|
842
|
+
const hits = [];
|
|
843
|
+
let usedTokens = 0;
|
|
844
|
+
for (const [ref, s] of top) {
|
|
805
845
|
const m = meta.get(ref);
|
|
806
|
-
|
|
807
|
-
|
|
846
|
+
if (!m)
|
|
847
|
+
continue;
|
|
848
|
+
const hit = { ref, kind: m.kind, title: m.title, snippet: m.body.slice(0, 120), score: s };
|
|
849
|
+
const tokenCost = estimatedSearchHitTokens(hit);
|
|
850
|
+
if (usedTokens + tokenCost > opts.tokenCap)
|
|
851
|
+
continue;
|
|
852
|
+
hits.push(hit);
|
|
853
|
+
usedTokens += tokenCost;
|
|
854
|
+
}
|
|
855
|
+
return hits;
|
|
808
856
|
}
|
|
809
857
|
/** All decisions/bugs/constraints/symbols/components touching a file path or
|
|
810
858
|
* symbol name (hunch_why). Pass `{ asOf }` (an ISO instant) to TIME-TRAVEL:
|
|
@@ -1581,7 +1629,13 @@ const RRF_K = numEnv("HUNCH_RRF_K", 60);
|
|
|
1581
1629
|
const RRF_W_FTS = numEnv("HUNCH_RRF_W_FTS", 1);
|
|
1582
1630
|
const RRF_W_SEM = numEnv("HUNCH_RRF_W_SEM", 0.7);
|
|
1583
1631
|
const RRF_W_GRAPH = numEnv("HUNCH_RRF_W_GRAPH", 0.5);
|
|
1584
|
-
const GRAPH_GAMMA = numEnv("HUNCH_GRAPH_GAMMA", 0.25);
|
|
1632
|
+
const GRAPH_GAMMA = Math.min(1, numEnv("HUNCH_GRAPH_GAMMA", 0.25));
|
|
1633
|
+
const GRAPH_DEPTH_HARD_MAX = 8;
|
|
1634
|
+
const GRAPH_NODE_HARD_MAX = 500;
|
|
1635
|
+
const GRAPH_TOKEN_HARD_MAX = 100_000;
|
|
1636
|
+
const GRAPH_MAX_DEPTH = boundedWhole(numEnv("HUNCH_GRAPH_MAX_DEPTH", 2), 2, GRAPH_DEPTH_HARD_MAX);
|
|
1637
|
+
const GRAPH_NODE_CAP = boundedWhole(numEnv("HUNCH_GRAPH_NODE_CAP", 50), 50, GRAPH_NODE_HARD_MAX);
|
|
1638
|
+
const GRAPH_TOKEN_CAP = boundedWhole(numEnv("HUNCH_GRAPH_TOKEN_CAP", 2_000), 2_000, GRAPH_TOKEN_HARD_MAX);
|
|
1585
1639
|
/** Prior tuning: how far a trust weight may move a hit from its FUSED position.
|
|
1586
1640
|
* SCALE maps the weight's realistic span onto positions (this repo's own decisions
|
|
1587
1641
|
* span w 0.48…1.0, so ×12 reaches the clamp at the low end); MAX_PRIOR_SHIFT is the
|
|
@@ -1595,6 +1649,20 @@ function numEnv(name, dflt) {
|
|
|
1595
1649
|
// a stream); rejecting it silently re-enabled the default weight (issue #33).
|
|
1596
1650
|
return Number.isFinite(v) && v >= 0 ? v : dflt;
|
|
1597
1651
|
}
|
|
1652
|
+
function boundedWhole(value, dflt, hardMax) {
|
|
1653
|
+
if (value === undefined || !Number.isFinite(value) || value < 0)
|
|
1654
|
+
return dflt;
|
|
1655
|
+
return Math.min(Math.floor(value), hardMax);
|
|
1656
|
+
}
|
|
1657
|
+
function isGraphContextRef(ref) {
|
|
1658
|
+
return ref.startsWith("sym_") || ref.startsWith("cmp_");
|
|
1659
|
+
}
|
|
1660
|
+
function compareRefs(a, b) {
|
|
1661
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
1662
|
+
}
|
|
1663
|
+
function estimatedSearchHitTokens(hit) {
|
|
1664
|
+
return Math.max(1, Math.ceil([...`${hit.kind} ${hit.ref}\n${hit.title}\n${hit.snippet}`].length / 4));
|
|
1665
|
+
}
|
|
1598
1666
|
/** Pack a vector's exact bytes for SQLite. Explicit offset+length so a SUBARRAY
|
|
1599
1667
|
* view (byteOffset != 0) writes only its slice, not the whole backing buffer.
|
|
1600
1668
|
* node:sqlite copies on bind, so the returned view never aliases the row. */
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
9
|
"websiteUrl": "https://hunch-pi.vercel.app",
|
|
10
|
-
"version": "1.
|
|
10
|
+
"version": "1.14.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.14.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|