@davesheffer/hunch 1.11.0 → 1.12.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/dist/cli/index.js +139 -1
- package/dist/core/agenthook.js +4 -0
- package/dist/core/docanchors.js +27 -3
- package/dist/core/docscan.js +5 -1
- package/dist/core/hookcache.js +16 -0
- package/dist/core/served.js +105 -0
- package/dist/integrations/scaffold.js +12 -0
- package/dist/mcp/server.js +10 -3
- package/package.json +3 -1
- package/server.json +2 -2
package/dist/cli/index.js
CHANGED
|
@@ -57,7 +57,8 @@ import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpol
|
|
|
57
57
|
import { isHumanConfirmed } from "../core/strictgate.js";
|
|
58
58
|
import { appendEvent, readEvents } from "../core/events.js";
|
|
59
59
|
import { computeStats, formatStats } from "../core/stats.js";
|
|
60
|
-
import { injectionMode } from "../core/hookcache.js";
|
|
60
|
+
import { injectionMode, resetSessionInjections } from "../core/hookcache.js";
|
|
61
|
+
import { recordServed, servedSummary } from "../core/served.js";
|
|
61
62
|
import { contextHookOutput, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
|
|
62
63
|
import { PIPELINE_LOOP, UNVERIFIED_NAG, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, savePipelineState, stopVerdict, } from "../core/pipeline.js";
|
|
63
64
|
import { draftDuplicateOf, isAcceptedDuplicateAnchor } from "../core/dupdetect.js";
|
|
@@ -3718,6 +3719,47 @@ program
|
|
|
3718
3719
|
store.close();
|
|
3719
3720
|
}
|
|
3720
3721
|
});
|
|
3722
|
+
program
|
|
3723
|
+
.command("served")
|
|
3724
|
+
.description("Delivery receipts: which memory records actually reached an agent, how often, and which never have")
|
|
3725
|
+
.option("--json", "emit the raw ledger summary")
|
|
3726
|
+
.action((opts) => {
|
|
3727
|
+
const { store, root } = storeFor();
|
|
3728
|
+
try {
|
|
3729
|
+
const summary = servedSummary(root);
|
|
3730
|
+
if (opts.json) {
|
|
3731
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
3732
|
+
return;
|
|
3733
|
+
}
|
|
3734
|
+
if (!summary.total) {
|
|
3735
|
+
console.log("No delivery receipts yet — they accrue as the pre-edit hook and subagent grounding fire on this machine.");
|
|
3736
|
+
return;
|
|
3737
|
+
}
|
|
3738
|
+
console.log(`\nHunch — delivery receipts (this machine)\n`);
|
|
3739
|
+
console.log(` ${summary.total} deliveries · ${summary.distinct_records} distinct record(s) · ${summary.distinct_sessions} session(s) · ${summary.first_at?.slice(0, 10)} → ${summary.last_at?.slice(0, 10)}\n`);
|
|
3740
|
+
const titleOf = (row) => {
|
|
3741
|
+
if (row.kind === "decisions")
|
|
3742
|
+
return store.recs("decisions").find((r) => r.id === row.record_id)?.title ?? row.record_id;
|
|
3743
|
+
if (row.kind === "constraints")
|
|
3744
|
+
return store.recs("constraints").find((r) => r.id === row.record_id)?.statement.slice(0, 70) ?? row.record_id;
|
|
3745
|
+
return row.record_id;
|
|
3746
|
+
};
|
|
3747
|
+
console.log(" Most delivered:");
|
|
3748
|
+
for (const row of summary.rows.slice(0, 10)) {
|
|
3749
|
+
console.log(` ${String(row.serves).padStart(4)}× (+${row.refreshes} still-current) ${row.record_id} — ${titleOf(row)}`);
|
|
3750
|
+
}
|
|
3751
|
+
const servedIds = new Set(summary.rows.map((r) => r.record_id));
|
|
3752
|
+
const neverServed = [
|
|
3753
|
+
...store.recs("constraints").filter((c) => c.status === "active" && !servedIds.has(c.id)).map((c) => c.id),
|
|
3754
|
+
...store.recs("decisions").filter((d) => d.status === "accepted" && !d.superseded_by && !servedIds.has(d.id)).map((d) => d.id),
|
|
3755
|
+
];
|
|
3756
|
+
console.log(`\n Never delivered on this machine: ${neverServed.length} in-force record(s)${neverServed.length ? ` — compact candidates start here (${neverServed.slice(0, 5).join(", ")}${neverServed.length > 5 ? ", …" : ""})` : ""}`);
|
|
3757
|
+
console.log(` Prevented violations live in \`hunch stats\` (events ledger); receipts here are the delivery half.\n`);
|
|
3758
|
+
}
|
|
3759
|
+
finally {
|
|
3760
|
+
store.close();
|
|
3761
|
+
}
|
|
3762
|
+
});
|
|
3721
3763
|
program
|
|
3722
3764
|
.command("hook")
|
|
3723
3765
|
.description("Agent-agnostic hook handler: normalizes Claude, VS Code, Cursor, Windsurf, and Antigravity events into Hunch context and strict policy checks. Reads hook JSON on stdin.")
|
|
@@ -3797,7 +3839,92 @@ program
|
|
|
3797
3839
|
emitContext(provider, "UserPromptSubmit", text);
|
|
3798
3840
|
return;
|
|
3799
3841
|
}
|
|
3842
|
+
if (evt.hook_event_name === "PreCompact") {
|
|
3843
|
+
// Compaction is about to summarize injected grounding out of the agent's
|
|
3844
|
+
// context while the dedup map still says "delivered". Reset it so every
|
|
3845
|
+
// post-compact injection is full again. Emit nothing — this event has no
|
|
3846
|
+
// context channel worth spending.
|
|
3847
|
+
resetSessionInjections(evt.session_id);
|
|
3848
|
+
return;
|
|
3849
|
+
}
|
|
3850
|
+
if (evt.hook_event_name === "SubagentStart") {
|
|
3851
|
+
// A delegated agent starts with NONE of the parent session's grounding:
|
|
3852
|
+
// session orientation never fired inside it and only per-edit PreToolUse
|
|
3853
|
+
// follows it in — so read-only agents (Explore/Plan) could work fully
|
|
3854
|
+
// blind. Slice by what the agent TYPE is about to do (dec_a788cc039b):
|
|
3855
|
+
// explorers get the indexed shape, planners get live decisions + what
|
|
3856
|
+
// was already rejected, everyone else gets the invariant digest. Public
|
|
3857
|
+
// store only; cheap reads.
|
|
3858
|
+
const s = new HunchStore(paths);
|
|
3859
|
+
try {
|
|
3860
|
+
const clip1 = (text, max) => {
|
|
3861
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
3862
|
+
return flat.length > max ? `${flat.slice(0, max - 1).trimEnd()}…` : flat;
|
|
3863
|
+
};
|
|
3864
|
+
const type = (evt.agent_type ?? "").toLowerCase();
|
|
3865
|
+
const L = [];
|
|
3866
|
+
const served = [];
|
|
3867
|
+
if (/explore|search|investigat/.test(type)) {
|
|
3868
|
+
// Orient from the graph, not grep rounds: the component map IS the shape.
|
|
3869
|
+
const components = s.advisoryRecs("components").filter((c) => c.status === "active");
|
|
3870
|
+
if (!components.length)
|
|
3871
|
+
return;
|
|
3872
|
+
L.push(`🧠 Hunch — repo shape for a delegated explorer: ${components.length} component(s).`);
|
|
3873
|
+
for (const c of components.slice(0, 12)) {
|
|
3874
|
+
L.push(`- ${c.name}${c.paths.length ? ` (${c.paths.slice(0, 2).join(", ")})` : ""}${c.responsibility ? ` — ${clip1(c.responsibility, 90)}` : ""}`);
|
|
3875
|
+
served.push({ kind: "components", record_id: c.id });
|
|
3876
|
+
}
|
|
3877
|
+
if (components.length > 12)
|
|
3878
|
+
L.push(`…and ${components.length - 12} more — hunch_structure() for the full map.`);
|
|
3879
|
+
L.push("Orient: hunch_structure(target) · hunch_why(target) · hunch_context(task).");
|
|
3880
|
+
}
|
|
3881
|
+
else if (/plan|architect|design/.test(type)) {
|
|
3882
|
+
// A plan drafted blind re-proposes what the graph already rejected.
|
|
3883
|
+
const decisions = s.advisoryRecs("decisions")
|
|
3884
|
+
.filter((d) => d.status === "accepted")
|
|
3885
|
+
.sort((a, b) => (a.date < b.date ? 1 : -1));
|
|
3886
|
+
if (!decisions.length)
|
|
3887
|
+
return;
|
|
3888
|
+
L.push(`🧠 Hunch — live decisions for a delegated planner (${decisions.length} in force; plans must not re-propose the rejected).`);
|
|
3889
|
+
for (const d of decisions.slice(0, 6)) {
|
|
3890
|
+
L.push(`- ${d.title} (${d.id})${d.alternatives_rejected.length ? ` — rejected: ${clip1(d.alternatives_rejected[0], 80)}` : ""}`);
|
|
3891
|
+
served.push({ kind: "decisions", record_id: d.id });
|
|
3892
|
+
}
|
|
3893
|
+
L.push("Before finalizing a plan: hunch_why(target) · hunch_current_decision(topic) · hunch_check_constraints(scope).");
|
|
3894
|
+
}
|
|
3895
|
+
else {
|
|
3896
|
+
const sevRank = { blocking: 0, warning: 1, advisory: 2 };
|
|
3897
|
+
const constraints = s.advisoryRecs("constraints")
|
|
3898
|
+
.filter((c) => c.status === "active")
|
|
3899
|
+
.sort((a, b) => sevRank[a.severity] - sevRank[b.severity]);
|
|
3900
|
+
if (!constraints.length)
|
|
3901
|
+
return;
|
|
3902
|
+
L.push(`🧠 Hunch — delegated agent grounding: ${constraints.length} invariant(s) in force in this repo.`);
|
|
3903
|
+
for (const c of constraints.slice(0, 8)) {
|
|
3904
|
+
L.push(`- [${c.severity}] ${clip1(c.statement, 140)}${c.scope.length ? ` (scope: ${c.scope.slice(0, 3).join(", ")})` : ""}`);
|
|
3905
|
+
served.push({ kind: "constraints", record_id: c.id });
|
|
3906
|
+
}
|
|
3907
|
+
if (constraints.length > 8)
|
|
3908
|
+
L.push(`…and ${constraints.length - 8} more — hunch_check_constraints(scope) for your files.`);
|
|
3909
|
+
L.push("Before editing: hunch_check_constraints(scope) · hunch_why(target). Orient: hunch_context(task).");
|
|
3910
|
+
}
|
|
3911
|
+
// No dedup here: the hook event carries the PARENT session id, but each
|
|
3912
|
+
// spawned agent is a fresh empty context — deduping would ground the
|
|
3913
|
+
// first Explore and silently starve every later one.
|
|
3914
|
+
recordServed(root, served.map((r) => ({ ...r, event: "served", target: `(subagent:${evt.agent_type ?? "any"})`, session_id: evt.session_id })));
|
|
3915
|
+
emitContext(provider, "SubagentStart", L.join("\n"));
|
|
3916
|
+
}
|
|
3917
|
+
finally {
|
|
3918
|
+
s.close();
|
|
3919
|
+
}
|
|
3920
|
+
return;
|
|
3921
|
+
}
|
|
3800
3922
|
if (evt.hook_event_name === "SessionStart") {
|
|
3923
|
+
// A compact-resume means everything injected so far was just summarized
|
|
3924
|
+
// away — the dedup map must forget it delivered anything, or the rest of
|
|
3925
|
+
// the session gets delta one-liners against grounding that is gone.
|
|
3926
|
+
if (evt.source === "compact")
|
|
3927
|
+
resetSessionInjections(evt.session_id);
|
|
3801
3928
|
// Orientation at the moment it matters: what just happened + what's next,
|
|
3802
3929
|
// straight from the graph — the agent sits down already knowing where it
|
|
3803
3930
|
// is instead of pulling (or worse, grepping) for it. Cheap reads only
|
|
@@ -3953,10 +4080,21 @@ program
|
|
|
3953
4080
|
// Identical grounding already shown this session → one-line delta instead of
|
|
3954
4081
|
// the full 10-16KB block. Any record change re-sends the full text; the
|
|
3955
4082
|
// strict-gate deny path above never routes through this (dec_244397d920).
|
|
4083
|
+
// Delivery receipts (dec_925f4bcaad): the ledger of what actually reached
|
|
4084
|
+
// an agent. A full injection is a serve; a delta one-liner attests the
|
|
4085
|
+
// earlier serve is still standing. Never throws, never blocks.
|
|
4086
|
+
const receipts = (event) => recordServed(root, [
|
|
4087
|
+
...ctx.constraints.map((c) => ({ event, kind: "constraints", record_id: c.id, target, session_id: evt.session_id })),
|
|
4088
|
+
...ctx.decisions.map((d) => ({ event, kind: "decisions", record_id: d.id, target, session_id: evt.session_id })),
|
|
4089
|
+
...ctx.bugs.map((b) => ({ event, kind: "bugs", record_id: b.id, target, session_id: evt.session_id })),
|
|
4090
|
+
...ctx.findings.map((f) => ({ event, kind: "findings", record_id: f.id, target, session_id: evt.session_id })),
|
|
4091
|
+
]);
|
|
3956
4092
|
if (injectionMode(evt.session_id, `pre:${target}`, text) === "delta") {
|
|
4093
|
+
receipts("refreshed");
|
|
3957
4094
|
emitContext(provider, "PreToolUse", `Hunch grounding for ${target}: unchanged this session (${ctx.decisions.length} decision(s), ${ctx.constraints.length} invariant(s) shown earlier — still current; hunch_why("${target}") to re-expand).`);
|
|
3958
4095
|
return;
|
|
3959
4096
|
}
|
|
4097
|
+
receipts("served");
|
|
3960
4098
|
emitContext(provider, "PreToolUse", text);
|
|
3961
4099
|
}
|
|
3962
4100
|
catch {
|
package/dist/core/agenthook.js
CHANGED
|
@@ -77,6 +77,8 @@ function eventName(value, provider) {
|
|
|
77
77
|
posttooluse: "PostToolUse",
|
|
78
78
|
userpromptsubmit: "UserPromptSubmit",
|
|
79
79
|
sessionstart: "SessionStart",
|
|
80
|
+
subagentstart: "SubagentStart",
|
|
81
|
+
precompact: "PreCompact",
|
|
80
82
|
stop: "Stop",
|
|
81
83
|
};
|
|
82
84
|
if (map[name])
|
|
@@ -155,6 +157,8 @@ export function normalizeHookEvent(raw, provider) {
|
|
|
155
157
|
tool_name: hunchToolName(stringAt(input, "tool_name", "toolName"), toolInput ?? {}),
|
|
156
158
|
tool_input: toolInput,
|
|
157
159
|
prompt: stringAt(input, "prompt", "user_prompt", "userPrompt"),
|
|
160
|
+
source: stringAt(input, "source"),
|
|
161
|
+
agent_type: stringAt(input, "agent_type", "agentType", "subagent_type", "subagentType"),
|
|
158
162
|
};
|
|
159
163
|
}
|
|
160
164
|
/** Provider-aware hook output. Context output is intentionally omitted for
|
package/dist/core/docanchors.js
CHANGED
|
@@ -28,16 +28,40 @@ function fencedRanges(text) {
|
|
|
28
28
|
ranges.push([open.start, text.length]);
|
|
29
29
|
return ranges;
|
|
30
30
|
}
|
|
31
|
+
/** Character ranges covered by inline code spans (`…`), same rationale as
|
|
32
|
+
* fencedRanges: prose quoting a marker in backticks is showing an example.
|
|
33
|
+
* CommonMark-lite: an opener run pairs with the next run of the SAME length
|
|
34
|
+
* on the same line; unpaired runs never open a span. */
|
|
35
|
+
function inlineSpanRanges(text) {
|
|
36
|
+
const ranges = [];
|
|
37
|
+
let offset = 0;
|
|
38
|
+
for (const line of text.split("\n")) {
|
|
39
|
+
let pending = null;
|
|
40
|
+
const runs = /`+/g;
|
|
41
|
+
let m;
|
|
42
|
+
while ((m = runs.exec(line))) {
|
|
43
|
+
if (!pending)
|
|
44
|
+
pending = { len: m[0].length, start: m.index };
|
|
45
|
+
else if (m[0].length === pending.len) {
|
|
46
|
+
ranges.push([offset + pending.start, offset + m.index + m[0].length - 1]);
|
|
47
|
+
pending = null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
offset += line.length + 1;
|
|
51
|
+
}
|
|
52
|
+
return ranges;
|
|
53
|
+
}
|
|
31
54
|
/** Parse every hunch:topic marker out of a markdown document. Markers inside
|
|
32
|
-
* fenced code blocks are examples, not declarations,
|
|
55
|
+
* fenced code blocks or inline code spans are examples, not declarations,
|
|
56
|
+
* and are skipped. */
|
|
33
57
|
export function parseDocAnchors(text) {
|
|
34
58
|
const out = [];
|
|
35
|
-
const
|
|
59
|
+
const skip = [...fencedRanges(text), ...inlineSpanRanges(text)];
|
|
36
60
|
MARKER.lastIndex = 0;
|
|
37
61
|
let m;
|
|
38
62
|
while ((m = MARKER.exec(text))) {
|
|
39
63
|
const at = m.index;
|
|
40
|
-
if (
|
|
64
|
+
if (skip.some(([s, e]) => at >= s && at <= e))
|
|
41
65
|
continue;
|
|
42
66
|
out.push({ topic: m[1], pin: m[2] ?? null, line: text.slice(0, at).split("\n").length });
|
|
43
67
|
}
|
package/dist/core/docscan.js
CHANGED
|
@@ -20,7 +20,11 @@ import { join, extname } from "node:path";
|
|
|
20
20
|
import { parseDocAnchors } from "./docanchors.js";
|
|
21
21
|
import { currentForTopic } from "./topics.js";
|
|
22
22
|
import { compareCodeUnits } from "./canonicalOrder.js";
|
|
23
|
-
|
|
23
|
+
// Self-referential status declarations only. A bare "proposed" is too loose:
|
|
24
|
+
// the auto-generated grounding block in AGENTS.md legitimately DESCRIBES the
|
|
25
|
+
// proposed decision status ("candidate/proposed rules") and was graded stale
|
|
26
|
+
// for it — a false alarm in the machinery that polices false alarms.
|
|
27
|
+
export const STALE_MARKER = /\b(?:status|state)\s*[:\-—]\s*(?:proposed|draft)\b|\bnot yet implemented\b|\bno code yet\b/i;
|
|
24
28
|
export const SRC_REF = /\bsrc\/[A-Za-z0-9_\-/]+\.ts\b/g;
|
|
25
29
|
const SKIP_DIRS = new Set(["node_modules", ".git", ".hunch", ".hunch-private", "dist", "vscode-extension", "site"]);
|
|
26
30
|
/** Bounded walk for repo markdown (root + docs/, depth-limited; heavy/irrelevant trees skipped). */
|
package/dist/core/hookcache.js
CHANGED
|
@@ -55,6 +55,22 @@ export function injectionMode(sessionId, key, content) {
|
|
|
55
55
|
return "full"; // grounded beats deduped, always
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
+
/** Forget everything injected into a session. Compaction summarizes injected
|
|
59
|
+
* grounding out of the agent's context while the dedup map still says
|
|
60
|
+
* "delivered" — so on PreCompact / SessionStart[source=compact] the map must
|
|
61
|
+
* reset, or post-compact edits get delta one-liners against grounding the
|
|
62
|
+
* agent no longer has. Never throws (same posture as injectionMode). */
|
|
63
|
+
export function resetSessionInjections(sessionId) {
|
|
64
|
+
try {
|
|
65
|
+
if (!sessionId)
|
|
66
|
+
return;
|
|
67
|
+
const file = join(tmpdir(), "hunch-hookcache", `${sessionId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 80)}.json`);
|
|
68
|
+
rmSync(file, { force: true });
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* unwritable tmpdir — next injectionMode call falls back to "full" anyway */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
58
74
|
/** Drop session caches from long-gone sessions (best effort, bounded dir). */
|
|
59
75
|
function sweep(dir) {
|
|
60
76
|
try {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delivery receipts (roadmap dec_925f4bcaad): a machine-local ledger of which
|
|
3
|
+
* memory records were actually DELIVERED to an agent, when, and into what.
|
|
4
|
+
*
|
|
5
|
+
* This is observed telemetry, not derived state: it cannot be reconstructed
|
|
6
|
+
* from the JSON store, so it must NOT live in the reindex-rebuilt SQLite index
|
|
7
|
+
* (con_a87360128b's derived layer is dropped and rebuilt at will). It gets its
|
|
8
|
+
* own database under .hunch-cache/ — gitignored, per-machine, append-only —
|
|
9
|
+
* the same family as hookcache's session state, not the store's.
|
|
10
|
+
*
|
|
11
|
+
* Failure posture inherits the hook's: recording a receipt must never cost a
|
|
12
|
+
* delivery. Every entry point swallows every error; a lost receipt is noise,
|
|
13
|
+
* a blocked edit is a broken product.
|
|
14
|
+
*/
|
|
15
|
+
import { createRequire } from "node:module";
|
|
16
|
+
import { mkdirSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
/** Load node:sqlite while swallowing ONLY its ExperimentalWarning — the same
|
|
19
|
+
* discipline as src/store/db.ts: this module rides the hook into every CLI
|
|
20
|
+
* invocation, and Hunch's stderr reaches humans, hooks, and MCP clients. A
|
|
21
|
+
* bare top-level import printed the warning on every command and failed the
|
|
22
|
+
* release gate's clean-stderr contract. */
|
|
23
|
+
function loadSqlite() {
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const realEmit = process.emitWarning.bind(process);
|
|
26
|
+
process.emitWarning = ((warning, ...rest) => {
|
|
27
|
+
if (String(warning).includes("SQLite is an experimental feature"))
|
|
28
|
+
return;
|
|
29
|
+
realEmit(warning, ...rest);
|
|
30
|
+
});
|
|
31
|
+
try {
|
|
32
|
+
return require("node:sqlite");
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
process.emitWarning = realEmit;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
let sqlite = null;
|
|
39
|
+
function openServedDb(root) {
|
|
40
|
+
sqlite ??= loadSqlite();
|
|
41
|
+
const dir = join(root, ".hunch-cache");
|
|
42
|
+
mkdirSync(dir, { recursive: true });
|
|
43
|
+
const db = new sqlite.DatabaseSync(join(dir, "served.db"));
|
|
44
|
+
db.exec(`CREATE TABLE IF NOT EXISTS served (
|
|
45
|
+
at TEXT NOT NULL,
|
|
46
|
+
session TEXT,
|
|
47
|
+
event TEXT NOT NULL,
|
|
48
|
+
kind TEXT NOT NULL,
|
|
49
|
+
record_id TEXT NOT NULL,
|
|
50
|
+
target TEXT NOT NULL
|
|
51
|
+
);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS served_record ON served (record_id);`);
|
|
53
|
+
return db;
|
|
54
|
+
}
|
|
55
|
+
/** Append delivery receipts. Never throws — a receipt must never cost a delivery. */
|
|
56
|
+
export function recordServed(root, entries) {
|
|
57
|
+
if (!entries.length)
|
|
58
|
+
return;
|
|
59
|
+
try {
|
|
60
|
+
const db = openServedDb(root);
|
|
61
|
+
try {
|
|
62
|
+
const at = new Date().toISOString();
|
|
63
|
+
const insert = db.prepare("INSERT INTO served (at, session, event, kind, record_id, target) VALUES (?, ?, ?, ?, ?, ?)");
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
insert.run(at, entry.session_id ?? null, entry.event, entry.kind, entry.record_id, entry.target);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
db.close();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
/* unwritable cache dir / locked db — the delivery already happened; drop the receipt */
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** The ledger, aggregated per record. Never throws; an unreadable ledger reads as empty. */
|
|
77
|
+
export function servedSummary(root) {
|
|
78
|
+
const empty = { total: 0, distinct_records: 0, distinct_sessions: 0, first_at: null, last_at: null, rows: [] };
|
|
79
|
+
try {
|
|
80
|
+
const db = openServedDb(root);
|
|
81
|
+
try {
|
|
82
|
+
const totals = db.prepare("SELECT COUNT(*) AS total, COUNT(DISTINCT record_id) AS records, COUNT(DISTINCT session) AS sessions, MIN(at) AS first_at, MAX(at) AS last_at FROM served").get();
|
|
83
|
+
const rows = db.prepare(`SELECT record_id, kind,
|
|
84
|
+
SUM(CASE WHEN event = 'served' THEN 1 ELSE 0 END) AS serves,
|
|
85
|
+
SUM(CASE WHEN event = 'refreshed' THEN 1 ELSE 0 END) AS refreshes,
|
|
86
|
+
MAX(at) AS last_at
|
|
87
|
+
FROM served GROUP BY record_id, kind ORDER BY serves DESC, refreshes DESC`).all();
|
|
88
|
+
return {
|
|
89
|
+
total: totals?.total ?? 0,
|
|
90
|
+
distinct_records: totals?.records ?? 0,
|
|
91
|
+
distinct_sessions: totals?.sessions ?? 0,
|
|
92
|
+
first_at: totals?.first_at ?? null,
|
|
93
|
+
last_at: totals?.last_at ?? null,
|
|
94
|
+
rows,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
db.close();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return empty;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=served.js.map
|
|
@@ -156,6 +156,18 @@ export function installClaudeHooks(root, hookCmd) {
|
|
|
156
156
|
...keep(json.hooks.SessionStart),
|
|
157
157
|
{ hooks: [{ type: "command", command: hookCmd }] },
|
|
158
158
|
];
|
|
159
|
+
// Delegated agents start with no session grounding (orientation never fired
|
|
160
|
+
// inside them); compaction summarizes injected grounding away while the dedup
|
|
161
|
+
// map still says "delivered". These two events keep delivery alive across the
|
|
162
|
+
// whole session lifecycle, not just its first context window.
|
|
163
|
+
json.hooks.SubagentStart = [
|
|
164
|
+
...keep(json.hooks.SubagentStart),
|
|
165
|
+
{ hooks: [{ type: "command", command: hookCmd }] },
|
|
166
|
+
];
|
|
167
|
+
json.hooks.PreCompact = [
|
|
168
|
+
...keep(json.hooks.PreCompact),
|
|
169
|
+
{ hooks: [{ type: "command", command: hookCmd }] },
|
|
170
|
+
];
|
|
159
171
|
// Verification pipeline (core/pipeline.ts): PostToolUse records observable
|
|
160
172
|
// facts (edits, verify commands); Stop refuses to end a turn with unverified
|
|
161
173
|
// product edits at firm/strict firmness. Delivery is enforced, not hoped for.
|
package/dist/mcp/server.js
CHANGED
|
@@ -49,6 +49,13 @@ const flushNote = (flush, home, mode) => flush === "pushed" ? ` (committed + pus
|
|
|
49
49
|
? " (committed to the overlay repo — push deferred: offline, no upstream, or merge conflict; the next capture or `hunch private --sync` retries)"
|
|
50
50
|
: " (auto-committed to .hunch/ — rides your next push)"
|
|
51
51
|
: "";
|
|
52
|
+
/** When an overlay exists, a PUBLIC write deserves one visible line: a record that
|
|
53
|
+
* lands in the committed store publishes on the next push, and an agent writing
|
|
54
|
+
* strategy/competitive content there is a leak nobody notices until it ships
|
|
55
|
+
* (2026-08-09: 15 roadmap records caught pre-push only by a release sweep). */
|
|
56
|
+
const publicHomeNote = (home, hasPrivate) => home === "public" && hasPrivate
|
|
57
|
+
? "\nℹ Landed in the COMMITTED PUBLIC store (publishes with the repo). For sensitive/strategy content, re-record with private:true — the overlay store."
|
|
58
|
+
: "";
|
|
52
59
|
// Read-side token budgets: every tool result is injected into a Claude Code
|
|
53
60
|
// session, so an uncapped list pollutes the context window. Cap each list to its
|
|
54
61
|
// highest-signal head (records are pre-sorted by severity/confidence) and tell the
|
|
@@ -903,7 +910,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
903
910
|
// record commits+pushes its overlay repo; a public one commits .hunch/ in THIS repo
|
|
904
911
|
// (commit only — it rides the user's next push, never auto-pushing their code branch).
|
|
905
912
|
const flush = flushCapture(store, hunchPaths(root).hunch, !!decision.private, `hunch: capture ${id}`, startupTeamRoute ?? undefined);
|
|
906
|
-
const flushed = flushNote(flush, home, store.mode);
|
|
913
|
+
const flushed = flushNote(flush, home, store.mode) + publicHomeNote(home, store.hasPrivate);
|
|
907
914
|
// Capture-session gate (staged deprecation, §9.3): the token was consumed
|
|
908
915
|
// above (it also decides the provenance tier). No token still writes
|
|
909
916
|
// (non-breaking) but lands as agent_recorded with a nudge toward /capture.
|
|
@@ -980,7 +987,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
980
987
|
if (home === "public" && !store.autoCommit)
|
|
981
988
|
refreshExistingGrounding(root, store); // overlay rules never render into committed grounding
|
|
982
989
|
const flush = flushCapture(store, hunchPaths(root).hunch, !!input.private, `hunch: capture ${rec.id}`, startupTeamRoute ?? undefined);
|
|
983
|
-
const flushed = flushNote(flush, home, store.mode);
|
|
990
|
+
const flushed = flushNote(flush, home, store.mode) + publicHomeNote(home, store.hasPrivate);
|
|
984
991
|
const enforce = rec.severity === "blocking"
|
|
985
992
|
? "blocks a DIRECT edit to its scope at strict firmness, and fails a PR whose diff touches that scope (CI guard); blast-radius hits and lower firmness stay advisory"
|
|
986
993
|
: "flags violating edits and PRs (advisory)";
|
|
@@ -1056,7 +1063,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1056
1063
|
store.putCapture("findings", rec, !!finding.private);
|
|
1057
1064
|
store.reindex();
|
|
1058
1065
|
const flush = flushCapture(store, hunchPaths(root).hunch, !!finding.private, `hunch: capture ${id}`, startupTeamRoute ?? undefined);
|
|
1059
|
-
const flushed = flushNote(flush, home, store.mode);
|
|
1066
|
+
const flushed = flushNote(flush, home, store.mode) + publicHomeNote(home, store.hasPrivate);
|
|
1060
1067
|
const where = finding.private
|
|
1061
1068
|
? ` [PRIVATE overlay — not committed to this repo]${flushed}`
|
|
1062
1069
|
: home === "private" ? ` [SHARED store — one source of truth for the whole team]${flushed}` : flushed;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.1",
|
|
4
4
|
"mcpName": "io.github.davesheffer/hunch",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
@@ -52,6 +52,8 @@
|
|
|
52
52
|
"node": ">=22.13.0"
|
|
53
53
|
},
|
|
54
54
|
"scripts": {
|
|
55
|
+
"version": "node tooling/sync-version-pins.mjs && git add plugin/.mcp.json server.json",
|
|
56
|
+
"sync-version-pins": "node tooling/sync-version-pins.mjs",
|
|
55
57
|
"clean": "node --input-type=commonjs -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
56
58
|
"build": "npm run clean && tsc -p tsconfig.json",
|
|
57
59
|
"dev": "tsx src/cli/index.ts",
|
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.12.1",
|
|
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.12.1",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|