@davesheffer/hunch 1.12.1 → 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/drift.js +11 -1
- package/dist/core/format.js +3 -50
- package/dist/core/glob.js +7 -0
- package/dist/core/publication.js +196 -0
- package/dist/core/served.js +42 -5
- 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 +28 -7
- package/dist/store/hunchStore.js +7 -10
- package/dist/synthesis/synthesize.js +10 -2
- 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/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
|