@davesheffer/hunch 1.10.8 → 1.12.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/dist/cli/index.js +47 -1
- package/dist/core/agenthook.js +4 -0
- package/dist/core/correction.js +23 -2
- package/dist/core/docanchors.js +35 -2
- package/dist/core/hookcache.js +16 -0
- package/dist/core/premises.js +20 -3
- package/dist/core/types.js +18 -1
- package/dist/integrations/scaffold.js +12 -0
- package/dist/mcp/server.js +17 -3
- package/package.json +1 -1
- package/server.json +4 -4
package/dist/cli/index.js
CHANGED
|
@@ -57,7 +57,7 @@ import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpol
|
|
|
57
57
|
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
|
-
import { injectionMode } from "../core/hookcache.js";
|
|
60
|
+
import { injectionMode, resetSessionInjections } from "../core/hookcache.js";
|
|
61
61
|
import { contextHookOutput, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
|
|
62
62
|
import { PIPELINE_LOOP, UNVERIFIED_NAG, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, savePipelineState, stopVerdict, } from "../core/pipeline.js";
|
|
63
63
|
import { draftDuplicateOf, isAcceptedDuplicateAnchor } from "../core/dupdetect.js";
|
|
@@ -3797,7 +3797,53 @@ program
|
|
|
3797
3797
|
emitContext(provider, "UserPromptSubmit", text);
|
|
3798
3798
|
return;
|
|
3799
3799
|
}
|
|
3800
|
+
if (evt.hook_event_name === "PreCompact") {
|
|
3801
|
+
// Compaction is about to summarize injected grounding out of the agent's
|
|
3802
|
+
// context while the dedup map still says "delivered". Reset it so every
|
|
3803
|
+
// post-compact injection is full again. Emit nothing — this event has no
|
|
3804
|
+
// context channel worth spending.
|
|
3805
|
+
resetSessionInjections(evt.session_id);
|
|
3806
|
+
return;
|
|
3807
|
+
}
|
|
3808
|
+
if (evt.hook_event_name === "SubagentStart") {
|
|
3809
|
+
// A delegated agent starts with NONE of the parent session's grounding:
|
|
3810
|
+
// session orientation never fired inside it and only per-edit PreToolUse
|
|
3811
|
+
// follows it in — so read-only agents (Explore/Plan) could work fully
|
|
3812
|
+
// blind. Give it the invariants that must survive delegation. Public
|
|
3813
|
+
// store only; once per agent type per session.
|
|
3814
|
+
const s = new HunchStore(paths);
|
|
3815
|
+
try {
|
|
3816
|
+
const sevRank = { blocking: 0, warning: 1, advisory: 2 };
|
|
3817
|
+
const constraints = s.advisoryRecs("constraints")
|
|
3818
|
+
.filter((c) => c.status === "active")
|
|
3819
|
+
.sort((a, b) => sevRank[a.severity] - sevRank[b.severity]);
|
|
3820
|
+
if (!constraints.length)
|
|
3821
|
+
return;
|
|
3822
|
+
const L = [`🧠 Hunch — delegated agent grounding: ${constraints.length} invariant(s) in force in this repo.`];
|
|
3823
|
+
for (const c of constraints.slice(0, 8)) {
|
|
3824
|
+
const flat = c.statement.replace(/\s+/g, " ").trim();
|
|
3825
|
+
const claim = flat.length > 140 ? `${flat.slice(0, 139).trimEnd()}…` : flat;
|
|
3826
|
+
L.push(`- [${c.severity}] ${claim}${c.scope.length ? ` (scope: ${c.scope.slice(0, 3).join(", ")})` : ""}`);
|
|
3827
|
+
}
|
|
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
|
+
// No dedup here: the hook event carries the PARENT session id, but each
|
|
3832
|
+
// spawned agent is a fresh empty context — deduping would ground the
|
|
3833
|
+
// first Explore and silently starve every later one.
|
|
3834
|
+
emitContext(provider, "SubagentStart", L.join("\n"));
|
|
3835
|
+
}
|
|
3836
|
+
finally {
|
|
3837
|
+
s.close();
|
|
3838
|
+
}
|
|
3839
|
+
return;
|
|
3840
|
+
}
|
|
3800
3841
|
if (evt.hook_event_name === "SessionStart") {
|
|
3842
|
+
// A compact-resume means everything injected so far was just summarized
|
|
3843
|
+
// away — the dedup map must forget it delivered anything, or the rest of
|
|
3844
|
+
// the session gets delta one-liners against grounding that is gone.
|
|
3845
|
+
if (evt.source === "compact")
|
|
3846
|
+
resetSessionInjections(evt.session_id);
|
|
3801
3847
|
// Orientation at the moment it matters: what just happened + what's next,
|
|
3802
3848
|
// straight from the graph — the agent sits down already knowing where it
|
|
3803
3849
|
// is instead of pulling (or worse, grepping) for it. Cheap reads only
|
package/dist/core/agenthook.js
CHANGED
|
@@ -77,6 +77,8 @@ function eventName(value, provider) {
|
|
|
77
77
|
posttooluse: "PostToolUse",
|
|
78
78
|
userpromptsubmit: "UserPromptSubmit",
|
|
79
79
|
sessionstart: "SessionStart",
|
|
80
|
+
subagentstart: "SubagentStart",
|
|
81
|
+
precompact: "PreCompact",
|
|
80
82
|
stop: "Stop",
|
|
81
83
|
};
|
|
82
84
|
if (map[name])
|
|
@@ -155,6 +157,8 @@ export function normalizeHookEvent(raw, provider) {
|
|
|
155
157
|
tool_name: hunchToolName(stringAt(input, "tool_name", "toolName"), toolInput ?? {}),
|
|
156
158
|
tool_input: toolInput,
|
|
157
159
|
prompt: stringAt(input, "prompt", "user_prompt", "userPrompt"),
|
|
160
|
+
source: stringAt(input, "source"),
|
|
161
|
+
agent_type: stringAt(input, "agent_type", "agentType", "subagent_type", "subagentType"),
|
|
158
162
|
};
|
|
159
163
|
}
|
|
160
164
|
/** Provider-aware hook output. Context output is intentionally omitted for
|
package/dist/core/correction.js
CHANGED
|
@@ -88,6 +88,19 @@ export function buildCorrectionConstraint(input, now) {
|
|
|
88
88
|
let severity = input.severity ?? "warning";
|
|
89
89
|
if (severity === "blocking" && repoWide && !input.applies_to_all)
|
|
90
90
|
severity = "warning";
|
|
91
|
+
// AUTHORSHIP TIER. This function hardcoded provenance human_confirmed @1, and
|
|
92
|
+
// isStrictBlocker treats human_confirmed + blocking as a DENY — so an un-interviewed
|
|
93
|
+
// agent call could mint a repo-wide deny carrying a signature nobody gave. That made
|
|
94
|
+
// the highest-authority write path the least gated one, and strictly worse than
|
|
95
|
+
// hunch_record_decision, which only ever produced advisory memory and is now tiered.
|
|
96
|
+
//
|
|
97
|
+
// The token sets the TIER, never whether the write lands. An un-vouched correction is
|
|
98
|
+
// still recorded immediately and still held against every assistant at edit time and
|
|
99
|
+
// in CI — Never Twice keeps its promise. What waits for a countersign is only the
|
|
100
|
+
// authority to DENY.
|
|
101
|
+
const vouched = input.vouched !== false;
|
|
102
|
+
if (!vouched && severity === "blocking")
|
|
103
|
+
severity = "warning";
|
|
91
104
|
return {
|
|
92
105
|
id: constraintId(rule),
|
|
93
106
|
type: input.type ?? "correctness",
|
|
@@ -101,13 +114,21 @@ export function buildCorrectionConstraint(input, now) {
|
|
|
101
114
|
// rule that goes stale. Validated against the repo's real deps when supplied → never mints a
|
|
102
115
|
// never-firing rule for a non-dependency. null when nothing derivable → falls back to scope.
|
|
103
116
|
forbids: deriveForbids(rule, input.knownDeps),
|
|
104
|
-
rationale: input.rationale ??
|
|
117
|
+
rationale: input.rationale ?? (vouched
|
|
118
|
+
? "Captured from a human correction of the agent (Never Twice)."
|
|
119
|
+
: "Recorded by the agent as a correction, WITHOUT a capture interview — advisory testimony until a human countersigns it via /capture (Never Twice)."),
|
|
105
120
|
source_decision: input.source_decision ?? null,
|
|
106
121
|
violations: [],
|
|
107
122
|
status: "active",
|
|
108
123
|
valid_from: now,
|
|
109
124
|
valid_to: null,
|
|
110
|
-
|
|
125
|
+
// The signature is EARNED, not assumed. isStrictBlocker treats human_confirmed as
|
|
126
|
+
// authority to deny, so stamping it on an un-interviewed write forges the one thing
|
|
127
|
+
// the strict gate trusts. agent_recorded is the same tier hunch_record_decision uses
|
|
128
|
+
// for an un-token'd write — advisory, real, and honest about who wrote it.
|
|
129
|
+
provenance: vouched
|
|
130
|
+
? { source: "human_confirmed", confidence: 1, evidence: [], last_verified: now }
|
|
131
|
+
: { source: "agent_recorded", confidence: 0.75, evidence: [], last_verified: now },
|
|
111
132
|
};
|
|
112
133
|
}
|
|
113
134
|
//# sourceMappingURL=correction.js.map
|
package/dist/core/docanchors.js
CHANGED
|
@@ -1,12 +1,45 @@
|
|
|
1
1
|
import { currentForTopic, rejectedForTopic } from "./topics.js";
|
|
2
2
|
const MARKER = /<!--\s*hunch:topic\s+([A-Za-z0-9._/-]+)(?:\s+(dec_[A-Za-z0-9]+))?\s*-->/g;
|
|
3
|
-
/**
|
|
3
|
+
/** Character ranges covered by fenced code blocks (``` or ~~~), so a
|
|
4
|
+
* documentation EXAMPLE of a marker never registers as a live anchor.
|
|
5
|
+
* CommonMark-lite: a fence of N chars (≤3 leading spaces) closes only on a
|
|
6
|
+
* line of ≥N of the same char and nothing else; an unclosed fence runs to
|
|
7
|
+
* EOF; a backtick fence's info string may not itself contain a backtick. */
|
|
8
|
+
function fencedRanges(text) {
|
|
9
|
+
const ranges = [];
|
|
10
|
+
let open = null;
|
|
11
|
+
let offset = 0;
|
|
12
|
+
for (const line of text.split("\n")) {
|
|
13
|
+
const m = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
14
|
+
if (m) {
|
|
15
|
+
const ch = m[1][0];
|
|
16
|
+
if (!open) {
|
|
17
|
+
if (!(ch === "`" && m[2].includes("`")))
|
|
18
|
+
open = { ch, len: m[1].length, start: offset };
|
|
19
|
+
}
|
|
20
|
+
else if (ch === open.ch && m[1].length >= open.len && m[2].trim() === "") {
|
|
21
|
+
ranges.push([open.start, offset + line.length]);
|
|
22
|
+
open = null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
offset += line.length + 1;
|
|
26
|
+
}
|
|
27
|
+
if (open)
|
|
28
|
+
ranges.push([open.start, text.length]);
|
|
29
|
+
return ranges;
|
|
30
|
+
}
|
|
31
|
+
/** Parse every hunch:topic marker out of a markdown document. Markers inside
|
|
32
|
+
* fenced code blocks are examples, not declarations, and are skipped. */
|
|
4
33
|
export function parseDocAnchors(text) {
|
|
5
34
|
const out = [];
|
|
35
|
+
const fences = fencedRanges(text);
|
|
6
36
|
MARKER.lastIndex = 0;
|
|
7
37
|
let m;
|
|
8
38
|
while ((m = MARKER.exec(text))) {
|
|
9
|
-
|
|
39
|
+
const at = m.index;
|
|
40
|
+
if (fences.some(([s, e]) => at >= s && at <= e))
|
|
41
|
+
continue;
|
|
42
|
+
out.push({ topic: m[1], pin: m[2] ?? null, line: text.slice(0, at).split("\n").length });
|
|
10
43
|
}
|
|
11
44
|
return out;
|
|
12
45
|
}
|
package/dist/core/hookcache.js
CHANGED
|
@@ -55,6 +55,22 @@ export function injectionMode(sessionId, key, content) {
|
|
|
55
55
|
return "full"; // grounded beats deduped, always
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
+
/** Forget everything injected into a session. Compaction summarizes injected
|
|
59
|
+
* grounding out of the agent's context while the dedup map still says
|
|
60
|
+
* "delivered" — so on PreCompact / SessionStart[source=compact] the map must
|
|
61
|
+
* reset, or post-compact edits get delta one-liners against grounding the
|
|
62
|
+
* agent no longer has. Never throws (same posture as injectionMode). */
|
|
63
|
+
export function resetSessionInjections(sessionId) {
|
|
64
|
+
try {
|
|
65
|
+
if (!sessionId)
|
|
66
|
+
return;
|
|
67
|
+
const file = join(tmpdir(), "hunch-hookcache", `${sessionId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 80)}.json`);
|
|
68
|
+
rmSync(file, { force: true });
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* unwritable tmpdir — next injectionMode call falls back to "full" anyway */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
58
74
|
/** Drop session caches from long-gone sessions (best effort, bounded dir). */
|
|
59
75
|
function sweep(dir) {
|
|
60
76
|
try {
|
package/dist/core/premises.js
CHANGED
|
@@ -5,7 +5,7 @@ const clip = (s, n = 90) => (s.length > n ? s.slice(0, n - 1).trimEnd() + "…"
|
|
|
5
5
|
function validRel(p) {
|
|
6
6
|
return !!p && !p.startsWith("/") && !/^[A-Za-z]:[\\/]/.test(p) && !p.split(/[\\/]/).includes("..");
|
|
7
7
|
}
|
|
8
|
-
function checkPath(claim, rel, wantExists, env) {
|
|
8
|
+
function checkPath(claim, rel, wantExists, env, under) {
|
|
9
9
|
if (!validRel(rel)) {
|
|
10
10
|
return { claim, holds: false, reason: `unevaluable: path must be repo-relative ("${rel}") — fix the premise record` };
|
|
11
11
|
}
|
|
@@ -15,9 +15,26 @@ function checkPath(claim, rel, wantExists, env) {
|
|
|
15
15
|
? { claim, holds: true, reason: `"${rel}" still exists` }
|
|
16
16
|
: { claim, holds: false, reason: `"${rel}" no longer exists` };
|
|
17
17
|
}
|
|
18
|
+
// NEGATIVE probe. A missing path proves nothing on its own: a deleted subtree, a
|
|
19
|
+
// renamed directory and a typo all read as "absent". The `under` anchor is what makes
|
|
20
|
+
// the answer mean something — when the ancestor is gone, the question no longer has a
|
|
21
|
+
// subject, so the premise is UNEVALUABLE rather than quietly satisfied. That is the
|
|
22
|
+
// decay this closes: it is what rotted five decisions pointing into vscode-extension/
|
|
23
|
+
// after that tree was cut.
|
|
24
|
+
if (under !== undefined) {
|
|
25
|
+
if (!validRel(under)) {
|
|
26
|
+
return { claim, holds: false, reason: `unevaluable: under must be repo-relative ("${under}") — fix the premise record` };
|
|
27
|
+
}
|
|
28
|
+
if (!env.exists(under)) {
|
|
29
|
+
return { claim, holds: false, reason: `unevaluable: the anchor "${under}" no longer exists, so "${rel}" being absent proves nothing — re-anchor or supersede` };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
18
32
|
return exists
|
|
19
33
|
? { claim, holds: false, reason: `"${rel}" now exists` }
|
|
20
|
-
|
|
34
|
+
// The residual risk is stated, not hidden: a MISTYPED path is also absent, and it
|
|
35
|
+
// stays absent forever — the harm is a premise that silently never fires, which no
|
|
36
|
+
// existence probe can detect. Saying so is what keeps "holds" honest.
|
|
37
|
+
: { claim, holds: true, reason: `"${rel}" still absent${under ? ` under "${under}"` : ""} (a mistyped path also reads as absent — confirm it on re-attest)` };
|
|
21
38
|
}
|
|
22
39
|
function checkOne(p, env) {
|
|
23
40
|
// The clock is an INJECTED input, so it is an unevaluable case like any other.
|
|
@@ -30,7 +47,7 @@ function checkOne(p, env) {
|
|
|
30
47
|
return { claim: p.claim, holds: false, reason: `unevaluable: caller supplied a non-ISO clock ("${env.now}")` };
|
|
31
48
|
}
|
|
32
49
|
if (p.path_absent !== undefined)
|
|
33
|
-
return checkPath(p.claim, p.path_absent, false, env);
|
|
50
|
+
return checkPath(p.claim, p.path_absent, false, env, p.under);
|
|
34
51
|
if (p.path_exists !== undefined)
|
|
35
52
|
return checkPath(p.claim, p.path_exists, true, env);
|
|
36
53
|
if (p.review_by !== undefined) {
|
package/dist/core/types.js
CHANGED
|
@@ -115,12 +115,29 @@ export const ConformancePredicateSchema = z.object({
|
|
|
115
115
|
// (the human renews, supersedes, or retires — same ethos as topic anchors).
|
|
116
116
|
export const PremiseSchema = z.object({
|
|
117
117
|
claim: z.string().min(1).describe("the human-readable reason this decision rests on"),
|
|
118
|
-
path_absent: z.string().optional().describe("premise holds while this repo-relative path does NOT exist"),
|
|
118
|
+
path_absent: z.string().optional().describe("premise holds while this repo-relative path does NOT exist. Requires `under`. PREFER path_exists where you can: a negative probe cannot tell 'verified absent' from 'wrong path', so it fails OPEN, while path_exists fails closed."),
|
|
119
|
+
under: z.string().optional().describe("required with path_absent: an EXISTING repo-relative ancestor of it. When this anchor disappears (a directory deleted or moved), the premise reads unevaluable instead of silently 'still absent'."),
|
|
119
120
|
path_exists: z.string().optional().describe("premise holds while this repo-relative path exists"),
|
|
120
121
|
review_by: z.string().optional().describe("dated attestation: premise holds until this ISO date, then needs re-attesting"),
|
|
121
122
|
attested: z.string().optional().describe("ISO date a human last attested the claim (informational)"),
|
|
122
123
|
}).refine((p) => [p.path_absent, p.path_exists, p.review_by].filter((x) => x !== undefined).length <= 1, {
|
|
123
124
|
message: "a premise carries at most one check (path_absent | path_exists | review_by)",
|
|
125
|
+
}).refine((p) => p.path_absent === undefined || (typeof p.under === "string" && p.under.trim().length > 0), {
|
|
126
|
+
message: "path_absent requires `under`: an existing ancestor path. Without an anchor, a deleted or renamed subtree reads as 'still absent' forever. Prefer path_exists where you can — it fails closed.",
|
|
127
|
+
path: ["under"],
|
|
128
|
+
}).refine((p) => {
|
|
129
|
+
if (p.path_absent === undefined || typeof p.under !== "string")
|
|
130
|
+
return true;
|
|
131
|
+
const norm = (s) => s.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
132
|
+
const target = norm(p.path_absent);
|
|
133
|
+
const anchor = norm(p.under);
|
|
134
|
+
// Must be a real ANCESTOR, not an arbitrary existing path: `under` is what makes
|
|
135
|
+
// "absent" meaningful ("nothing named gateway UNDER src"). An unrelated anchor
|
|
136
|
+
// would prove the premise still evaluable while telling you nothing about it.
|
|
137
|
+
return anchor !== "" && target !== anchor && target.startsWith(`${anchor}/`);
|
|
138
|
+
}, {
|
|
139
|
+
message: "`under` must be a proper ancestor of `path_absent` (e.g. path_absent 'src/gateway' with under 'src')",
|
|
140
|
+
path: ["under"],
|
|
124
141
|
});
|
|
125
142
|
export const DecisionSchema = z.object({
|
|
126
143
|
id: z.string().describe("dec_*"),
|
|
@@ -156,6 +156,18 @@ export function installClaudeHooks(root, hookCmd) {
|
|
|
156
156
|
...keep(json.hooks.SessionStart),
|
|
157
157
|
{ hooks: [{ type: "command", command: hookCmd }] },
|
|
158
158
|
];
|
|
159
|
+
// Delegated agents start with no session grounding (orientation never fired
|
|
160
|
+
// inside them); compaction summarizes injected grounding away while the dedup
|
|
161
|
+
// map still says "delivered". These two events keep delivery alive across the
|
|
162
|
+
// whole session lifecycle, not just its first context window.
|
|
163
|
+
json.hooks.SubagentStart = [
|
|
164
|
+
...keep(json.hooks.SubagentStart),
|
|
165
|
+
{ hooks: [{ type: "command", command: hookCmd }] },
|
|
166
|
+
];
|
|
167
|
+
json.hooks.PreCompact = [
|
|
168
|
+
...keep(json.hooks.PreCompact),
|
|
169
|
+
{ hooks: [{ type: "command", command: hookCmd }] },
|
|
170
|
+
];
|
|
159
171
|
// Verification pipeline (core/pipeline.ts): PostToolUse records observable
|
|
160
172
|
// facts (edits, verify commands); Stop refuses to end a turn with unverified
|
|
161
173
|
// product edits at firm/strict firmness. Delivery is enforced, not hoped for.
|
package/dist/mcp/server.js
CHANGED
|
@@ -739,7 +739,8 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
739
739
|
// PremiseSchema in src/core/types.ts.
|
|
740
740
|
premises: z.array(z.object({
|
|
741
741
|
claim: z.string().min(1).describe("the human-readable reason this decision rests on"),
|
|
742
|
-
path_absent: z.string().optional().describe("premise holds while this repo-relative path does NOT exist"),
|
|
742
|
+
path_absent: z.string().optional().describe("premise holds while this repo-relative path does NOT exist. REQUIRES `under`. Prefer path_exists where you can — a negative probe fails OPEN, a positive one fails closed."),
|
|
743
|
+
under: z.string().optional().describe("required with path_absent: an EXISTING repo-relative ANCESTOR of it (path_absent 'src/gateway' -> under 'src'). When the anchor disappears the premise reads unevaluable instead of silently 'still absent'."),
|
|
743
744
|
path_exists: z.string().optional().describe("premise holds while this repo-relative path exists"),
|
|
744
745
|
review_by: z.string().optional().describe("dated attestation: premise holds until this ISO date, then needs re-attesting"),
|
|
745
746
|
attested: z.string().optional().describe("ISO date a human last attested the claim (informational)"),
|
|
@@ -941,6 +942,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
941
942
|
rationale: z.string().optional().describe("Why it must hold."),
|
|
942
943
|
source_decision: z.string().optional().describe("id of a decision this correction derives from."),
|
|
943
944
|
private: z.boolean().optional().describe("write into the PRIVATE overlay store (HUNCH_PRIVATE_DIR) instead of the committed repo — a sensitive rule enforced locally (pre-edit hook + local check) but never exposed in a public PR comment. Errors if no private store is configured."),
|
|
945
|
+
capture_token: z.string().optional().describe("token from hunch_capture_decision. The rule is recorded and enforced either way — the token only decides whether it may DENY: without one it lands as advisory testimony capped at severity 'warning'."),
|
|
944
946
|
},
|
|
945
947
|
}, async (input) => {
|
|
946
948
|
try {
|
|
@@ -950,7 +952,12 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
950
952
|
// paths (edit-tool payloads and MCP roots are absolute) and every consumer matches
|
|
951
953
|
// repo-relative — without this the rule would be blocking-but-inert and would leak
|
|
952
954
|
// the local filesystem path into the committed graph.
|
|
953
|
-
|
|
955
|
+
// Same authorship tier as hunch_record_decision: a consumed token mints the
|
|
956
|
+
// signature, an un-token'd write is testimony. Here the stakes are HIGHER — a
|
|
957
|
+
// blocking constraint DENIES edits, so an un-vouched write is capped at
|
|
958
|
+
// "warning" rather than being refused. Never Twice still lands immediately.
|
|
959
|
+
const vouched = consumeCaptureToken(input.capture_token);
|
|
960
|
+
const rec = buildCorrectionConstraint({ ...input, knownDeps: knownRepoDeps(root), root, vouched }, new Date().toISOString());
|
|
954
961
|
// Private corrections go to the overlay (enforced locally via the merged read,
|
|
955
962
|
// never rendered into the public CI comment, which is public-only by construction).
|
|
956
963
|
const home = store.captureHome(!!input.private);
|
|
@@ -983,7 +990,14 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
983
990
|
// The Constraint itself is the durable retry queue. Normal `hunch index`
|
|
984
991
|
// and post-commit sync rescan it; no in-process timer can be lost on exit.
|
|
985
992
|
const reviewNote = "\n\nREVIEW PENDING: After the fix is committed, run hunch index; an installed post-commit hook retries this automatically on the fixing commit. Only the supported static ESM import-declaration package projection is eligible, and it remains activation-blocked; the immediate guard is already durable.";
|
|
986
|
-
|
|
993
|
+
// Say plainly which tier this landed in. A silent downgrade would be its own
|
|
994
|
+
// dishonesty: the caller asked for "blocking" and must be told it is not.
|
|
995
|
+
const tierNote = vouched
|
|
996
|
+
? ""
|
|
997
|
+
: `
|
|
998
|
+
|
|
999
|
+
⚠ Recorded WITHOUT a capture interview — this rule is agent_recorded TESTIMONY${input.severity === "blocking" ? ' and was capped from "blocking" to "warning"' : ""}. It IS enforced: the pre-edit hook and CI surface it on every matching edit from now on. What it cannot do is DENY an edit — only a rule a human countersigned may block. Countersign it by re-recording through hunch_capture_decision → hunch_record_correction(capture_token).`;
|
|
1000
|
+
return ok(`${existing ? "Updated" : "Recorded"} ${rec.severity} constraint ${rec.id}: "${rec.statement}" (scope: ${rec.scope.join(", ")}).${where} It now ${enforce}.${reviewNote}${tierNote}`);
|
|
987
1001
|
}
|
|
988
1002
|
catch (e) {
|
|
989
1003
|
return err(`Failed to record correction: ${e.message}`);
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.davesheffer/hunch",
|
|
4
|
-
"description": "Engineering memory for AI-assisted codebases: decisions,
|
|
4
|
+
"description": "Engineering memory for AI-assisted codebases: decisions, bug lineage and invariants, over MCP.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"url": "https://github.com/davesheffer/hunch",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
9
|
"websiteUrl": "https://hunch-pi.vercel.app",
|
|
10
|
-
"version": "1.
|
|
10
|
+
"version": "1.12.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.12.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|