@davesheffer/hunch 1.12.1 → 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.
@@ -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
@@ -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";
@@ -53,9 +54,23 @@ const flushNote = (flush, home, mode) => flush === "pushed" ? ` (committed + pus
53
54
  * lands in the committed store publishes on the next push, and an agent writing
54
55
  * strategy/competitive content there is a leak nobody notices until it ships
55
56
  * (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
- : "";
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
+ };
59
74
  // Read-side token budgets: every tool result is injected into a Claude Code
60
75
  // session, so an uncapped list pollutes the context window. Cap each list to its
61
76
  // highest-signal head (records are pre-sorted by severity/confidence) and tell the
@@ -910,7 +925,7 @@ export function buildServerWithRootControl(initialRoot) {
910
925
  // record commits+pushes its overlay repo; a public one commits .hunch/ in THIS repo
911
926
  // (commit only — it rides the user's next push, never auto-pushing their code branch).
912
927
  const flush = flushCapture(store, hunchPaths(root).hunch, !!decision.private, `hunch: capture ${id}`, startupTeamRoute ?? undefined);
913
- const flushed = flushNote(flush, home, store.mode) + publicHomeNote(home, store.hasPrivate);
928
+ const flushed = flushNote(flush, home, store.mode) + publicHomeNote(home, store.hasPrivate, rec, hunchPaths(root).hunch);
914
929
  // Capture-session gate (staged deprecation, §9.3): the token was consumed
915
930
  // above (it also decides the provenance tier). No token still writes
916
931
  // (non-breaking) but lands as agent_recorded with a nudge toward /capture.
@@ -987,7 +1002,7 @@ export function buildServerWithRootControl(initialRoot) {
987
1002
  if (home === "public" && !store.autoCommit)
988
1003
  refreshExistingGrounding(root, store); // overlay rules never render into committed grounding
989
1004
  const flush = flushCapture(store, hunchPaths(root).hunch, !!input.private, `hunch: capture ${rec.id}`, startupTeamRoute ?? undefined);
990
- const flushed = flushNote(flush, home, store.mode) + publicHomeNote(home, store.hasPrivate);
1005
+ const flushed = flushNote(flush, home, store.mode) + publicHomeNote(home, store.hasPrivate, rec, hunchPaths(root).hunch);
991
1006
  const enforce = rec.severity === "blocking"
992
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"
993
1008
  : "flags violating edits and PRs (advisory)";
@@ -1063,7 +1078,7 @@ export function buildServerWithRootControl(initialRoot) {
1063
1078
  store.putCapture("findings", rec, !!finding.private);
1064
1079
  store.reindex();
1065
1080
  const flush = flushCapture(store, hunchPaths(root).hunch, !!finding.private, `hunch: capture ${id}`, startupTeamRoute ?? undefined);
1066
- const flushed = flushNote(flush, home, store.mode) + publicHomeNote(home, store.hasPrivate);
1081
+ const flushed = flushNote(flush, home, store.mode) + publicHomeNote(home, store.hasPrivate, rec, hunchPaths(root).hunch);
1067
1082
  const where = finding.private
1068
1083
  ? ` [PRIVATE overlay — not committed to this repo]${flushed}`
1069
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.1",
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>",
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.1",
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.1",
16
+ "version": "1.12.2",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {