@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 +108 -16
- package/dist/core/docanchors.js +27 -3
- package/dist/core/docscan.js +5 -1
- package/dist/core/drift.js +11 -1
- package/dist/core/publication.js +196 -0
- package/dist/core/served.js +105 -0
- package/dist/extractors/languages.js +51 -41
- package/dist/extractors/parse.js +43 -1
- package/dist/integrations/providers.js +31 -11
- package/dist/mcp/server.js +25 -3
- package/dist/synthesis/synthesize.js +10 -2
- package/package.json +3 -1
- package/server.json +2 -2
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.
|
|
3813
|
-
//
|
|
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
|
|
3817
|
-
|
|
3818
|
-
.
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
const
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
const
|
|
3826
|
-
|
|
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 {
|
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/drift.js
CHANGED
|
@@ -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("*") ||
|
|
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",
|
package/dist/extractors/parse.js
CHANGED
|
@@ -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:
|
|
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.
|
|
147
|
-
* paths with spaces intact for source/dev installs. */
|
|
162
|
+
* machine-local CLI path. */
|
|
148
163
|
function hookCommand(inv, provider) {
|
|
149
|
-
return [
|
|
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
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
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. */
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
|
205
|
-
// ("shared") mode; else the public store.
|
|
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.
|
|
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.
|
|
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.
|
|
16
|
+
"version": "1.12.2",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|