@davesheffer/hunch 1.12.2 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +1 -1
- package/README.md +14 -16
- package/dist/cli/index.js +59 -37
- package/dist/core/delivery.js +322 -0
- package/dist/core/format.js +3 -50
- package/dist/core/glob.js +7 -0
- package/dist/core/served.js +42 -5
- package/dist/mcp/server.js +7 -1
- package/dist/store/hunchStore.js +7 -10
- package/package.json +1 -1
- package/server.json +2 -2
package/NOTICE
CHANGED
package/README.md
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
[](https://github.com/davesheffer/hunch)
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
|
|
9
|
-
Hunch is engineering
|
|
10
|
-
rejected approaches, and bug history behind your code,
|
|
11
|
-
relevant
|
|
9
|
+
Hunch is the engineering-memory and architectural-conformance layer for AI-assisted codebases. It
|
|
10
|
+
records the decisions, constraints, rejected approaches, and bug history behind your code, delivers
|
|
11
|
+
the relevant evidence before an assistant changes anything, then checks the result deterministically.
|
|
12
12
|
|
|
13
13
|
Memory starts **advisory**. Nothing blocks until you explicitly trust a precise rule and choose
|
|
14
14
|
strict enforcement.
|
|
@@ -16,9 +16,10 @@ strict enforcement.
|
|
|
16
16
|
**Memory is the input. The product boundary is the receipt:** relevant evidence before an edit,
|
|
17
17
|
then a deterministic check of the change against the rules your team has explicitly trusted.
|
|
18
18
|
|
|
19
|
-
> **New in v1.
|
|
20
|
-
>
|
|
21
|
-
|
|
19
|
+
> **New in v1.13.0:** CLI, MCP, and edit hooks now share one provenance-checked, hard-budgeted
|
|
20
|
+
> delivery envelope, and local receipts record exactly what reached the agent and why.
|
|
21
|
+
|
|
22
|
+
See the public [roadmap](ROADMAP.md) for what is next and what is deliberately out of scope.
|
|
22
23
|
|
|
23
24
|
## Start in five minutes
|
|
24
25
|
|
|
@@ -81,7 +82,7 @@ Git repo that every teammate can access, install the Matrix release on team mach
|
|
|
81
82
|
have one maintainer run:
|
|
82
83
|
|
|
83
84
|
```bash
|
|
84
|
-
npm i -g @davesheffer/hunch@1.
|
|
85
|
+
npm i -g @davesheffer/hunch@1.13.0
|
|
85
86
|
hunch shared --repo git@github.com:acme/project-hunch-memory.git
|
|
86
87
|
git add .gitignore .hunch/team.json
|
|
87
88
|
git commit -m "chore: connect shared Hunch memory"
|
|
@@ -96,7 +97,7 @@ printed by Hunch. Omit `--migrate` for a new setup.
|
|
|
96
97
|
After the pointer commit lands, teammates need Hunch installed and Git access to the memory repo:
|
|
97
98
|
|
|
98
99
|
```bash
|
|
99
|
-
npm i -g @davesheffer/hunch@1.
|
|
100
|
+
npm i -g @davesheffer/hunch@1.13.0
|
|
100
101
|
git pull
|
|
101
102
|
hunch init
|
|
102
103
|
hunch doctor
|
|
@@ -124,10 +125,8 @@ hunch policy card pol_...
|
|
|
124
125
|
```
|
|
125
126
|
|
|
126
127
|
The upgrade creates evidence, a plan, and a proof but leaves the policy proposed with
|
|
127
|
-
`authority: none`.
|
|
128
|
-
|
|
129
|
-
explicit audited human acceptance before they can become advisory or blocking; Hunch never grants
|
|
130
|
-
that authority automatically.
|
|
128
|
+
`authority: none`. A proved policy still requires explicit, audited human acceptance before it can
|
|
129
|
+
become advisory or blocking; Hunch never grants that authority automatically.
|
|
131
130
|
|
|
132
131
|
Need to pause or roll back without deleting memory?
|
|
133
132
|
|
|
@@ -143,10 +142,9 @@ but stops automatic memory commits and pushes. As a team-coordinated rollback, r
|
|
|
143
142
|
commit to stop discovery after teammates pull the revert. Existing machines retain their ignored
|
|
144
143
|
local overlay until they are deliberately disconnected; do not delete the memory repo as part of a
|
|
145
144
|
rollback. For this rollout, reinstall the previous published package with
|
|
146
|
-
`npm i -g @davesheffer/hunch@1.
|
|
147
|
-
target from the npm registry instead of trusting Git tags.
|
|
148
|
-
|
|
149
|
-
team client to v1.9 before resuming Matrix policy workflows.
|
|
145
|
+
`npm i -g @davesheffer/hunch@1.12.1`; the release receipt resolves and records the verified rollback
|
|
146
|
+
target from the npm registry instead of trusting Git tags. Pause enforcement first as shown above,
|
|
147
|
+
and keep every team client on the same release before resuming Matrix policy workflows.
|
|
150
148
|
|
|
151
149
|
## Synthesis without surprise billing
|
|
152
150
|
|
package/dist/cli/index.js
CHANGED
|
@@ -52,6 +52,7 @@ import { writeMcpJson, writeSlashCommands, installClaudeHooks } from "../integra
|
|
|
52
52
|
import { scaffoldProviders, regenerateGrounding, refreshExistingGrounding, refreshCommittableGrounding } from "../integrations/providers.js";
|
|
53
53
|
import { healClaudeConfigCaseSplit } from "../integrations/claudeConfig.js";
|
|
54
54
|
import { formatContext, formatStructure } from "../core/format.js";
|
|
55
|
+
import { buildDeliveryEnvelope } from "../core/delivery.js";
|
|
55
56
|
import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
|
|
56
57
|
import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
|
|
57
58
|
import { isHumanConfirmed } from "../core/strictgate.js";
|
|
@@ -69,7 +70,7 @@ import { computeDrift } from "../core/drift.js";
|
|
|
69
70
|
import { renderCompilerScorecard, scoreCompilerCaseBank } from "../constitution/scorecard.js";
|
|
70
71
|
import { generateWiki, wikiStatus, wikiPrompt, publicHome, privateHome, readWikiManifestAt, nowData } from "../wiki/wiki.js";
|
|
71
72
|
import { adoptProsePrompt } from "../wiki/adopt.js";
|
|
72
|
-
import { topicCollisions,
|
|
73
|
+
import { topicCollisions, isInForce } from "../core/topics.js";
|
|
73
74
|
import { pendingEscalations, policyEscalations } from "../core/escalations.js";
|
|
74
75
|
import { premiseEscalations } from "../core/premises.js";
|
|
75
76
|
import { parseDocAnchors, renderDocGrounding } from "../core/docanchors.js";
|
|
@@ -92,7 +93,7 @@ import { planCompaction } from "../store/compact.js";
|
|
|
92
93
|
import { repairDecisionReference } from "../core/refrepair.js";
|
|
93
94
|
import { resolveInvocation, dim, synthesisStatusLines, maybeWarnOllamaContext } from "./invocation.js";
|
|
94
95
|
const program = new Command();
|
|
95
|
-
program.name("hunch").description("Hunch —
|
|
96
|
+
program.name("hunch").description("Hunch — engineering memory and a deterministic Change Gate for AI-assisted codebases.").version(HUNCH_VERSION);
|
|
96
97
|
let openStore = null;
|
|
97
98
|
function openTeamStore(root, opts = {}) {
|
|
98
99
|
// A committed team.json is an explicit declaration that this checkout belongs
|
|
@@ -3518,7 +3519,13 @@ program
|
|
|
3518
3519
|
return;
|
|
3519
3520
|
}
|
|
3520
3521
|
}
|
|
3521
|
-
process.stdout.write(formatContext(ctx
|
|
3522
|
+
process.stdout.write(formatContext(ctx, {
|
|
3523
|
+
root,
|
|
3524
|
+
symbols: store.recs("symbols"),
|
|
3525
|
+
components: store.recs("components"),
|
|
3526
|
+
decisionCorpus: store.recs("decisions"),
|
|
3527
|
+
historical: !!asOf,
|
|
3528
|
+
}));
|
|
3522
3529
|
store.close();
|
|
3523
3530
|
});
|
|
3524
3531
|
// ---- timeline -------------------------------------------------------------
|
|
@@ -3746,7 +3753,9 @@ program
|
|
|
3746
3753
|
};
|
|
3747
3754
|
console.log(" Most delivered:");
|
|
3748
3755
|
for (const row of summary.rows.slice(0, 10)) {
|
|
3749
|
-
|
|
3756
|
+
const delivery = row.best_rank == null ? "" : ` · best rank ${row.best_rank}`;
|
|
3757
|
+
const cost = row.average_token_cost == null ? "" : ` · ~${row.average_token_cost} tokens`;
|
|
3758
|
+
console.log(` ${String(row.serves).padStart(4)}× (+${row.refreshes} still-current) ${row.record_id}${delivery}${cost} — ${titleOf(row)}`);
|
|
3750
3759
|
}
|
|
3751
3760
|
const servedIds = new Set(summary.rows.map((r) => r.record_id));
|
|
3752
3761
|
const neverServed = [
|
|
@@ -3871,8 +3880,9 @@ program
|
|
|
3871
3880
|
return;
|
|
3872
3881
|
L.push(`🧠 Hunch — repo shape for a delegated explorer: ${components.length} component(s).`);
|
|
3873
3882
|
for (const c of components.slice(0, 12)) {
|
|
3874
|
-
|
|
3875
|
-
|
|
3883
|
+
const line = `- ${c.name}${c.paths.length ? ` (${c.paths.slice(0, 2).join(", ")})` : ""}${c.responsibility ? ` — ${clip1(c.responsibility, 90)}` : ""}`;
|
|
3884
|
+
L.push(line);
|
|
3885
|
+
served.push({ kind: "components", record_id: c.id, token_cost: Math.ceil([...line].length / 4) });
|
|
3876
3886
|
}
|
|
3877
3887
|
if (components.length > 12)
|
|
3878
3888
|
L.push(`…and ${components.length - 12} more — hunch_structure() for the full map.`);
|
|
@@ -3887,8 +3897,9 @@ program
|
|
|
3887
3897
|
return;
|
|
3888
3898
|
L.push(`🧠 Hunch — live decisions for a delegated planner (${decisions.length} in force; plans must not re-propose the rejected).`);
|
|
3889
3899
|
for (const d of decisions.slice(0, 6)) {
|
|
3890
|
-
|
|
3891
|
-
|
|
3900
|
+
const line = `- ${d.title} (${d.id})${d.alternatives_rejected.length ? ` — rejected: ${clip1(d.alternatives_rejected[0], 80)}` : ""}`;
|
|
3901
|
+
L.push(line);
|
|
3902
|
+
served.push({ kind: "decisions", record_id: d.id, token_cost: Math.ceil([...line].length / 4) });
|
|
3892
3903
|
}
|
|
3893
3904
|
L.push("Before finalizing a plan: hunch_why(target) · hunch_current_decision(topic) · hunch_check_constraints(scope).");
|
|
3894
3905
|
}
|
|
@@ -3901,8 +3912,9 @@ program
|
|
|
3901
3912
|
return;
|
|
3902
3913
|
L.push(`🧠 Hunch — delegated agent grounding: ${constraints.length} invariant(s) in force in this repo.`);
|
|
3903
3914
|
for (const c of constraints.slice(0, 8)) {
|
|
3904
|
-
|
|
3905
|
-
|
|
3915
|
+
const line = `- [${c.severity}] ${clip1(c.statement, 140)}${c.scope.length ? ` (scope: ${c.scope.slice(0, 3).join(", ")})` : ""}`;
|
|
3916
|
+
L.push(line);
|
|
3917
|
+
served.push({ kind: "constraints", record_id: c.id, token_cost: Math.ceil([...line].length / 4) });
|
|
3906
3918
|
}
|
|
3907
3919
|
if (constraints.length > 8)
|
|
3908
3920
|
L.push(`…and ${constraints.length - 8} more — hunch_check_constraints(scope) for your files.`);
|
|
@@ -3911,7 +3923,15 @@ program
|
|
|
3911
3923
|
// No dedup here: the hook event carries the PARENT session id, but each
|
|
3912
3924
|
// spawned agent is a fresh empty context — deduping would ground the
|
|
3913
3925
|
// first Explore and silently starve every later one.
|
|
3914
|
-
recordServed(root, served.map((r) => ({
|
|
3926
|
+
recordServed(root, served.map((r, index) => ({
|
|
3927
|
+
...r,
|
|
3928
|
+
event: "served",
|
|
3929
|
+
target: `(subagent:${evt.agent_type ?? "any"})`,
|
|
3930
|
+
session_id: evt.session_id,
|
|
3931
|
+
rank: index + 1,
|
|
3932
|
+
delivery_reason: "ranked",
|
|
3933
|
+
provenance_status: "unverified",
|
|
3934
|
+
})));
|
|
3915
3935
|
emitContext(provider, "SubagentStart", L.join("\n"));
|
|
3916
3936
|
}
|
|
3917
3937
|
finally {
|
|
@@ -4058,40 +4078,42 @@ program
|
|
|
4058
4078
|
const hasContent = ctx.constraints.length || ctx.decisions.length || ctx.bugs.length || ctx.blast_radius.length || ctx.findings.length || retired.length || docGround;
|
|
4059
4079
|
if (!hasContent)
|
|
4060
4080
|
return; // no noise on files Hunch hasn't learned yet
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
text += `\n\n${grounding}`;
|
|
4078
|
-
if (docGround)
|
|
4079
|
-
text += `\n\n${docGround}`;
|
|
4081
|
+
const envelope = buildDeliveryEnvelope(ctx, {
|
|
4082
|
+
root,
|
|
4083
|
+
symbols: store.recs("symbols"),
|
|
4084
|
+
components: store.recs("components"),
|
|
4085
|
+
decisionCorpus: store.recs("decisions"),
|
|
4086
|
+
supplements: [
|
|
4087
|
+
...(retired.length ? [{
|
|
4088
|
+
id: "retired-code",
|
|
4089
|
+
kind: "retired-code",
|
|
4090
|
+
priority: 200,
|
|
4091
|
+
text: `⚠ Deliberately RETIRED from this file — do not re-introduce without cause: ${retired.map((r) => `${[...r.symbols, ...r.deps].join(", ")} (${r.decision})`).join("; ")}.`,
|
|
4092
|
+
}] : []),
|
|
4093
|
+
...(docGround ? [{ id: "doc-grounding", kind: "doc-grounding", priority: 100, text: docGround }] : []),
|
|
4094
|
+
],
|
|
4095
|
+
});
|
|
4096
|
+
const text = envelope.text.trim();
|
|
4080
4097
|
// Identical grounding already shown this session → one-line delta instead of
|
|
4081
4098
|
// the full 10-16KB block. Any record change re-sends the full text; the
|
|
4082
4099
|
// strict-gate deny path above never routes through this (dec_244397d920).
|
|
4083
4100
|
// Delivery receipts (dec_925f4bcaad): the ledger of what actually reached
|
|
4084
4101
|
// an agent. A full injection is a serve; a delta one-liner attests the
|
|
4085
4102
|
// earlier serve is still standing. Never throws, never blocks.
|
|
4086
|
-
const receipts = (event) => recordServed(root,
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4103
|
+
const receipts = (event) => recordServed(root, envelope.delivered.map((item) => ({
|
|
4104
|
+
event,
|
|
4105
|
+
kind: item.kind,
|
|
4106
|
+
record_id: item.record_id,
|
|
4107
|
+
target,
|
|
4108
|
+
session_id: evt.session_id,
|
|
4109
|
+
rank: item.rank,
|
|
4110
|
+
delivery_reason: item.delivery_reason,
|
|
4111
|
+
provenance_status: item.provenance_status,
|
|
4112
|
+
token_cost: item.token_cost,
|
|
4113
|
+
})));
|
|
4092
4114
|
if (injectionMode(evt.session_id, `pre:${target}`, text) === "delta") {
|
|
4093
4115
|
receipts("refreshed");
|
|
4094
|
-
emitContext(provider, "PreToolUse", `Hunch grounding for ${target}: unchanged this session (${
|
|
4116
|
+
emitContext(provider, "PreToolUse", `Hunch grounding for ${target}: unchanged this session (${envelope.delivered.filter((item) => item.kind === "decisions").length} decision(s), ${envelope.delivered.filter((item) => item.kind === "constraints").length} invariant(s) shown earlier — still current; hunch_why("${target}") to re-expand).`);
|
|
4095
4117
|
return;
|
|
4096
4118
|
}
|
|
4097
4119
|
receipts("served");
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic delivery envelope for agent-facing memory.
|
|
3
|
+
*
|
|
4
|
+
* Retrieval answers "what may be relevant". This layer answers the separate,
|
|
5
|
+
* observable question "what was actually safe and small enough to deliver".
|
|
6
|
+
* Keeping that boundary explicit makes delivery receipts truthful: callers
|
|
7
|
+
* receipt only `delivered`, never every record returned by retrieval.
|
|
8
|
+
*/
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { isAbsolute, join } from "node:path";
|
|
12
|
+
import { pathMatchesGlob, pathsRelated } from "./glob.js";
|
|
13
|
+
import { toPosixTarget } from "./paths.js";
|
|
14
|
+
import { renderGrounding } from "./topics.js";
|
|
15
|
+
const SEVERITY = { advisory: 1, warning: 2, blocking: 3, low: 1, medium: 2, high: 3, critical: 4 };
|
|
16
|
+
function clipHeadline(value, max) {
|
|
17
|
+
const flat = value.replace(/\s+/g, " ").trim();
|
|
18
|
+
if (flat.length <= max)
|
|
19
|
+
return flat;
|
|
20
|
+
return `${flat.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
|
|
21
|
+
}
|
|
22
|
+
function sourceTier(source) {
|
|
23
|
+
const parts = (source ?? "").split("+");
|
|
24
|
+
if (parts.includes("human_confirmed"))
|
|
25
|
+
return "human";
|
|
26
|
+
if (parts.includes("agent_recorded"))
|
|
27
|
+
return "agent";
|
|
28
|
+
if (parts.includes("llm_draft"))
|
|
29
|
+
return "model";
|
|
30
|
+
return source || "unknown";
|
|
31
|
+
}
|
|
32
|
+
function isSafeDeliveryAnchor(value) {
|
|
33
|
+
const path = toPosixTarget(value);
|
|
34
|
+
const segments = path.split("/");
|
|
35
|
+
return !!path
|
|
36
|
+
&& !isAbsolute(path)
|
|
37
|
+
&& !/^[a-zA-Z]:/.test(path)
|
|
38
|
+
&& segments.every((segment) => !!segment && segment !== "." && segment !== ".." && segment.toLowerCase() !== ".git");
|
|
39
|
+
}
|
|
40
|
+
function anchorResolves(anchor, target, options) {
|
|
41
|
+
const normalized = toPosixTarget(anchor);
|
|
42
|
+
if (!isSafeDeliveryAnchor(normalized))
|
|
43
|
+
return false;
|
|
44
|
+
const hasGlob = /[*?]/.test(normalized);
|
|
45
|
+
if (!hasGlob && options.root && existsSync(join(options.root, normalized)))
|
|
46
|
+
return true;
|
|
47
|
+
if (hasGlob && options.root && existsSync(join(options.root, toPosixTarget(target))) && pathMatchesGlob(target, normalized))
|
|
48
|
+
return true;
|
|
49
|
+
if (!hasGlob && pathsRelated(normalized, target) && options.root && existsSync(join(options.root, toPosixTarget(target))))
|
|
50
|
+
return true;
|
|
51
|
+
return (options.symbols ?? []).some((symbol) => pathMatchesGlob(symbol.file, normalized) || (!hasGlob && pathsRelated(symbol.file, normalized)));
|
|
52
|
+
}
|
|
53
|
+
function symbolResolves(anchor, target, options) {
|
|
54
|
+
return anchor === target || (options.symbols ?? []).some((symbol) => symbol.id === anchor || symbol.name === anchor);
|
|
55
|
+
}
|
|
56
|
+
function componentResolves(id, target, options) {
|
|
57
|
+
const component = (options.components ?? []).find((candidate) => candidate.id === id && candidate.status === "active");
|
|
58
|
+
return !!component && component.paths.some((path) => anchorResolves(path, target, options));
|
|
59
|
+
}
|
|
60
|
+
function gitProbe(root, args) {
|
|
61
|
+
const result = spawnSync("git", ["-C", root, ...args], {
|
|
62
|
+
encoding: "utf8",
|
|
63
|
+
env: { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" },
|
|
64
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
65
|
+
timeout: 2_000,
|
|
66
|
+
windowsHide: true,
|
|
67
|
+
});
|
|
68
|
+
return { available: !result.error, status: result.status, stdout: result.stdout?.trim() ?? "" };
|
|
69
|
+
}
|
|
70
|
+
function defaultBranchRef(root) {
|
|
71
|
+
const remote = gitProbe(root, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]);
|
|
72
|
+
if (!remote.available)
|
|
73
|
+
return null;
|
|
74
|
+
if (remote.status === 0 && remote.stdout)
|
|
75
|
+
return remote.stdout;
|
|
76
|
+
for (const ref of ["refs/heads/main", "refs/heads/master", "HEAD"]) {
|
|
77
|
+
const probe = gitProbe(root, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
|
|
78
|
+
if (!probe.available)
|
|
79
|
+
return null;
|
|
80
|
+
if (probe.status === 0)
|
|
81
|
+
return ref;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
function localCommitReachability(root) {
|
|
86
|
+
let initialized = false;
|
|
87
|
+
let branch = null;
|
|
88
|
+
return (commit) => {
|
|
89
|
+
if (!initialized) {
|
|
90
|
+
branch = defaultBranchRef(root);
|
|
91
|
+
initialized = true;
|
|
92
|
+
}
|
|
93
|
+
if (!branch)
|
|
94
|
+
return "unknown";
|
|
95
|
+
if (!/^[0-9a-f]{7,64}$/i.test(commit))
|
|
96
|
+
return "unreachable";
|
|
97
|
+
const exact = gitProbe(root, ["rev-parse", "--verify", "--quiet", `${commit}^{commit}`]);
|
|
98
|
+
if (!exact.available)
|
|
99
|
+
return "unknown";
|
|
100
|
+
if (exact.status !== 0)
|
|
101
|
+
return "unreachable";
|
|
102
|
+
const ancestry = gitProbe(root, ["merge-base", "--is-ancestor", exact.stdout, branch]);
|
|
103
|
+
if (!ancestry.available)
|
|
104
|
+
return "unknown";
|
|
105
|
+
if (ancestry.status === 0)
|
|
106
|
+
return "reachable";
|
|
107
|
+
if (ancestry.status === 1)
|
|
108
|
+
return "unreachable";
|
|
109
|
+
return "unknown";
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function validationState(anchors, anchorsResolve, commit, reachability, requireCommit = false, canValidateAnchors = true) {
|
|
113
|
+
if (anchors.length && canValidateAnchors && !anchors.some(anchorsResolve)) {
|
|
114
|
+
return { state: "stale", detail: "no recorded file/symbol/component anchor resolves in the current workspace/index" };
|
|
115
|
+
}
|
|
116
|
+
if (requireCommit && commit) {
|
|
117
|
+
const reachable = reachability(commit);
|
|
118
|
+
if (reachable === "unreachable")
|
|
119
|
+
return { state: "stale", detail: `capture commit ${commit} is not reachable from the default branch` };
|
|
120
|
+
if (reachable === "unknown")
|
|
121
|
+
return { state: "unverified", detail: `capture commit ${commit} could not be checked` };
|
|
122
|
+
}
|
|
123
|
+
if (!anchors.length || !canValidateAnchors || (requireCommit && (commit === null || commit === undefined))) {
|
|
124
|
+
return { state: "unverified", detail: requireCommit
|
|
125
|
+
? "record has no complete file/symbol + commit provenance pair"
|
|
126
|
+
: "record has no resolvable file/symbol provenance anchor" };
|
|
127
|
+
}
|
|
128
|
+
return { state: "current", detail: requireCommit ? "anchors resolve and capture commit is reachable" : "anchors resolve" };
|
|
129
|
+
}
|
|
130
|
+
function fitText(text, cap) {
|
|
131
|
+
if (cap <= 0)
|
|
132
|
+
return "";
|
|
133
|
+
const chars = [...text];
|
|
134
|
+
if (chars.length <= cap)
|
|
135
|
+
return text;
|
|
136
|
+
return chars.slice(0, cap).join("");
|
|
137
|
+
}
|
|
138
|
+
function charCount(text) {
|
|
139
|
+
return [...text].length;
|
|
140
|
+
}
|
|
141
|
+
function estimatedTokens(text) {
|
|
142
|
+
return Math.max(1, Math.ceil(charCount(text) / 4));
|
|
143
|
+
}
|
|
144
|
+
/** Build the one envelope used by CLI, MCP, and the edit hook. */
|
|
145
|
+
export function buildDeliveryEnvelope(ctx, options = {}) {
|
|
146
|
+
const budget = Number.isFinite(ctx.budget_tokens) ? Math.max(0, Math.floor(ctx.budget_tokens)) : 1500;
|
|
147
|
+
const cap = budget * 4;
|
|
148
|
+
const reachability = options.commitReachability
|
|
149
|
+
?? (options.root ? localCommitReachability(options.root) : (() => "unknown"));
|
|
150
|
+
const canValidateAnchors = options.root !== undefined || options.symbols !== undefined || options.components !== undefined;
|
|
151
|
+
const candidates = [];
|
|
152
|
+
for (const constraint of ctx.constraints) {
|
|
153
|
+
const retired = !options.historical && (constraint.status === "retired" || constraint.valid_to != null);
|
|
154
|
+
const validation = validationState(constraint.scope, (anchor) => anchorResolves(anchor, ctx.target, options), undefined, reachability, false, canValidateAnchors);
|
|
155
|
+
candidates.push({
|
|
156
|
+
ref: { kind: "constraints", record_id: constraint.id },
|
|
157
|
+
mandatory: !retired && constraint.severity === "blocking",
|
|
158
|
+
score: 900 + SEVERITY[constraint.severity] * 10 + (constraint.provenance.confidence ?? 0),
|
|
159
|
+
provenance: validation.state,
|
|
160
|
+
staleDetail: validation.detail,
|
|
161
|
+
retiredDetail: retired ? "constraint is retired at HEAD" : undefined,
|
|
162
|
+
line: `${constraint.id} | constraint/${constraint.severity} | ${clipHeadline(constraint.statement, 180)} | scope ${clipHeadline(constraint.scope.join(", ") || "repo", 100)} | ${sourceTier(constraint.provenance.source)}/${validation.state} | hunch_why("${constraint.id}")`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
for (const decision of ctx.decisions) {
|
|
166
|
+
const retired = !options.historical && (decision.status === "rejected" || decision.status === "superseded" || !!decision.superseded_by || decision.valid_to != null);
|
|
167
|
+
const fileAnchors = decision.related_files;
|
|
168
|
+
const componentAnchors = decision.related_components;
|
|
169
|
+
const validation = validationState([...fileAnchors, ...componentAnchors], (anchor) => fileAnchors.includes(anchor)
|
|
170
|
+
? anchorResolves(anchor, ctx.target, options)
|
|
171
|
+
: componentResolves(anchor, ctx.target, options), decision.commit, reachability, true, canValidateAnchors);
|
|
172
|
+
candidates.push({
|
|
173
|
+
ref: { kind: "decisions", record_id: decision.id },
|
|
174
|
+
mandatory: false,
|
|
175
|
+
score: 700 + (decision.status === "accepted" ? 20 : 0) + (decision.provenance.confidence ?? 0),
|
|
176
|
+
provenance: validation.state,
|
|
177
|
+
staleDetail: validation.detail,
|
|
178
|
+
retiredDetail: retired ? `decision is ${decision.status} at HEAD` : undefined,
|
|
179
|
+
line: `${decision.id} | decision/${decision.status} | ${clipHeadline(`${decision.title}: ${decision.decision}`, 220)} | scope ${clipHeadline(decision.related_files.join(", ") || decision.related_components.join(", ") || "unanchored", 100)} | ${sourceTier(decision.provenance.source)}/${validation.state} | hunch_why("${decision.id}")`,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
for (const bug of ctx.bugs) {
|
|
183
|
+
const anchors = [...bug.affected_files, ...bug.affected_symbols];
|
|
184
|
+
const validation = validationState(anchors, (anchor) => bug.affected_files.includes(anchor)
|
|
185
|
+
? anchorResolves(anchor, ctx.target, options)
|
|
186
|
+
: symbolResolves(anchor, ctx.target, options), undefined, reachability, false, canValidateAnchors);
|
|
187
|
+
candidates.push({
|
|
188
|
+
ref: { kind: "bugs", record_id: bug.id },
|
|
189
|
+
mandatory: false,
|
|
190
|
+
score: 800 + SEVERITY[bug.severity] * 10 + (bug.status === "open" || bug.status === "regressed" ? 10 : 0),
|
|
191
|
+
provenance: validation.state,
|
|
192
|
+
staleDetail: validation.detail,
|
|
193
|
+
line: `${bug.id} | bug/${bug.status}/${bug.severity} | ${clipHeadline(`${bug.title} — root cause: ${bug.root_cause}`, 220)} | ${sourceTier(bug.provenance.source)}/${validation.state} | hunch_why("${bug.id}")`,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
for (const finding of ctx.findings) {
|
|
197
|
+
const anchors = [...finding.affected_files, ...finding.affected_symbols];
|
|
198
|
+
const validation = validationState(anchors, (anchor) => finding.affected_files.includes(anchor)
|
|
199
|
+
? anchorResolves(anchor, ctx.target, options)
|
|
200
|
+
: symbolResolves(anchor, ctx.target, options), undefined, reachability, false, canValidateAnchors);
|
|
201
|
+
const evidence = `${finding.violates_constraint ? `; violates ${finding.violates_constraint}` : ""}${finding.method ? `; re-verify via ${finding.method}` : ""}`;
|
|
202
|
+
candidates.push({
|
|
203
|
+
ref: { kind: "findings", record_id: finding.id },
|
|
204
|
+
mandatory: false,
|
|
205
|
+
score: 600 + SEVERITY[finding.severity] * 10,
|
|
206
|
+
provenance: validation.state,
|
|
207
|
+
staleDetail: validation.detail,
|
|
208
|
+
line: `${finding.id} | finding/${finding.triage}/${finding.severity} | ${clipHeadline(`${finding.title} — ${finding.observation}${evidence}`, 240)} | ${sourceTier(finding.provenance.source)}/${validation.state} | hunch_why("${finding.id}")`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
for (const dependent of ctx.blast_radius) {
|
|
212
|
+
candidates.push({ mandatory: false, score: 400 - dependent.depth, provenance: "current", line: `graph | blast/d${dependent.depth} | ${clipHeadline(dependent.via, 220)}` });
|
|
213
|
+
}
|
|
214
|
+
if (ctx.components.length) {
|
|
215
|
+
candidates.push({ mandatory: false, score: 300, provenance: "current", line: `graph | components | ${clipHeadline(ctx.components.map((component) => component.name).join(", "), 240)}` });
|
|
216
|
+
}
|
|
217
|
+
const hasAnything = candidates.length > 0 || (options.supplements?.length ?? 0) > 0;
|
|
218
|
+
if (!hasAnything) {
|
|
219
|
+
const empty = `# Hunch context for "${ctx.target}"\n\n(No recorded constraints/decisions/bugs for this target yet — Hunch is still learning it.)\n`;
|
|
220
|
+
const text = fitText(empty, cap);
|
|
221
|
+
return { text, delivered: [], supplements: [], omitted: [], budget_tokens: budget, used_chars: charCount(text), blocking_overflow: false };
|
|
222
|
+
}
|
|
223
|
+
const omitted = [];
|
|
224
|
+
const eligible = [];
|
|
225
|
+
for (const candidate of candidates) {
|
|
226
|
+
if (candidate.retiredDetail && candidate.ref) {
|
|
227
|
+
omitted.push({ ...candidate.ref, reason: "retired", detail: candidate.retiredDetail });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (candidate.provenance === "stale" && !candidate.mandatory && candidate.ref) {
|
|
231
|
+
omitted.push({ ...candidate.ref, reason: "stale-provenance", detail: candidate.staleDetail ?? "provenance is stale" });
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
eligible.push(candidate);
|
|
235
|
+
}
|
|
236
|
+
eligible.sort((left, right) => Number(right.mandatory) - Number(left.mandatory) || right.score - left.score || (left.ref?.record_id ?? left.line).localeCompare(right.ref?.record_id ?? right.line));
|
|
237
|
+
const recordCandidates = eligible.filter((candidate) => candidate.ref);
|
|
238
|
+
const structuralCandidates = eligible.filter((candidate) => !candidate.ref);
|
|
239
|
+
const lines = [
|
|
240
|
+
`# Hunch context for "${ctx.target}"`,
|
|
241
|
+
"",
|
|
242
|
+
"## 🧠 Ranked memory (Invariants · Decisions · Bugs · Known findings)",
|
|
243
|
+
];
|
|
244
|
+
let text = `${lines.join("\n")}\n`;
|
|
245
|
+
const delivered = [];
|
|
246
|
+
const supplements = [];
|
|
247
|
+
let blockingOverflow = false;
|
|
248
|
+
for (const [index, candidate] of recordCandidates.entries()) {
|
|
249
|
+
const next = `- ${candidate.line}\n`;
|
|
250
|
+
if (charCount(text) + charCount(next) <= cap || candidate.mandatory) {
|
|
251
|
+
text += next;
|
|
252
|
+
delivered.push({
|
|
253
|
+
...candidate.ref,
|
|
254
|
+
rank: index + 1,
|
|
255
|
+
delivery_reason: candidate.mandatory ? "blocking-reserved" : "ranked",
|
|
256
|
+
provenance_status: candidate.provenance,
|
|
257
|
+
token_cost: estimatedTokens(next),
|
|
258
|
+
});
|
|
259
|
+
if (charCount(text) > cap && candidate.mandatory)
|
|
260
|
+
blockingOverflow = true;
|
|
261
|
+
}
|
|
262
|
+
else if (candidate.ref) {
|
|
263
|
+
omitted.push({ ...candidate.ref, reason: "budget", detail: `ranked headline did not fit the ${budget}-token budget` });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const deliveredDecisionIds = new Set(delivered.filter((item) => item.kind === "decisions").map((item) => item.record_id));
|
|
267
|
+
const deliveredDecisions = ctx.decisions.filter((decision) => deliveredDecisionIds.has(decision.id));
|
|
268
|
+
const decisionGrounding = options.decisionCorpus?.length && deliveredDecisions.length
|
|
269
|
+
? renderGrounding(deliveredDecisions, [...options.decisionCorpus])
|
|
270
|
+
: "";
|
|
271
|
+
const supplementalCandidates = [
|
|
272
|
+
...(decisionGrounding ? [{ id: "decision-grounding", kind: "decision-grounding", text: decisionGrounding, priority: 1_000 }] : []),
|
|
273
|
+
...(options.supplements ?? []),
|
|
274
|
+
].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0) || left.id.localeCompare(right.id));
|
|
275
|
+
for (const [index, supplement] of supplementalCandidates.entries()) {
|
|
276
|
+
const content = clipHeadline(supplement.text, 700);
|
|
277
|
+
if (!content) {
|
|
278
|
+
supplements.push({ id: supplement.id, kind: supplement.kind, delivered: false, reason: "empty", rank: index + 1, token_cost: 0 });
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const next = `- supplemental/${supplement.kind} | ${content}\n`;
|
|
282
|
+
const tokenCost = estimatedTokens(next);
|
|
283
|
+
if (charCount(text) + charCount(next) <= cap) {
|
|
284
|
+
text += next;
|
|
285
|
+
supplements.push({ id: supplement.id, kind: supplement.kind, delivered: true, reason: "supplemental", rank: index + 1, token_cost: tokenCost });
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
supplements.push({ id: supplement.id, kind: supplement.kind, delivered: false, reason: "budget", rank: index + 1, token_cost: tokenCost });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
for (const candidate of structuralCandidates) {
|
|
292
|
+
const next = `- ${candidate.line}\n`;
|
|
293
|
+
if (charCount(text) + charCount(next) <= cap)
|
|
294
|
+
text += next;
|
|
295
|
+
}
|
|
296
|
+
const staleCount = omitted.filter((item) => item.reason === "stale-provenance" || item.reason === "retired").length;
|
|
297
|
+
const budgetCount = omitted.filter((item) => item.reason === "budget").length;
|
|
298
|
+
const notes = [
|
|
299
|
+
staleCount ? `${staleCount} stale/retired record(s) withheld; run hunch drift or hunch_why(id) to inspect.` : "",
|
|
300
|
+
budgetCount ? `${budgetCount} lower-ranked record(s) omitted by budget; use hunch_why(id) to drill down.` : "",
|
|
301
|
+
].filter(Boolean);
|
|
302
|
+
if (notes.length) {
|
|
303
|
+
const footer = `… ${notes.join(" ")}\n`;
|
|
304
|
+
if (charCount(text) + charCount(footer) <= cap)
|
|
305
|
+
text += footer;
|
|
306
|
+
}
|
|
307
|
+
if (!text.endsWith("\n") && charCount(text) < cap)
|
|
308
|
+
text += "\n";
|
|
309
|
+
if (!blockingOverflow)
|
|
310
|
+
text = fitText(text, cap);
|
|
311
|
+
omitted.sort((left, right) => left.record_id.localeCompare(right.record_id) || left.reason.localeCompare(right.reason));
|
|
312
|
+
return {
|
|
313
|
+
text,
|
|
314
|
+
delivered,
|
|
315
|
+
supplements,
|
|
316
|
+
omitted,
|
|
317
|
+
budget_tokens: budget,
|
|
318
|
+
used_chars: charCount(text),
|
|
319
|
+
blocking_overflow: blockingOverflow,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
//# sourceMappingURL=delivery.js.map
|
package/dist/core/format.js
CHANGED
|
@@ -1,53 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const v = p.last_verified ? `, verified ${p.last_verified.slice(0, 10)}` : "";
|
|
5
|
-
return ` ⟨${p.source ?? "?"} ${p.confidence ?? "?"}${v}⟩`;
|
|
6
|
-
}
|
|
7
|
-
export function formatContext(ctx) {
|
|
8
|
-
const out = [`# Hunch context for "${ctx.target}"`];
|
|
9
|
-
if (ctx.constraints.length) {
|
|
10
|
-
out.push(`\n## ⛔ Invariants (must not break)`);
|
|
11
|
-
for (const c of ctx.constraints)
|
|
12
|
-
out.push(`- [${c.severity}] ${c.statement}${prov(c.provenance)}\n (${c.id}; scope ${c.scope.join(", ") || "repo"})`);
|
|
13
|
-
}
|
|
14
|
-
if (ctx.decisions.length) {
|
|
15
|
-
out.push(`\n## 🧭 Decisions (why it's shaped this way)`);
|
|
16
|
-
for (const d of ctx.decisions)
|
|
17
|
-
out.push(`- [${d.status}] ${d.title}${prov(d.provenance)}\n ${d.decision}`);
|
|
18
|
-
}
|
|
19
|
-
if (ctx.bugs.length) {
|
|
20
|
-
out.push(`\n## 🐞 Bug history (don't reintroduce)`);
|
|
21
|
-
for (const b of ctx.bugs)
|
|
22
|
-
out.push(`- [${b.status}/${b.severity}] ${b.title} — root cause: ${b.root_cause}${prov(b.provenance)}`);
|
|
23
|
-
}
|
|
24
|
-
if (ctx.findings.length) {
|
|
25
|
-
out.push(`\n## 🔍 Known findings (observed, unresolved — no fix landed yet)`);
|
|
26
|
-
for (const f of ctx.findings) {
|
|
27
|
-
out.push(`- [${f.triage}/${f.severity}] ${f.title} — ${f.observation}${prov(f.provenance)}\n (${f.id}; observed ${f.observed_at.slice(0, 10)}${f.violates_constraint ? `; violates ${f.violates_constraint}` : ""}${f.method ? `; re-verify via ${f.method}` : ""})`);
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
if (ctx.blast_radius.length) {
|
|
31
|
-
out.push(`\n## 💥 Blast radius (transitive dependents)`);
|
|
32
|
-
out.push(ctx.blast_radius.map((d) => `- [d${d.depth}] ${d.via}`).join("\n"));
|
|
33
|
-
}
|
|
34
|
-
if (ctx.components.length)
|
|
35
|
-
out.push(`\n## 📦 Components: ${ctx.components.map((c) => c.name).join(", ")}`);
|
|
36
|
-
if (out.length === 1)
|
|
37
|
-
out.push(`\n(No recorded constraints/decisions/bugs for this target yet — Hunch is still learning it.)`);
|
|
38
|
-
// crude budget trim: ~4 chars/token. Slice on code points (not UTF-16 units)
|
|
39
|
-
// and back off to the last line boundary so we never split a surrogate pair or
|
|
40
|
-
// a record mid-line.
|
|
41
|
-
const text = out.join("\n");
|
|
42
|
-
const cap = ctx.budget_tokens * 4;
|
|
43
|
-
const chars = [...text];
|
|
44
|
-
if (chars.length <= cap)
|
|
45
|
-
return text + "\n";
|
|
46
|
-
let trimmed = chars.slice(0, cap).join("");
|
|
47
|
-
const lastNl = trimmed.lastIndexOf("\n");
|
|
48
|
-
if (lastNl > cap * 0.5)
|
|
49
|
-
trimmed = trimmed.slice(0, lastNl);
|
|
50
|
-
return trimmed + "\n… (trimmed to budget)\n";
|
|
1
|
+
import { buildDeliveryEnvelope } from "./delivery.js";
|
|
2
|
+
export function formatContext(ctx, options = {}) {
|
|
3
|
+
return buildDeliveryEnvelope(ctx, options).text;
|
|
51
4
|
}
|
|
52
5
|
/** Render a StructureView as a compact orientation brief (hunch_structure). */
|
|
53
6
|
export function formatStructure(v) {
|
package/dist/core/glob.js
CHANGED
|
@@ -59,4 +59,11 @@ export function pathMatchesGlob(path, glob) {
|
|
|
59
59
|
return true;
|
|
60
60
|
return false;
|
|
61
61
|
}
|
|
62
|
+
/** Do two concrete repo paths identify the same path at different prefix depth?
|
|
63
|
+
* Segment-anchored: `x/scenario.ts` relates to `scenario.ts`, never `io.ts`. */
|
|
64
|
+
export function pathsRelated(left, right) {
|
|
65
|
+
const a = norm(left);
|
|
66
|
+
const b = norm(right);
|
|
67
|
+
return a === b || a.endsWith(`/${b}`) || b.endsWith(`/${a}`);
|
|
68
|
+
}
|
|
62
69
|
//# sourceMappingURL=glob.js.map
|
package/dist/core/served.js
CHANGED
|
@@ -36,6 +36,34 @@ function loadSqlite() {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
let sqlite = null;
|
|
39
|
+
const RECEIPT_COLUMNS = [
|
|
40
|
+
["rank", "INTEGER"],
|
|
41
|
+
["delivery_reason", "TEXT"],
|
|
42
|
+
["provenance_status", "TEXT"],
|
|
43
|
+
["token_cost", "INTEGER"],
|
|
44
|
+
];
|
|
45
|
+
function columnNames(db) {
|
|
46
|
+
const rows = db.prepare("PRAGMA table_info(served)").all();
|
|
47
|
+
return new Set(rows.map((row) => row.name));
|
|
48
|
+
}
|
|
49
|
+
/** Additive, idempotent migration for machine-local ledgers created by older releases. */
|
|
50
|
+
function ensureReceiptColumns(db) {
|
|
51
|
+
const existing = columnNames(db);
|
|
52
|
+
for (const [name, type] of RECEIPT_COLUMNS) {
|
|
53
|
+
if (existing.has(name))
|
|
54
|
+
continue;
|
|
55
|
+
try {
|
|
56
|
+
db.exec(`ALTER TABLE served ADD COLUMN ${name} ${type}`);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
// Two hook processes may race the same migration. Only swallow when the
|
|
60
|
+
// other process demonstrably completed this exact additive change.
|
|
61
|
+
if (!columnNames(db).has(name))
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
existing.add(name);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
39
67
|
function openServedDb(root) {
|
|
40
68
|
sqlite ??= loadSqlite();
|
|
41
69
|
const dir = join(root, ".hunch-cache");
|
|
@@ -50,6 +78,7 @@ function openServedDb(root) {
|
|
|
50
78
|
target TEXT NOT NULL
|
|
51
79
|
);
|
|
52
80
|
CREATE INDEX IF NOT EXISTS served_record ON served (record_id);`);
|
|
81
|
+
ensureReceiptColumns(db);
|
|
53
82
|
return db;
|
|
54
83
|
}
|
|
55
84
|
/** Append delivery receipts. Never throws — a receipt must never cost a delivery. */
|
|
@@ -60,9 +89,9 @@ export function recordServed(root, entries) {
|
|
|
60
89
|
const db = openServedDb(root);
|
|
61
90
|
try {
|
|
62
91
|
const at = new Date().toISOString();
|
|
63
|
-
const insert = db.prepare("INSERT INTO served (at, session, event, kind, record_id, target) VALUES (?, ?, ?, ?, ?, ?)");
|
|
92
|
+
const insert = db.prepare("INSERT INTO served (at, session, event, kind, record_id, target, rank, delivery_reason, provenance_status, token_cost) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
|
64
93
|
for (const entry of entries) {
|
|
65
|
-
insert.run(at, entry.session_id ?? null, entry.event, entry.kind, entry.record_id, entry.target);
|
|
94
|
+
insert.run(at, entry.session_id ?? null, entry.event, entry.kind, entry.record_id, entry.target, entry.rank ?? null, entry.delivery_reason ?? null, entry.provenance_status ?? null, entry.token_cost ?? null);
|
|
66
95
|
}
|
|
67
96
|
}
|
|
68
97
|
finally {
|
|
@@ -75,16 +104,23 @@ export function recordServed(root, entries) {
|
|
|
75
104
|
}
|
|
76
105
|
/** The ledger, aggregated per record. Never throws; an unreadable ledger reads as empty. */
|
|
77
106
|
export function servedSummary(root) {
|
|
78
|
-
const empty = { total: 0, distinct_records: 0, distinct_sessions: 0, first_at: null, last_at: null, rows: [] };
|
|
107
|
+
const empty = { total: 0, distinct_records: 0, distinct_sessions: 0, first_at: null, last_at: null, rows: [], recent: [] };
|
|
79
108
|
try {
|
|
80
109
|
const db = openServedDb(root);
|
|
81
110
|
try {
|
|
82
111
|
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
|
|
112
|
+
const rawRows = db.prepare(`SELECT record_id, kind,
|
|
84
113
|
SUM(CASE WHEN event = 'served' THEN 1 ELSE 0 END) AS serves,
|
|
85
114
|
SUM(CASE WHEN event = 'refreshed' THEN 1 ELSE 0 END) AS refreshes,
|
|
86
|
-
MAX(at) AS last_at
|
|
115
|
+
MAX(at) AS last_at,
|
|
116
|
+
MIN(rank) AS best_rank,
|
|
117
|
+
ROUND(AVG(token_cost), 2) AS average_token_cost
|
|
87
118
|
FROM served GROUP BY record_id, kind ORDER BY serves DESC, refreshes DESC`).all();
|
|
119
|
+
const rawRecent = db.prepare(`SELECT at, session AS session_id, event, kind, record_id, target,
|
|
120
|
+
rank, delivery_reason, provenance_status, token_cost
|
|
121
|
+
FROM served ORDER BY rowid DESC LIMIT 50`).all();
|
|
122
|
+
const rows = rawRows.map((row) => ({ ...row }));
|
|
123
|
+
const recent = rawRecent.map((receipt) => ({ ...receipt }));
|
|
88
124
|
return {
|
|
89
125
|
total: totals?.total ?? 0,
|
|
90
126
|
distinct_records: totals?.records ?? 0,
|
|
@@ -92,6 +128,7 @@ export function servedSummary(root) {
|
|
|
92
128
|
first_at: totals?.first_at ?? null,
|
|
93
129
|
last_at: totals?.last_at ?? null,
|
|
94
130
|
rows,
|
|
131
|
+
recent,
|
|
95
132
|
};
|
|
96
133
|
}
|
|
97
134
|
finally {
|
package/dist/mcp/server.js
CHANGED
|
@@ -605,7 +605,13 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
605
605
|
return ok(`No file/symbol resolves for "${target}" — closest graph matches instead:\n\n${lines.join("\n")}\n\n(For a file/symbol brief pass a concrete target; free-text goes through the same search as hunch_query.)`);
|
|
606
606
|
}
|
|
607
607
|
}
|
|
608
|
-
return ok(formatContext(ctx
|
|
608
|
+
return ok(formatContext(ctx, {
|
|
609
|
+
root,
|
|
610
|
+
symbols: store.recs("symbols"),
|
|
611
|
+
components: store.recs("components"),
|
|
612
|
+
decisionCorpus: store.recs("decisions"),
|
|
613
|
+
historical: !!asOf,
|
|
614
|
+
}));
|
|
609
615
|
});
|
|
610
616
|
// -- hunch_now (the hot view: recent activity + roadmap) --------------------
|
|
611
617
|
// PUBLIC store only, per dec_29eff08c69's jurisdiction rule: an assistant may
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -19,7 +19,7 @@ import { RESET_SQL, embedHash } from "./schema.js";
|
|
|
19
19
|
import { selectEmbedder } from "./embedder.js";
|
|
20
20
|
import { JsonStore } from "./jsonStore.js";
|
|
21
21
|
import { gitCommonDir, gitWorktreeRoot, sameGitPublication } from "../extractors/git.js";
|
|
22
|
-
import { pathMatchesGlob } from "../core/glob.js";
|
|
22
|
+
import { pathMatchesGlob, pathsRelated } from "../core/glob.js";
|
|
23
23
|
import { currentForTopic, isInForce } from "../core/topics.js";
|
|
24
24
|
import { edgeId } from "../core/ids.js";
|
|
25
25
|
import { isStrictBlocker, isVetoBlocker } from "../core/strictgate.js";
|
|
@@ -819,14 +819,14 @@ export class HunchStore {
|
|
|
819
819
|
const symbols = this.recs("symbols");
|
|
820
820
|
const components = this.recs("components");
|
|
821
821
|
const asOf = opts.asOf;
|
|
822
|
-
//
|
|
822
|
+
// pathsRelated, not bare endsWith: "scenario.ts".endsWith("io.ts") is true,
|
|
823
823
|
// so an unanchored suffix pulled unrelated files' records into why()/the
|
|
824
824
|
// pre-edit grounding block (issue #32). Segment-anchored matching only.
|
|
825
|
-
const matchedSymbols = symbols.filter((s) => s.file === target || s.name === target || s.id === target ||
|
|
825
|
+
const matchedSymbols = symbols.filter((s) => s.file === target || s.name === target || s.id === target || pathsRelated(s.file, target));
|
|
826
826
|
const symIds = new Set(matchedSymbols.map((s) => s.id));
|
|
827
827
|
const fileSet = new Set(matchedSymbols.map((s) => s.file));
|
|
828
828
|
const isPath = target.includes("/") || target.includes(".");
|
|
829
|
-
const fileMatch = (files) => files.some((f) => f === target || (isPath &&
|
|
829
|
+
const fileMatch = (files) => files.some((f) => f === target || (isPath && pathsRelated(f, target)) || fileSet.has(f));
|
|
830
830
|
return {
|
|
831
831
|
target,
|
|
832
832
|
decisions: decisions.filter((d) => (fileMatch(d.related_files) || d.related_components.some((c) => components.find((x) => x.id === c && fileMatch(x.paths))))
|
|
@@ -1057,7 +1057,7 @@ export class HunchStore {
|
|
|
1057
1057
|
const live = (f) => f.triage === "open" || f.triage === "accepted-risk" || f.triage === "scheduled";
|
|
1058
1058
|
return this.recs("findings")
|
|
1059
1059
|
.filter(live)
|
|
1060
|
-
.filter((f) => f.affected_files.some((af) => pathMatchesGlob(t, af) || pathMatchesGlob(af, t) ||
|
|
1060
|
+
.filter((f) => f.affected_files.some((af) => pathMatchesGlob(t, af) || pathMatchesGlob(af, t) || pathsRelated(toPosixTarget(af), t))
|
|
1061
1061
|
|| f.affected_symbols.some((s) => s === scope))
|
|
1062
1062
|
.sort((a, b) => (SEV_FINDING[b.severity] ?? 0) - (SEV_FINDING[a.severity] ?? 0) || a.id.localeCompare(b.id));
|
|
1063
1063
|
}
|
|
@@ -1294,7 +1294,7 @@ export class HunchStore {
|
|
|
1294
1294
|
const addedDeps = new Set(added.deps);
|
|
1295
1295
|
if (!addedSyms.size && !addedDeps.size)
|
|
1296
1296
|
return [];
|
|
1297
|
-
const fileRelevant = (related) => related.some((f) => files.some((x) =>
|
|
1297
|
+
const fileRelevant = (related) => related.some((f) => files.some((x) => pathsRelated(x, f)));
|
|
1298
1298
|
const decisions = this.recs("decisions");
|
|
1299
1299
|
// decisions tied to an active blocking constraint via source_decision
|
|
1300
1300
|
const blockingDec = new Set(this.recs("constraints")
|
|
@@ -1416,7 +1416,7 @@ export class HunchStore {
|
|
|
1416
1416
|
continue;
|
|
1417
1417
|
if (!d.retired.symbols.length && !d.retired.deps.length)
|
|
1418
1418
|
continue;
|
|
1419
|
-
if (!d.related_files.some((f) =>
|
|
1419
|
+
if (!d.related_files.some((f) => pathsRelated(f, file)))
|
|
1420
1420
|
continue;
|
|
1421
1421
|
out.push({ decision: d.id, title: d.title, symbols: d.retired.symbols, deps: d.retired.deps });
|
|
1422
1422
|
}
|
|
@@ -1558,9 +1558,6 @@ const SEV_FINDING = { critical: 4, high: 3, medium: 2, low: 1 };
|
|
|
1558
1558
|
/** Do two repo paths refer to the same file? Exact match, or one is a trailing
|
|
1559
1559
|
* path-SEGMENT suffix of the other (e.g. "x.ts" vs "src/x.ts") — anchored at a
|
|
1560
1560
|
* "/" boundary so "re.ts" never matches "store.ts" (the bare-endsWith hazard). */
|
|
1561
|
-
function pathRelated(a, b) {
|
|
1562
|
-
return a === b || a.endsWith("/" + b) || b.endsWith("/" + a);
|
|
1563
|
-
}
|
|
1564
1561
|
function inWindow(valid_from, valid_to, asOf) {
|
|
1565
1562
|
if (!asOf)
|
|
1566
1563
|
return true;
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
9
|
"websiteUrl": "https://hunch-pi.vercel.app",
|
|
10
|
-
"version": "1.
|
|
10
|
+
"version": "1.13.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "@davesheffer/hunch",
|
|
16
|
-
"version": "1.
|
|
16
|
+
"version": "1.13.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|