@davesheffer/hunch 1.12.0 → 1.12.2

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 CHANGED
@@ -58,6 +58,7 @@ 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
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.")
@@ -3809,28 +3851,67 @@ program
3809
3851
  // A delegated agent starts with NONE of the parent session's grounding:
3810
3852
  // session orientation never fired inside it and only per-edit PreToolUse
3811
3853
  // follows it in — so read-only agents (Explore/Plan) could work fully
3812
- // blind. Give it the invariants that must survive delegation. Public
3813
- // store only; once per agent type per session.
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.
3814
3858
  const s = new HunchStore(paths);
3815
3859
  try {
3816
- const sevRank = { blocking: 0, warning: 1, advisory: 2 };
3817
- const constraints = s.advisoryRecs("constraints")
3818
- .filter((c) => c.status === "active")
3819
- .sort((a, b) => sevRank[a.severity] - sevRank[b.severity]);
3820
- if (!constraints.length)
3821
- return;
3822
- const L = [`🧠 Hunch — delegated agent grounding: ${constraints.length} invariant(s) in force in this repo.`];
3823
- for (const c of constraints.slice(0, 8)) {
3824
- const flat = c.statement.replace(/\s+/g, " ").trim();
3825
- const claim = flat.length > 140 ? `${flat.slice(0, 139).trimEnd()}…` : flat;
3826
- L.push(`- [${c.severity}] ${claim}${c.scope.length ? ` (scope: ${c.scope.slice(0, 3).join(", ")})` : ""}`);
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).");
3827
3910
  }
3828
- if (constraints.length > 8)
3829
- L.push(`…and ${constraints.length - 8} more — hunch_check_constraints(scope) for your files.`);
3830
- L.push("Before editing: hunch_check_constraints(scope) · hunch_why(target). Orient: hunch_context(task).");
3831
3911
  // No dedup here: the hook event carries the PARENT session id, but each
3832
3912
  // spawned agent is a fresh empty context — deduping would ground the
3833
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 })));
3834
3915
  emitContext(provider, "SubagentStart", L.join("\n"));
3835
3916
  }
3836
3917
  finally {
@@ -3999,10 +4080,21 @@ program
3999
4080
  // Identical grounding already shown this session → one-line delta instead of
4000
4081
  // the full 10-16KB block. Any record change re-sends the full text; the
4001
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
+ ]);
4002
4092
  if (injectionMode(evt.session_id, `pre:${target}`, text) === "delta") {
4093
+ receipts("refreshed");
4003
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).`);
4004
4095
  return;
4005
4096
  }
4097
+ receipts("served");
4006
4098
  emitContext(provider, "PreToolUse", text);
4007
4099
  }
4008
4100
  catch {
@@ -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, and are skipped. */
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 fences = fencedRanges(text);
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 (fences.some(([s, e]) => at >= s && at <= e))
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
  }
@@ -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
- export const STALE_MARKER = /\b(proposed|not yet implemented|no code yet)\b/i;
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). */
@@ -29,6 +29,16 @@ export function computeDrift(store, root) {
29
29
  // anchor-stale. Keeps the doc≠graph gate's false-positive rate ~zero: a routine
30
30
  // narrowing supersession (successor lists fewer files) never flags files still governed.
31
31
  const liveFiles = new Set(decisions.filter(isLive).flatMap((d) => (d.related_files ?? []).map(toPosixTarget)));
32
+ // A related_files entry may name a DIRECTORY ("vscode-extension/"), which governs every
33
+ // file beneath it. Exact Set.has cannot see that, so a live directory-scoped decision
34
+ // failed to suppress anchor-stale for files it plainly covers — a false positive that
35
+ // only appeared on a public-only store, because an overlay decision happened to claim
36
+ // the same file by exact path and masked it locally.
37
+ const liveDirs = [...liveFiles].filter((f) => f.endsWith("/"));
38
+ const governedByLiveDecision = (file) => {
39
+ const p = toPosixTarget(file);
40
+ return liveFiles.has(p) || liveDirs.some((dir) => p.startsWith(dir));
41
+ };
32
42
  const premiseEnv = { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) };
33
43
  for (const d of decisions) {
34
44
  // 1. DEAD-REFERENCE — only for in-force decisions; a superseded one referencing
@@ -66,7 +76,7 @@ export function computeDrift(store, root) {
66
76
  const current = currentForTopic(decisions, d.topic);
67
77
  if (current && current.id !== d.id) {
68
78
  for (const f of d.related_files ?? []) {
69
- if (!f || f.includes("*") || liveFiles.has(toPosixTarget(f)))
79
+ if (!f || f.includes("*") || governedByLiveDecision(f))
70
80
  continue;
71
81
  if (!referenceExists(store, root, d.id, f))
72
82
  continue; // missing file is history → dead-ref's job
@@ -0,0 +1,196 @@
1
+ /** Publication safety — what a record would EXPOSE if it lands in the committed store.
2
+ *
3
+ * Context (2026-08-09, 2026-08-11): a capture defaults to the PUBLIC store, and for a
4
+ * default `hunch init` user `.hunch/*.json` is git-tracked, so an unflagged
5
+ * `hunch_record_*` publishes on the next push. Two leaks reached the public tree that
6
+ * way. Nothing inspected what the records SAID.
7
+ *
8
+ * Two tiers, deliberately unequal:
9
+ *
10
+ * - STRUCTURAL hits (machine paths, overlay paths, secret material) are
11
+ * domain-independent — a Windows home directory is a leak in any repository, in any
12
+ * industry. These are safe to enforce in a package other people install.
13
+ * - VOCABULARY hits are corpus-tuned and ship EMPTY. A term list built from one
14
+ * project's strategy prose fires on another project's ordinary engineering writing
15
+ * ("revenue" is a domain noun in a billing system). A repo opts in through
16
+ * `.hunch/publication.json`; the package never presumes.
17
+ *
18
+ * Nothing here throws or blocks. `con_03a0b94b2e` holds the lifecycle hook to
19
+ * fail-open, and a privacy heuristic is a smoke detector, not a proof — it earns a
20
+ * visible line, not a veto. */
21
+ import { readFileSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ /** Structural kinds are the only ones a package may enforce for a stranger's repo. */
24
+ const STRUCTURAL = new Set([
25
+ "machine-path",
26
+ "private-overlay-path",
27
+ "secret-material",
28
+ ]);
29
+ export function isStructural(hit) {
30
+ return STRUCTURAL.has(hit.kind);
31
+ }
32
+ /** Placeholder home directories that documentation and tests use on purpose.
33
+ * `/Users/me/repo` appears in src/integrations/claudeConfig.ts and its test as an
34
+ * illustration; flagging those would train everyone to ignore the scanner. */
35
+ const PLACEHOLDER_USER = /^(me|you|user|username|<[^>]+>|\$\{[^}]+\}|example|test|foo|bar)$/i;
36
+ const MACHINE_PATH = [
37
+ /[A-Za-z]:[\\/]Users[\\/]([^\\/"'\s,)\]]+)/g,
38
+ /(?:^|[\s"'(])\/(?:Users|home)\/([^/"'\s,)\]]+)/g,
39
+ ];
40
+ /** A path INTO the overlay (dir + file), not a bare mention of the feature. The
41
+ * gitignore entry and the CLAUDE.md description name `.hunch-private` legitimately;
42
+ * `.hunch-private/exp03-bank/cases-hunch-draft.json` names private CONTENT. */
43
+ const OVERLAY_PATH = /\.hunch-private[\\/][A-Za-z0-9._-]+[\\/][A-Za-z0-9._-]+/g;
44
+ /** Live credential shapes only. A bare `VSCE_PAT` / `AUTH_TOKEN` identifier is a
45
+ * variable name, not a secret, and flagging it produced a false positive on
46
+ * dec_cd37bf2d9a during tuning. */
47
+ const SECRET_MATERIAL = /\b(?:gh[pousr]_[A-Za-z0-9]{16,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})\b/g;
48
+ const clip = (s, max = 120) => {
49
+ const flat = s.replace(/\s+/g, " ").trim();
50
+ return flat.length > max ? flat.slice(0, max) + "…" : flat;
51
+ };
52
+ /** Fields that hold human prose. Vocabulary is scanned here only — scanning raw JSON
53
+ * would match ids, file paths and evidence lines and drown the signal. */
54
+ const PROSE_KEYS = new Set([
55
+ "title", "context", "decision", "rationale", "statement", "rule", "observation",
56
+ "summary", "description", "symptom", "root_cause", "resolution", "notes",
57
+ "consequences", "alternatives_rejected", "steps", "why",
58
+ ]);
59
+ function proseOf(record) {
60
+ const out = [];
61
+ if (!record || typeof record !== "object")
62
+ return out;
63
+ for (const [k, v] of Object.entries(record)) {
64
+ if (!PROSE_KEYS.has(k))
65
+ continue;
66
+ if (typeof v === "string")
67
+ out.push({ field: k, text: v });
68
+ else if (Array.isArray(v)) {
69
+ v.forEach((item, i) => {
70
+ if (typeof item === "string")
71
+ out.push({ field: `${k}[${i}]`, text: item });
72
+ });
73
+ }
74
+ }
75
+ return out;
76
+ }
77
+ function readPatterns(file) {
78
+ let parsed;
79
+ try {
80
+ parsed = JSON.parse(readFileSync(file, "utf8"));
81
+ }
82
+ catch {
83
+ return [];
84
+ }
85
+ const raw = parsed?.vocabulary;
86
+ if (!Array.isArray(raw))
87
+ return [];
88
+ const out = [];
89
+ for (const pattern of raw) {
90
+ if (typeof pattern !== "string")
91
+ continue;
92
+ try {
93
+ out.push(new RegExp(pattern, "gi"));
94
+ }
95
+ catch {
96
+ // One bad pattern must not disable the rest of a user's list.
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+ /** Read a repo's opt-in term list, merged from two layers:
102
+ * .hunch/publication.json committed, shareable patterns
103
+ * .hunch/publication.local.json gitignored, per-machine patterns
104
+ *
105
+ * The local layer exists because the list itself can be sensitive. A rule that
106
+ * catches a competitor teardown has to NAME competitors, and committing that
107
+ * publishes a watchlist — the exact class of content the scanner is meant to keep
108
+ * out of a public repo. Patterns that would embarrass you if read belong in the
109
+ * local layer; generic ones can ship.
110
+ *
111
+ * Absent file, malformed JSON, or an invalid pattern all degrade to "no vocabulary".
112
+ * A privacy heuristic that crashes a capture is worse than one that stays quiet, and
113
+ * the structural tier — the part that works for everyone — never depends on this. */
114
+ export function loadVocabulary(hunchDir) {
115
+ return [
116
+ ...readPatterns(join(hunchDir, "publication.json")),
117
+ ...readPatterns(join(hunchDir, "publication.local.json")),
118
+ ];
119
+ }
120
+ /** Every string VALUE in the record, with the field path that carried it.
121
+ * Deliberately not `JSON.stringify` + one regex pass: the JSON encoding doubles
122
+ * backslashes, so `C:\Users\x` becomes `C:\\Users\\x` and a path rule written for
123
+ * real text silently stops matching on Windows — which is exactly how the first
124
+ * version of this scanner passed its macOS cases and failed its Windows ones. */
125
+ function stringValues(node, path, seen, out) {
126
+ if (typeof node === "string") {
127
+ out.push({ field: path, text: node });
128
+ return;
129
+ }
130
+ if (!node || typeof node !== "object")
131
+ return;
132
+ if (seen.has(node))
133
+ return; // cycles are input we tolerate, never throw on
134
+ seen.add(node);
135
+ if (Array.isArray(node)) {
136
+ node.forEach((v, i) => stringValues(v, `${path}[${i}]`, seen, out));
137
+ return;
138
+ }
139
+ for (const [k, v] of Object.entries(node)) {
140
+ stringValues(v, path === "$" ? k : `${path}.${k}`, seen, out);
141
+ }
142
+ }
143
+ /** Pure. No IO, no throw. Returns every sensitivity signal in one record. */
144
+ export function scanRecord(record, opts = {}) {
145
+ const hits = [];
146
+ if (!record || typeof record !== "object")
147
+ return hits;
148
+ const values = [];
149
+ stringValues(record, "$", new WeakSet(), values);
150
+ for (const { field, text } of values) {
151
+ for (const re of MACHINE_PATH) {
152
+ for (const m of text.matchAll(re)) {
153
+ const who = m[1] ?? "";
154
+ if (PLACEHOLDER_USER.test(who))
155
+ continue;
156
+ hits.push({ kind: "machine-path", field, excerpt: clip(m[0]) });
157
+ }
158
+ }
159
+ for (const m of text.matchAll(OVERLAY_PATH)) {
160
+ hits.push({ kind: "private-overlay-path", field, excerpt: clip(m[0]) });
161
+ }
162
+ for (const m of text.matchAll(SECRET_MATERIAL)) {
163
+ // Never echo a live credential back into a log or a tool result.
164
+ hits.push({ kind: "secret-material", field, excerpt: `${m[0].slice(0, 6)}… (redacted)` });
165
+ }
166
+ }
167
+ const vocab = opts.vocabulary ?? [];
168
+ if (vocab.length) {
169
+ for (const { field, text } of proseOf(record)) {
170
+ for (const re of vocab) {
171
+ const probe = new RegExp(re.source, re.flags.includes("g") ? re.flags : re.flags + "g");
172
+ for (const m of text.matchAll(probe)) {
173
+ const at = m.index ?? 0;
174
+ hits.push({
175
+ kind: "market-vocabulary",
176
+ field,
177
+ excerpt: clip(text.slice(Math.max(0, at - 40), at + m[0].length + 40)),
178
+ });
179
+ }
180
+ }
181
+ }
182
+ }
183
+ return hits;
184
+ }
185
+ /** One line naming what was matched and the exact remedy. The predecessor of this
186
+ * message was a generic "for sensitive content use private:true" nudge, which was
187
+ * present and ignored during both leaks — a warning that does not quote the offending
188
+ * text reads as boilerplate. */
189
+ export function publicationWarning(hits) {
190
+ if (!hits.length)
191
+ return "";
192
+ const shown = hits.slice(0, 3).map((h) => `${h.kind}${h.field === "$" ? "" : ` in ${h.field}`}: "${h.excerpt}"`);
193
+ const more = hits.length > shown.length ? ` (+${hits.length - shown.length} more)` : "";
194
+ return `\n⚠ PUBLICATION RISK — this record would publish with the repo:\n ${shown.join("\n ")}${more}\n Re-record with private:true to route it to the overlay instead.`;
195
+ }
196
+ //# sourceMappingURL=publication.js.map
@@ -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
@@ -6,25 +6,25 @@
6
6
  * four files.
7
7
  */
8
8
  import { loadNativeTreeSitter } from "./nativeTreeSitter.js";
9
- const TS_QUERY = `
10
- (function_declaration name: (identifier) @fn.name) @fn.def
11
- (generator_function_declaration name: (identifier) @fn.name) @fn.def
12
- (method_definition name: (property_identifier) @method.name) @method.def
13
- (class_declaration name: (type_identifier) @class.name) @class.def
14
- (interface_declaration name: (type_identifier) @iface.name) @iface.def
15
- (type_alias_declaration name: (type_identifier) @type.name) @type.def
16
- (variable_declarator
17
- name: (identifier) @arrow.name
18
- value: [(arrow_function) (function_expression)]) @arrow.def
19
- (import_statement source: (string) @import.src)
20
- (call_expression function: (identifier) @call.id)
21
- (call_expression function: (member_expression property: (property_identifier) @call.member))
22
- ;; Construction IS a call. Without these, \`new Foo()\` produced no edge at all, so
23
- ;; every class in a TS/JS repo had fan_in 0: blast radius before a constructor
24
- ;; change came back empty, and a \`not-calls\` conformance predicate over a class
25
- ;; could never see its own counterexample.
26
- (new_expression constructor: (identifier) @call.id)
27
- (new_expression constructor: (member_expression property: (property_identifier) @call.member))
9
+ const TS_QUERY = `
10
+ (function_declaration name: (identifier) @fn.name) @fn.def
11
+ (generator_function_declaration name: (identifier) @fn.name) @fn.def
12
+ (method_definition name: (property_identifier) @method.name) @method.def
13
+ (class_declaration name: (type_identifier) @class.name) @class.def
14
+ (interface_declaration name: (type_identifier) @iface.name) @iface.def
15
+ (type_alias_declaration name: (type_identifier) @type.name) @type.def
16
+ (variable_declarator
17
+ name: (identifier) @arrow.name
18
+ value: [(arrow_function) (function_expression)]) @arrow.def
19
+ (import_statement source: (string) @import.src)
20
+ (call_expression function: (identifier) @call.id)
21
+ (call_expression function: (member_expression property: (property_identifier) @call.member))
22
+ ;; Construction IS a call. Without these, \`new Foo()\` produced no edge at all, so
23
+ ;; every class in a TS/JS repo had fan_in 0: blast radius before a constructor
24
+ ;; change came back empty, and a \`not-calls\` conformance predicate over a class
25
+ ;; could never see its own counterexample.
26
+ (new_expression constructor: (identifier) @call.id)
27
+ (new_expression constructor: (member_expression property: (property_identifier) @call.member))
28
28
  `;
29
29
  const TS_BUILTIN_METHODS = new Set([
30
30
  "map", "filter", "forEach", "reduce", "find", "findIndex", "some", "every", "includes",
@@ -54,6 +54,16 @@ const TS_SHARED = {
54
54
  "iface.name": "iface.def", "type.name": "type.def", "arrow.name": "arrow.def",
55
55
  },
56
56
  builtinMethods: TS_BUILTIN_METHODS,
57
+ // ES2018 relaxed template literals: an INVALID escape (`\x` with no hex, `\u`
58
+ // short, `\u{` unterminated) is legal inside a TAGGED template — the cooked
59
+ // value is undefined and the raw text survives, which is the entire point of
60
+ // String.raw`C:\Users\x`. tree-sitter-javascript never implemented that
61
+ // relaxation and is identical through 0.25.0, so it emits an ERROR node inside
62
+ // the template_string. In an UNTAGGED template the same escape IS a syntax
63
+ // error, and the grammar models the two differently — a tagged template's
64
+ // template_string hangs off a call_expression, an untagged one off whatever
65
+ // consumes the value — so requiring that pair keeps genuine errors failing.
66
+ toleratedErrorScopes: [{ node: "template_string", parentIs: "call_expression" }],
57
67
  };
58
68
  const TYPESCRIPT = {
59
69
  ...TS_SHARED,
@@ -71,28 +81,28 @@ const TSX = {
71
81
  grammarKey: "tsx",
72
82
  loadGrammar: () => loadNativeTreeSitter().tsx,
73
83
  };
74
- const PY_QUERY = `
75
- (class_definition
76
- name: (identifier) @class.name
77
- body: (block
78
- [
79
- (function_definition name: (identifier) @method.name) @method.def
80
- (decorated_definition definition: (function_definition name: (identifier) @method.name) @method.def)
81
- ])) @class.def
82
- ;; Every class, including one with no directly-nested def: dataclasses, Exception
83
- ;; subclasses, Enums, TypedDicts and pydantic models are method-less by design and
84
- ;; were invisible to the entire graph (no symbol, no component, no edges), so
85
- ;; \`hunch why\` and blast radius came back empty for exactly the classes a refactor
86
- ;; breaks. parse.ts keys pendingDefs by node id and keeps the first classification,
87
- ;; so a class that ALSO matches the method-bearing pattern above is not duplicated.
88
- (class_definition name: (identifier) @class.name) @class.def
89
- (function_definition name: (identifier) @fn.name) @fn.def
90
- (import_statement name: (dotted_name) @import.src)
91
- (import_statement name: (aliased_import name: (dotted_name) @import.src))
92
- (import_from_statement module_name: (dotted_name) @import.src)
93
- (import_from_statement module_name: (relative_import) @import.src)
94
- (call function: (identifier) @call.id)
95
- (call function: (attribute attribute: (identifier) @call.member))
84
+ const PY_QUERY = `
85
+ (class_definition
86
+ name: (identifier) @class.name
87
+ body: (block
88
+ [
89
+ (function_definition name: (identifier) @method.name) @method.def
90
+ (decorated_definition definition: (function_definition name: (identifier) @method.name) @method.def)
91
+ ])) @class.def
92
+ ;; Every class, including one with no directly-nested def: dataclasses, Exception
93
+ ;; subclasses, Enums, TypedDicts and pydantic models are method-less by design and
94
+ ;; were invisible to the entire graph (no symbol, no component, no edges), so
95
+ ;; \`hunch why\` and blast radius came back empty for exactly the classes a refactor
96
+ ;; breaks. parse.ts keys pendingDefs by node id and keeps the first classification,
97
+ ;; so a class that ALSO matches the method-bearing pattern above is not duplicated.
98
+ (class_definition name: (identifier) @class.name) @class.def
99
+ (function_definition name: (identifier) @fn.name) @fn.def
100
+ (import_statement name: (dotted_name) @import.src)
101
+ (import_statement name: (aliased_import name: (dotted_name) @import.src))
102
+ (import_from_statement module_name: (dotted_name) @import.src)
103
+ (import_from_statement module_name: (relative_import) @import.src)
104
+ (call function: (identifier) @call.id)
105
+ (call function: (attribute attribute: (identifier) @call.member))
96
106
  `;
97
107
  const PY_BUILTIN_METHODS = new Set([
98
108
  "get", "set", "keys", "values", "items", "pop", "popitem", "update", "setdefault", "copy", "clear",
@@ -82,7 +82,49 @@ export function parseSource(file, source) {
82
82
  });
83
83
  }
84
84
  symbols.sort((a, b) => a.startByte - b.startByte);
85
- return { symbols, imports, calls, parseable: !tree.rootNode.hasError };
85
+ return { symbols, imports, calls, parseable: isParseable(tree.rootNode, spec) };
86
+ }
87
+ /** True when every ERROR/MISSING node in the tree sits in an ancestor shape this
88
+ * language declares as a known grammar limitation (LanguageSpec.toleratedErrorScopes).
89
+ *
90
+ * This matters because `conform` is fail-CLOSED on scan completeness: one file
91
+ * reporting parseable:false rejects the WHOLE architectural-conformance scan, so a
92
+ * grammar false positive takes down the gate for the entire repo. Scoping the
93
+ * tolerance to a declared ancestor pair — rather than downgrading unparseable files
94
+ * to a warning — keeps the completeness guarantee intact for real syntax errors.
95
+ *
96
+ * A tolerated ERROR's children are not visited: tree-sitter reports the same span
97
+ * again as a nested ERROR child, and the raw text inside a template literal cannot
98
+ * contain an independent error to hide. */
99
+ function isParseable(root, spec) {
100
+ if (!root.hasError)
101
+ return true; // covers ERROR and MISSING; no walk needed
102
+ const scopes = spec.toleratedErrorScopes ?? [];
103
+ if (scopes.length === 0)
104
+ return false;
105
+ let ok = true;
106
+ const visit = (node) => {
107
+ if (!ok)
108
+ return;
109
+ if (node.type === "ERROR" || node.isMissing) {
110
+ if (!inToleratedScope(node, scopes))
111
+ ok = false;
112
+ return;
113
+ }
114
+ for (let i = 0; i < node.childCount; i++)
115
+ visit(node.child(i));
116
+ };
117
+ visit(root);
118
+ return ok;
119
+ }
120
+ function inToleratedScope(node, scopes) {
121
+ for (let ancestor = node.parent; ancestor; ancestor = ancestor.parent) {
122
+ for (const scope of scopes) {
123
+ if (ancestor.type === scope.node && ancestor.parent?.type === scope.parentIs)
124
+ return true;
125
+ }
126
+ }
127
+ return false;
86
128
  }
87
129
  /** Walk up to the nearest node whose type is a definition this language recognizes. */
88
130
  function ascendToDef(node, defNodeTypes) {
@@ -141,24 +141,44 @@ function writeJson(file, obj) {
141
141
  writeFileAtomic(file, JSON.stringify(obj, null, 2) + "\n");
142
142
  return file;
143
143
  }
144
+ /** Quote one argv token only when it needs quoting. These commands are run by
145
+ * whatever shell the host assistant uses, which on Windows is PowerShell — and
146
+ * PowerShell parses a QUOTED first token as a string expression, not a command,
147
+ * so the old quote-everything form died before the hook ever ran:
148
+ *
149
+ * "npx" "-y" "--package=…" "hunch" "hook" "--provider" "vscode"
150
+ * → Unexpected token '"-y"' in expression or statement.
151
+ *
152
+ * A token of safe characters is a command/word in PowerShell, cmd AND POSIX sh,
153
+ * so quote-only-when-needed is the one shape all three accept. Backslash is not
154
+ * safe bare (sh eats it as an escape), so Windows paths still get quoted — those
155
+ * appear only in per-machine source-checkout installs, never in the published
156
+ * npx invocation that `hunch init` writes into tracked config. */
157
+ function shellToken(part) {
158
+ return /^[A-Za-z0-9_@:=+.,/-]+$/.test(part) ? part : JSON.stringify(part);
159
+ }
144
160
  /** Provider hook commands live in tracked config files, so use the structured
145
161
  * invocation (the same portable npx package reference as MCP) rather than a
146
- * machine-local CLI path. JSON quoting is accepted by POSIX shells and keeps
147
- * paths with spaces intact for source/dev installs. */
162
+ * machine-local CLI path. */
148
163
  function hookCommand(inv, provider) {
149
- return [...[inv.command], ...inv.args, "hook", "--provider", provider].map((part) => JSON.stringify(part)).join(" ");
164
+ return [inv.command, ...inv.args, "hook", "--provider", provider].map(shellToken).join(" ");
150
165
  }
151
166
  function isHunchProviderHook(entry) {
152
167
  const e = entry && typeof entry === "object" ? entry : null;
153
168
  const command = typeof e?.command === "string" ? e.command : "";
154
- // Anchored to the exact shape hookCommand() writes — JSON-quoted parts ending
155
- // in "hook" "--provider" "<name>" plus a Hunch launcher (the pinned npm
156
- // package spec, or a quoted …/index.js|ts path for source installs). The old
157
- // unanchored /index\.(js|ts)/ + /\bhook\b/ pair classified FOREIGN entries
158
- // like `node ./hook/index.js` as ours and silently deleted them, violating
159
- // the leave-every-foreign-hook-in-place contract (con_8460b6770f, issue #41).
160
- return /(?:@davesheffer\/hunch|[\\/]index\.(?:js|ts)")/.test(command)
161
- && /\s"hook"(?:\s+"--provider"\s+"[a-z]+")?\s*$/.test(command);
169
+ // Anchored to the shapes hookCommand() writes — a Hunch launcher (the pinned
170
+ // npm package spec, or a …/index.js|ts path for source installs) plus a tail
171
+ // of `hook --provider <name>`, tokens quoted or bare. The old unanchored
172
+ // /index\.(js|ts)/ + /\bhook\b/ pair classified FOREIGN entries like
173
+ // `node ./hook/index.js` as ours and silently deleted them, violating the
174
+ // leave-every-foreign-hook-in-place contract (con_8460b6770f, issue #41), so
175
+ // the bare tail must still carry --provider to match; only the LEGACY
176
+ // fully-quoted form (written before this quoting fix, and by hunch versions
177
+ // that predate --provider) may omit it, and its quotes keep it unambiguous.
178
+ const launcher = /@davesheffer\/hunch|[\\/]index\.(?:js|ts)(?=["\s]|$)/.test(command);
179
+ const legacyTail = /\s"hook"(?:\s+"--provider"\s+"[a-z]+")?\s*$/.test(command);
180
+ const tail = /\s"?hook"?\s+"?--provider"?\s+"?[a-z]+"?\s*$/.test(command);
181
+ return launcher && (legacyTail || tail);
162
182
  }
163
183
  /** Merge our command entries into a standard `{ hooks: { Event: [] } }` file.
164
184
  * We replace only old Hunch commands and leave every foreign hook in place. */
@@ -33,6 +33,7 @@ import { HUNCH_VERSION } from "../core/version.js";
33
33
  import { assertCompleteRepoScan, indexRepo, scanRepo } from "../extractors/indexer.js";
34
34
  import { liveForTopic, historyForTopic, rejectedForTopic, captureConflicts } from "../core/topics.js";
35
35
  import { pendingEscalations, policyEscalations } from "../core/escalations.js";
36
+ import { scanRecord, publicationWarning, loadVocabulary } from "../core/publication.js";
36
37
  import { premiseEscalations } from "../core/premises.js";
37
38
  import { issueCaptureToken as issueToken, consumeCaptureToken as consumeToken } from "../core/capturetoken.js";
38
39
  import { randomUUID } from "node:crypto";
@@ -49,6 +50,27 @@ const flushNote = (flush, home, mode) => flush === "pushed" ? ` (committed + pus
49
50
  ? " (committed to the overlay repo — push deferred: offline, no upstream, or merge conflict; the next capture or `hunch private --sync` retries)"
50
51
  : " (auto-committed to .hunch/ — rides your next push)"
51
52
  : "";
53
+ /** When an overlay exists, a PUBLIC write deserves one visible line: a record that
54
+ * lands in the committed store publishes on the next push, and an agent writing
55
+ * strategy/competitive content there is a leak nobody notices until it ships
56
+ * (2026-08-09: 15 roadmap records caught pre-push only by a release sweep). */
57
+ /** Repo-local term list, read once per server process. The package ships none;
58
+ * `.hunch/publication.json` is how a repo opts in (see src/core/publication.ts). */
59
+ let vocabularyCache = null;
60
+ const publicationVocabulary = (hunchDir) => (vocabularyCache ??= loadVocabulary(hunchDir));
61
+ const publicHomeNote = (home, hasPrivate, record, hunchDir) => {
62
+ if (home !== "public")
63
+ return "";
64
+ // The generic nudge below was already present during BOTH leaks and was ignored,
65
+ // because a warning that cannot quote the offending text reads as boilerplate.
66
+ // scanRecord adds the specific line: what matched, in which field.
67
+ const risk = record === undefined
68
+ ? ""
69
+ : publicationWarning(scanRecord(record, { vocabulary: hunchDir ? publicationVocabulary(hunchDir) : [] }));
70
+ if (!hasPrivate)
71
+ return risk;
72
+ 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
+ };
52
74
  // Read-side token budgets: every tool result is injected into a Claude Code
53
75
  // session, so an uncapped list pollutes the context window. Cap each list to its
54
76
  // highest-signal head (records are pre-sorted by severity/confidence) and tell the
@@ -903,7 +925,7 @@ export function buildServerWithRootControl(initialRoot) {
903
925
  // record commits+pushes its overlay repo; a public one commits .hunch/ in THIS repo
904
926
  // (commit only — it rides the user's next push, never auto-pushing their code branch).
905
927
  const flush = flushCapture(store, hunchPaths(root).hunch, !!decision.private, `hunch: capture ${id}`, startupTeamRoute ?? undefined);
906
- const flushed = flushNote(flush, home, store.mode);
928
+ const flushed = flushNote(flush, home, store.mode) + publicHomeNote(home, store.hasPrivate, rec, hunchPaths(root).hunch);
907
929
  // Capture-session gate (staged deprecation, §9.3): the token was consumed
908
930
  // above (it also decides the provenance tier). No token still writes
909
931
  // (non-breaking) but lands as agent_recorded with a nudge toward /capture.
@@ -980,7 +1002,7 @@ export function buildServerWithRootControl(initialRoot) {
980
1002
  if (home === "public" && !store.autoCommit)
981
1003
  refreshExistingGrounding(root, store); // overlay rules never render into committed grounding
982
1004
  const flush = flushCapture(store, hunchPaths(root).hunch, !!input.private, `hunch: capture ${rec.id}`, startupTeamRoute ?? undefined);
983
- const flushed = flushNote(flush, home, store.mode);
1005
+ const flushed = flushNote(flush, home, store.mode) + publicHomeNote(home, store.hasPrivate, rec, hunchPaths(root).hunch);
984
1006
  const enforce = rec.severity === "blocking"
985
1007
  ? "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
1008
  : "flags violating edits and PRs (advisory)";
@@ -1056,7 +1078,7 @@ export function buildServerWithRootControl(initialRoot) {
1056
1078
  store.putCapture("findings", rec, !!finding.private);
1057
1079
  store.reindex();
1058
1080
  const flush = flushCapture(store, hunchPaths(root).hunch, !!finding.private, `hunch: capture ${id}`, startupTeamRoute ?? undefined);
1059
- const flushed = flushNote(flush, home, store.mode);
1081
+ const flushed = flushNote(flush, home, store.mode) + publicHomeNote(home, store.hasPrivate, rec, hunchPaths(root).hunch);
1060
1082
  const where = finding.private
1061
1083
  ? ` [PRIVATE overlay — not committed to this repo]${flushed}`
1062
1084
  : home === "private" ? ` [SHARED store — one source of truth for the whole team]${flushed}` : flushed;
@@ -201,8 +201,16 @@ export async function syncCommit(store, root, sha, opts = {}) {
201
201
  },
202
202
  date: meta.date, // the commit date
203
203
  };
204
- // Route to the record's ONE home: the overlay when asked (--private) or in unified
205
- // ("shared") mode; else the public store. Same contract as every other capture path.
204
+ // Route to the resolved home: the overlay when asked (--private / opts.home) or in
205
+ // unified ("shared") mode; else the public store.
206
+ //
207
+ // DELIBERATELY home-scoped, NOT putCapture. Synthesis keeps the public and private
208
+ // spines separate: a public failure must never reach through and rewrite a same-id
209
+ // private record, which putCapture's cross-home collision guard would either throw on
210
+ // or (via putWhereItLives) silently redirect. test/private-capture.test.ts pins that
211
+ // behaviour — "public post-promotion rewrite cannot overwrite the private collision".
212
+ // An earlier comment here claimed the "same contract as every other capture path",
213
+ // which read as an accidental bypass and invited exactly that wrong fix.
206
214
  if (home === "private")
207
215
  store.putPrivate("decisions", decision);
208
216
  else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.12.0",
3
+ "version": "1.12.2",
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.12.0",
10
+ "version": "1.12.2",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@davesheffer/hunch",
16
- "version": "1.12.0",
16
+ "version": "1.12.2",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {