@davesheffer/hunch 1.3.1 → 1.4.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 -2
- package/dist/core/docanchors.js +5 -2
- package/dist/core/docscan.js +6 -0
- package/dist/core/pipeline.js +182 -0
- package/dist/integrations/scaffold.js +11 -0
- package/dist/mcp/server.js +50 -13
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -50,6 +50,7 @@ import { formatContext, formatStructure } from "../core/format.js";
|
|
|
50
50
|
import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
|
|
51
51
|
import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
|
|
52
52
|
import { injectionMode } from "../core/hookcache.js";
|
|
53
|
+
import { PIPELINE_LOOP, UNVERIFIED_NAG, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, savePipelineState, stopVerdict, } from "../core/pipeline.js";
|
|
53
54
|
import { draftDuplicateOf } from "../core/dupdetect.js";
|
|
54
55
|
import { loadGoldenSet, evaluateGraphLift } from "../eval/harness.js";
|
|
55
56
|
import { loadGuardCases, evalGuards, generateGuardCases } from "../eval/guards.js";
|
|
@@ -1556,11 +1557,47 @@ program
|
|
|
1556
1557
|
const firmness = readConfig(paths).firmness;
|
|
1557
1558
|
if (firmness === "off")
|
|
1558
1559
|
return;
|
|
1560
|
+
// Verification pipeline (delivery enforced, not hoped for — see core/pipeline.ts).
|
|
1561
|
+
// PostToolUse records facts; Stop gates on them. Both are pipeline-only events,
|
|
1562
|
+
// handled before the grounding dispatch below.
|
|
1563
|
+
if (evt.hook_event_name === "PostToolUse" && evt.session_id && pipelineEnabled()) {
|
|
1564
|
+
let st = loadPipelineState(evt.session_id);
|
|
1565
|
+
if (/^(Edit|Write|MultiEdit)$/.test(evt.tool_name ?? "")) {
|
|
1566
|
+
const p = evt.tool_input?.file_path;
|
|
1567
|
+
if (p)
|
|
1568
|
+
st = onEdit(st, toRepoRel(root, p));
|
|
1569
|
+
}
|
|
1570
|
+
else if (evt.tool_name === "Bash" || evt.tool_name === "PowerShell") {
|
|
1571
|
+
st = onCommand(st, String(evt.tool_input?.command ?? ""));
|
|
1572
|
+
}
|
|
1573
|
+
else if (evt.tool_name === "Skill") {
|
|
1574
|
+
st = onSkill(st, String(evt.tool_input?.skill ?? ""));
|
|
1575
|
+
}
|
|
1576
|
+
savePipelineState(evt.session_id, st);
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
if (evt.hook_event_name === "Stop" && evt.session_id && pipelineEnabled()) {
|
|
1580
|
+
const st = loadPipelineState(evt.session_id);
|
|
1581
|
+
const verdict = stopVerdict(st, firmness);
|
|
1582
|
+
if (verdict.block) {
|
|
1583
|
+
savePipelineState(evt.session_id, verdict.state);
|
|
1584
|
+
process.stdout.write(JSON.stringify({ decision: "block", reason: verdict.reason }));
|
|
1585
|
+
}
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1559
1588
|
if (evt.hook_event_name === "UserPromptSubmit") {
|
|
1560
1589
|
// When the prompt reads like a correction ("no / that's wrong / never X"),
|
|
1561
1590
|
// nudge the agent to PERSIST it as an enforced constraint (Never Twice) —
|
|
1562
1591
|
// not just obey it this once and forget it next session.
|
|
1563
|
-
|
|
1592
|
+
let text = looksLikeCorrection(evt.prompt) ? `${HOOK_REMINDER}\n\n${CORRECTION_NUDGE}` : HOOK_REMINDER;
|
|
1593
|
+
// Pipeline turn bookkeeping (fresh block budget) + the one nag that must
|
|
1594
|
+
// repeat: edits from an earlier turn still unverified.
|
|
1595
|
+
if (evt.session_id && pipelineEnabled()) {
|
|
1596
|
+
const st = onPrompt(loadPipelineState(evt.session_id));
|
|
1597
|
+
savePipelineState(evt.session_id, st);
|
|
1598
|
+
if (!st.verifyAfterEdit)
|
|
1599
|
+
text += `\n\n${UNVERIFIED_NAG}`;
|
|
1600
|
+
}
|
|
1564
1601
|
// Once per session is enough for the availability reminder — repeating it
|
|
1565
1602
|
// every prompt burns context for zero information. A correction nudge has
|
|
1566
1603
|
// different content, so it always comes through (dec_244397d920).
|
|
@@ -1579,8 +1616,12 @@ program
|
|
|
1579
1616
|
try {
|
|
1580
1617
|
const decisions = s.json.loadAll("decisions");
|
|
1581
1618
|
const { recent, roadmap, pendingReview } = nowData(decisions, 3);
|
|
1582
|
-
if (!decisions.length)
|
|
1619
|
+
if (!decisions.length) {
|
|
1620
|
+
// Fresh graph: nothing to orient on, but the operating loop still ships.
|
|
1621
|
+
if (pipelineEnabled())
|
|
1622
|
+
emitContext("SessionStart", PIPELINE_LOOP);
|
|
1583
1623
|
return;
|
|
1624
|
+
}
|
|
1584
1625
|
const L = [];
|
|
1585
1626
|
L.push(`🧠 Hunch orientation — ${decisions.length} decision(s) in the graph.`);
|
|
1586
1627
|
if (recent.length) {
|
|
@@ -1594,6 +1635,10 @@ program
|
|
|
1594
1635
|
if (pendingReview > 0)
|
|
1595
1636
|
L.push(`${pendingReview} auto-draft(s) awaiting \`hunch review\`.`);
|
|
1596
1637
|
L.push("Orient further: hunch_context(task) · hunch_structure() · `hunch now`.");
|
|
1638
|
+
// The operating loop rides session start — guaranteed delivery, once
|
|
1639
|
+
// (the zod bench showed ambient skills are read in ~0% of sessions).
|
|
1640
|
+
if (pipelineEnabled())
|
|
1641
|
+
L.push("", PIPELINE_LOOP);
|
|
1597
1642
|
emitContext("SessionStart", L.join("\n"));
|
|
1598
1643
|
}
|
|
1599
1644
|
finally {
|
package/dist/core/docanchors.js
CHANGED
|
@@ -29,8 +29,11 @@ export function renderDocGrounding(anchors, decisions) {
|
|
|
29
29
|
const rejected = rejectedForTopic(decisions, a.topic);
|
|
30
30
|
if (rejected.length)
|
|
31
31
|
line += `\n rejected: ${rejected.slice(0, 3).map((r) => clip(r, 90)).join("; ")}`;
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
// Scan ALL markers for this topic, not just the first: the topic dedupe must not
|
|
33
|
+
// let an earlier unpinned marker swallow a later marker's stale-pin warning.
|
|
34
|
+
const stalePin = anchors.find((x) => x.topic === a.topic && x.pin && x.pin !== current.id)?.pin;
|
|
35
|
+
if (stalePin) {
|
|
36
|
+
line += `\n ⚠ this section is PINNED to ${stalePin}, which is no longer current — reconcile the prose with ${current.id}, then re-pin.`;
|
|
34
37
|
}
|
|
35
38
|
parts.push(line);
|
|
36
39
|
}
|
package/dist/core/docscan.js
CHANGED
|
@@ -47,6 +47,12 @@ export function markdownDocs(root) {
|
|
|
47
47
|
}
|
|
48
48
|
};
|
|
49
49
|
walk(root, "", 0);
|
|
50
|
+
// Agent-facing prose lives under dot-dirs the general walk skips: skills and
|
|
51
|
+
// commands are exactly the docs that rot against the graph (a skill preaching
|
|
52
|
+
// a superseded decision misleads every future session) — scan them explicitly.
|
|
53
|
+
for (const sub of [".claude/skills", ".claude/commands"]) {
|
|
54
|
+
walk(join(root, sub), sub, 1);
|
|
55
|
+
}
|
|
50
56
|
return out;
|
|
51
57
|
}
|
|
52
58
|
/** Grade every repo doc. GENERATED wiki pages (hunch:wiki header) are views of
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verification pipeline (the "enforcement spine"): hooks that guarantee the
|
|
3
|
+
* operating loop — evidence → change → verify → attack → report — instead of
|
|
4
|
+
* hoping the agent reads a skill. Measured motivation (2026-07-08 zod bench):
|
|
5
|
+
* skills-as-files were read in 0/20 sessions; when the same content was
|
|
6
|
+
* guaranteed-delivered, FAIL→PASS flipped on every discriminating cell.
|
|
7
|
+
* Delivery, not content, was the bottleneck — so delivery is enforced here.
|
|
8
|
+
*
|
|
9
|
+
* Gates evaluate OBSERVABLE FACTS recorded from PostToolUse events (which
|
|
10
|
+
* files were edited, which verify-shaped commands ran afterwards) — never the
|
|
11
|
+
* agent's claims. The Stop gate refuses to end a turn with unverified product
|
|
12
|
+
* edits, at most twice per turn: a broken gate degrades to advisory, never a
|
|
13
|
+
* lockout.
|
|
14
|
+
*
|
|
15
|
+
* Firmness mapping (no new knob):
|
|
16
|
+
* off → pipeline inert
|
|
17
|
+
* advisory → inject the loop at SessionStart + nag on unverified edits; no blocks
|
|
18
|
+
* firm → + Stop gate (max 2 blocks per turn)
|
|
19
|
+
* strict → same as firm (strict's extra bite lives in the pre-edit deny gate)
|
|
20
|
+
*
|
|
21
|
+
* State is per-session scratch in the OS tmpdir (NOT .hunch/ — it is derived,
|
|
22
|
+
* disposable, and single-writer), mirroring hookcache.ts. Failure posture is
|
|
23
|
+
* con_03a0b94b2e: any error → do nothing, exit clean. Kill switch: HUNCH_PIPELINE=0.
|
|
24
|
+
*/
|
|
25
|
+
import { mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
export const emptyState = () => ({
|
|
29
|
+
turn: 0,
|
|
30
|
+
soulInjected: false,
|
|
31
|
+
blocks: 0,
|
|
32
|
+
domains: {},
|
|
33
|
+
editedFiles: [],
|
|
34
|
+
verifyAfterEdit: true,
|
|
35
|
+
});
|
|
36
|
+
export const DEFAULT_PROFILES = {
|
|
37
|
+
backend: {
|
|
38
|
+
paths: /(^|\/)(src|lib|server|api|core|store|services?)\/|\.(ts|mts|cts|js|mjs|cjs|py|go|rs|java|rb|php)$/i,
|
|
39
|
+
verify: /vitest|jest|pytest|go test|cargo test|tsx --test|npm (run )?test|pnpm (run )?test|tsc|typecheck/i,
|
|
40
|
+
},
|
|
41
|
+
frontend: {
|
|
42
|
+
paths: /\.(tsx|jsx|css|scss|html|vue|svelte)$|(^|\/)(components|pages|site|app|ui)\//i,
|
|
43
|
+
verify: /vite|next (build|dev)|npm run (build|dev)|pnpm (run )?(build|dev)|playwright|storybook|tsc/i,
|
|
44
|
+
},
|
|
45
|
+
tests: {
|
|
46
|
+
paths: /(^|\/)(test|tests|__tests__|e2e|spec)\/|\.(test|spec)\./i,
|
|
47
|
+
verify: /vitest|jest|pytest|tsx --test|npm (run )?test|pnpm (run )?test|playwright/i,
|
|
48
|
+
},
|
|
49
|
+
infra: {
|
|
50
|
+
paths: /Dockerfile|docker-compose|\.tf$|\.tfvars$|(^|\/)(\.github|k8s|helm|terraform|infra|deploy)\/|\.ya?ml$/i,
|
|
51
|
+
verify: /terraform (plan|validate)|docker (build|compose)|kubectl .*--dry-run|helm (lint|template)|actionlint|npm run build/i,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
/** Product code = behavior that ships. Docs, hunch's own graph, and .claude
|
|
55
|
+
* config are not gated — editing THIS machinery must never trip it. */
|
|
56
|
+
export function isProductPath(p) {
|
|
57
|
+
const norm = String(p).replace(/\\/g, "/");
|
|
58
|
+
if (/\.(md|mdx|txt)$/i.test(norm))
|
|
59
|
+
return false;
|
|
60
|
+
if (/(^|\/)\.(claude|hunch)(\/|$)/.test(norm))
|
|
61
|
+
return false;
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
export function classifyDomains(path, profiles = DEFAULT_PROFILES) {
|
|
65
|
+
const norm = path.replace(/\\/g, "/");
|
|
66
|
+
return Object.entries(profiles)
|
|
67
|
+
.filter(([, d]) => d.paths.test(norm))
|
|
68
|
+
.map(([name]) => name);
|
|
69
|
+
}
|
|
70
|
+
function verifyPattern(state, profiles = DEFAULT_PROFILES) {
|
|
71
|
+
const active = Object.keys(state.domains).filter((d) => profiles[d]);
|
|
72
|
+
const src = (active.length ? active : Object.keys(profiles)).map((d) => profiles[d].verify.source).join("|");
|
|
73
|
+
return new RegExp(src, "i");
|
|
74
|
+
}
|
|
75
|
+
// ------------------------------------------------------- state transitions
|
|
76
|
+
/** New user prompt: fresh block budget. */
|
|
77
|
+
export function onPrompt(state) {
|
|
78
|
+
return { ...state, turn: state.turn + 1, blocks: 0 };
|
|
79
|
+
}
|
|
80
|
+
/** Edit/Write/MultiEdit landed on `path`. */
|
|
81
|
+
export function onEdit(state, path, profiles = DEFAULT_PROFILES) {
|
|
82
|
+
if (!path || !isProductPath(path))
|
|
83
|
+
return state;
|
|
84
|
+
const domains = { ...state.domains };
|
|
85
|
+
for (const d of classifyDomains(path, profiles))
|
|
86
|
+
domains[d] = true;
|
|
87
|
+
return {
|
|
88
|
+
...state,
|
|
89
|
+
domains,
|
|
90
|
+
verifyAfterEdit: false,
|
|
91
|
+
editedFiles: state.editedFiles.includes(path) ? state.editedFiles : [...state.editedFiles, path],
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** A shell command ran. Verify-shaped + after an edit → the edits are covered. */
|
|
95
|
+
export function onCommand(state, command, profiles = DEFAULT_PROFILES) {
|
|
96
|
+
if (state.editedFiles.length && verifyPattern(state, profiles).test(command)) {
|
|
97
|
+
return { ...state, verifyAfterEdit: true };
|
|
98
|
+
}
|
|
99
|
+
return state;
|
|
100
|
+
}
|
|
101
|
+
/** A verification-class skill ran (/verify, /code-review) — counts as coverage. */
|
|
102
|
+
export function onSkill(state, skill) {
|
|
103
|
+
if (/code-review|verify|review/i.test(skill))
|
|
104
|
+
return { ...state, verifyAfterEdit: true };
|
|
105
|
+
return state;
|
|
106
|
+
}
|
|
107
|
+
// ------------------------------------------------------------------- gates
|
|
108
|
+
export const PIPELINE_LOOP = [
|
|
109
|
+
"Hunch pipeline — operating loop (enforced on observable facts, not claims):",
|
|
110
|
+
"1. SCOPE — restate the task; define done as something observable (a passing test, a rendered page, a number).",
|
|
111
|
+
"2. EVIDENCE — observe current behavior before editing: run the failing thing, read the real code path, quote the real error.",
|
|
112
|
+
"3. CHANGE — smallest edit that fixes the root cause, not the symptom.",
|
|
113
|
+
"4. VERIFY — after the last edit, RUN the relevant check (test/build/typecheck/plan). A claim without an exit code is not a result.",
|
|
114
|
+
"5. ATTACK — one honest paragraph: what would make this conclusion wrong?",
|
|
115
|
+
"6. REPORT — what ran, what passed, what stays unverified. Failures verbatim.",
|
|
116
|
+
].join("\n");
|
|
117
|
+
export const UNVERIFIED_NAG = "Hunch pipeline: earlier product edits are still UNVERIFIED — run the relevant test/build/typecheck before claiming anything about them.";
|
|
118
|
+
/** Stop-gate verdict. Blocks only at firm/strict, only with unverified product
|
|
119
|
+
* edits, and at most twice per turn. */
|
|
120
|
+
export function stopVerdict(state, firmness) {
|
|
121
|
+
const gated = firmness === "firm" || firmness === "strict";
|
|
122
|
+
if (!gated || state.verifyAfterEdit || state.editedFiles.length === 0 || state.blocks >= 2)
|
|
123
|
+
return { block: false };
|
|
124
|
+
const domains = Object.keys(state.domains).join(", ") || "generic";
|
|
125
|
+
return {
|
|
126
|
+
block: true,
|
|
127
|
+
state: { ...state, blocks: state.blocks + 1 },
|
|
128
|
+
reason: `Hunch pipeline gate — VERIFY unsatisfied. Product files were edited (${state.editedFiles.slice(-5).join(", ")}) ` +
|
|
129
|
+
`but no verifying command ran afterwards (domain: ${domains}). Do now, in order: ` +
|
|
130
|
+
`(1) run the relevant test/build/typecheck for those files; ` +
|
|
131
|
+
`(2) one honest paragraph attacking your own conclusion — what would make it wrong; ` +
|
|
132
|
+
`(3) report what ran, what passed, what stays unverified. ` +
|
|
133
|
+
`If verification is truly impossible here, say so explicitly and why.`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
// ------------------------------------------------------------------ storage
|
|
137
|
+
const STATE_DIR = join(tmpdir(), "hunch-pipeline");
|
|
138
|
+
const SWEEP_AGE_MS = 48 * 3600 * 1000;
|
|
139
|
+
export function pipelineEnabled() {
|
|
140
|
+
return process.env.HUNCH_PIPELINE !== "0";
|
|
141
|
+
}
|
|
142
|
+
/** Load session state; on ANY problem return a fresh state (never throw). */
|
|
143
|
+
export function loadPipelineState(sessionId) {
|
|
144
|
+
try {
|
|
145
|
+
const raw = JSON.parse(readFileSync(stateFile(sessionId), "utf8"));
|
|
146
|
+
return { ...emptyState(), ...raw };
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return emptyState();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** Persist session state (best effort — scratch data, single writer). */
|
|
153
|
+
export function savePipelineState(sessionId, state) {
|
|
154
|
+
try {
|
|
155
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
156
|
+
sweep();
|
|
157
|
+
writeFileSync(stateFile(sessionId), JSON.stringify(state));
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
/* losing scratch state beats breaking the hook */
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function stateFile(sessionId) {
|
|
164
|
+
return join(STATE_DIR, `${sessionId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 80)}.json`);
|
|
165
|
+
}
|
|
166
|
+
function sweep() {
|
|
167
|
+
try {
|
|
168
|
+
for (const f of readdirSync(STATE_DIR)) {
|
|
169
|
+
try {
|
|
170
|
+
if (Date.now() - statSync(join(STATE_DIR, f)).mtimeMs > SWEEP_AGE_MS)
|
|
171
|
+
rmSync(join(STATE_DIR, f), { force: true });
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
/* raced — skip */
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
/* dir unreadable — skip */
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
//# sourceMappingURL=pipeline.js.map
|
|
@@ -141,6 +141,17 @@ export function installClaudeHooks(root, hookCmd) {
|
|
|
141
141
|
...keep(json.hooks.SessionStart),
|
|
142
142
|
{ hooks: [{ type: "command", command: hookCmd }] },
|
|
143
143
|
];
|
|
144
|
+
// Verification pipeline (core/pipeline.ts): PostToolUse records observable
|
|
145
|
+
// facts (edits, verify commands); Stop refuses to end a turn with unverified
|
|
146
|
+
// product edits at firm/strict firmness. Delivery is enforced, not hoped for.
|
|
147
|
+
json.hooks.PostToolUse = [
|
|
148
|
+
...keep(json.hooks.PostToolUse),
|
|
149
|
+
{ matcher: "Edit|Write|MultiEdit|Bash|PowerShell|Skill", hooks: [{ type: "command", command: hookCmd }] },
|
|
150
|
+
];
|
|
151
|
+
json.hooks.Stop = [
|
|
152
|
+
...keep(json.hooks.Stop),
|
|
153
|
+
{ hooks: [{ type: "command", command: hookCmd }] },
|
|
154
|
+
];
|
|
144
155
|
const next = JSON.stringify(json, null, 2) + "\n";
|
|
145
156
|
if (existed && before === next)
|
|
146
157
|
return { path: file, action: "unchanged" };
|
package/dist/mcp/server.js
CHANGED
|
@@ -53,9 +53,23 @@ const more = (total, cap, hint = "") => total > cap ? `\n …(+${total - cap} m
|
|
|
53
53
|
// thin wrappers bind the process clock and id source at the call site (§5 Stage 1).
|
|
54
54
|
const issueCaptureToken = () => issueToken(randomUUID, Date.now());
|
|
55
55
|
const consumeCaptureToken = (token) => consumeToken(token, Date.now());
|
|
56
|
-
/** The interrogation protocol returned by hunch_capture_decision.
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
/** The interrogation protocol returned by hunch_capture_decision. With `deciding`,
|
|
57
|
+
* the choice is NOT yet made: the verdict loop runs first so the record's
|
|
58
|
+
* alternatives_rejected are attacks that actually ran — not post-hoc fiction. */
|
|
59
|
+
function grillingProtocol(topic, token, deciding = false) {
|
|
60
|
+
const verdict = [
|
|
61
|
+
"The decision is NOT yet made — run the VERDICT LOOP first (one question at a time), then the grilling rules below.",
|
|
62
|
+
"",
|
|
63
|
+
"VERDICT LOOP:",
|
|
64
|
+
"A. SPLIT — one separable call per verdict, one topic per call. If the ask bundles several decisions, split and run each.",
|
|
65
|
+
"B. CANDIDATES — elicit at least TWO real options (include do-nothing when sane). One candidate = anchoring; keep asking.",
|
|
66
|
+
"C. ATTACK — attack each candidate from INDEPENDENT lenses: product, technical, strategy, economics, and self-consistency (does it contradict a recorded decision or constraint? cite dec_/con_ ids). Every attack cites evidence observed this session; no evidence → mark it plausible and weigh it less.",
|
|
67
|
+
"D. CONVERGE — two or more independent landing attacks kill a candidate. Keep the FAILED attacks too — they are the tested-safe surface; fold them into context. No convergence → prefer the candidate whose failure is REVERSIBLE.",
|
|
68
|
+
"E. TRIPWIRES — for each rejected candidate, ask what future evidence would make it right after all; embed it in the rejected alternative ('rejected X — revisit if Y').",
|
|
69
|
+
"",
|
|
70
|
+
"",
|
|
71
|
+
].join("\n");
|
|
72
|
+
return (deciding ? verdict : "") + [
|
|
59
73
|
"You are capturing an engineering decision into Hunch's graph. Run the GRILLING LOOP, then commit.",
|
|
60
74
|
"",
|
|
61
75
|
"RULES:",
|
|
@@ -68,6 +82,20 @@ function grillingProtocol(topic, token) {
|
|
|
68
82
|
"Required before commit: topic, title, decision, context (the rationale/why), alternatives_rejected. Missing any → keep grilling.",
|
|
69
83
|
].join("\n");
|
|
70
84
|
}
|
|
85
|
+
/** Deterministic quality nudge for a freshly recorded ACCEPTED decision: an
|
|
86
|
+
* unattacked record (no rejected alternatives) or rejections without a
|
|
87
|
+
* "revisit if" flip condition get ONE advisory line — never a gate. */
|
|
88
|
+
function qualityNudge(rec) {
|
|
89
|
+
if (rec.status !== "accepted")
|
|
90
|
+
return "";
|
|
91
|
+
if (!rec.alternatives_rejected.length) {
|
|
92
|
+
return `\n\n△ Unattacked record: no alternatives_rejected. The graph can only veto what was explicitly rejected — next time run hunch_capture_decision(deciding:true) so rejections come from attacks that actually ran.`;
|
|
93
|
+
}
|
|
94
|
+
if (!rec.alternatives_rejected.some((a) => /revisit if/i.test(a))) {
|
|
95
|
+
return `\n\n△ Tip: none of the ${rec.alternatives_rejected.length} rejected alternative(s) carries a "revisit if …" flip condition — embed one per rejection so a future session knows when the call expires.`;
|
|
96
|
+
}
|
|
97
|
+
return "";
|
|
98
|
+
}
|
|
71
99
|
/** Resolve a free-form target (symbol id / name / file path) to symbol records. */
|
|
72
100
|
function resolveSymbols(store, target) {
|
|
73
101
|
target = toPosixTarget(target);
|
|
@@ -359,10 +387,11 @@ export function buildServer(root) {
|
|
|
359
387
|
inputSchema: {
|
|
360
388
|
topic: z.string().optional().describe("proposed topic anchor (confirm with the human before committing)"),
|
|
361
389
|
seed: z.string().optional().describe("what the decision is about, to focus the first question"),
|
|
390
|
+
deciding: z.boolean().optional().describe("the choice is NOT yet made — prepend the verdict loop (candidates → evidenced attacks → convergence → tripwires) so alternatives_rejected come from attacks that actually ran, then grill and record as usual"),
|
|
362
391
|
},
|
|
363
|
-
}, async ({ topic, seed }) => {
|
|
392
|
+
}, async ({ topic, seed, deciding }) => {
|
|
364
393
|
const token = issueCaptureToken();
|
|
365
|
-
return ok(`${grillingProtocol(topic, token)}${seed ? `\n\nSeed: ${seed}` : ""}`);
|
|
394
|
+
return ok(`${grillingProtocol(topic, token, !!deciding)}${seed ? `\n\nSeed: ${seed}` : ""}`);
|
|
366
395
|
});
|
|
367
396
|
// -- hunch_current_decision (decision-grounding: current(topic)) ----------
|
|
368
397
|
server.registerTool("hunch_current_decision", {
|
|
@@ -448,31 +477,36 @@ export function buildServer(root) {
|
|
|
448
477
|
provenance: { source, confidence: 0.95, evidence: (decision.related_files ?? existing?.provenance.evidence ?? []).map(toPosixTarget) },
|
|
449
478
|
date: now,
|
|
450
479
|
};
|
|
480
|
+
// Where this write will actually land (see captureHome). Resolved BEFORE the
|
|
481
|
+
// uniqueness guard: in unified ("shared") mode home is the overlay even when
|
|
482
|
+
// private:false, so the guard must key its incumbent lookup on HOME, not on
|
|
483
|
+
// the flag — keying on the flag let a shared-mode supersede of a public
|
|
484
|
+
// incumbent pass the guard and then no-op the close (two live decisions).
|
|
485
|
+
const home = store.captureHome(!!decision.private);
|
|
451
486
|
// Decision-grounding uniqueness guard (§4 Enforcement): never create a SECOND
|
|
452
487
|
// live decision for one topic. Exclude ONLY the incumbent this write will
|
|
453
488
|
// actually close — one resolvable in the SAME store the write lands in. A
|
|
454
|
-
// cross-store supersede (
|
|
489
|
+
// cross-store supersede (the incumbent lives where this write can't close it)
|
|
455
490
|
// would no-op and leave two live decisions, so it is treated as unresolved
|
|
456
491
|
// (willClose=null) → the guard fires and refuses. Same-id re-record is allowed.
|
|
457
492
|
if (rec.topic && rec.status === "accepted") {
|
|
458
|
-
const willClose = decision.supersedes && store.decisionInStore(decision.supersedes,
|
|
493
|
+
const willClose = decision.supersedes && store.decisionInStore(decision.supersedes, home === "private")
|
|
459
494
|
? decision.supersedes
|
|
460
495
|
: null;
|
|
461
496
|
const others = captureConflicts(store.recs("decisions"), rec.topic, id, willClose);
|
|
462
497
|
if (others.length) {
|
|
463
498
|
const list = others.map((d) => `${d.id} ("${d.title}")`).join(", ");
|
|
464
499
|
const crossStore = decision.supersedes && !willClose
|
|
465
|
-
? ` (note: supersedes:"${decision.supersedes}" is not in the ${
|
|
500
|
+
? ` (note: supersedes:"${decision.supersedes}" is not in the ${home} store this write lands in, so it can't be closed from here)`
|
|
466
501
|
: "";
|
|
467
502
|
return err(`Topic "${rec.topic}" already has a live decision: ${list}.${crossStore} ` +
|
|
468
503
|
`Hunch will not create a second current decision for one topic. Resolve it: ` +
|
|
469
504
|
`re-record with supersedes:<id> to replace it (linked, same store), pick a distinct topic to split, or discard this capture.`);
|
|
470
505
|
}
|
|
471
506
|
}
|
|
472
|
-
// Route the write to its ONE home
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
const home = store.captureHome(!!decision.private);
|
|
507
|
+
// Route the write to its ONE home: an explicit private:true goes to the overlay
|
|
508
|
+
// (putPrivate throws rather than silently falling public); in unified ("shared")
|
|
509
|
+
// mode EVERY capture goes to the overlay; else the public store.
|
|
476
510
|
if (home === "private")
|
|
477
511
|
store.putPrivate("decisions", rec);
|
|
478
512
|
else
|
|
@@ -501,12 +535,15 @@ export function buildServer(root) {
|
|
|
501
535
|
: capture_token
|
|
502
536
|
? ""
|
|
503
537
|
: `\n\n⚠ Recorded WITHOUT a capture interview — the record stands, but harden it NOW in one exchange instead of switching flows: answer the first grilling question directly — "What alternative did you seriously consider and reject for '${rec.title.slice(0, 60)}', and what breaks if a future session re-introduces it?" — then fold the answer into alternatives_rejected via hunch_record_decision(supersedes: ${id}) or start the full interview with hunch_capture_decision. (A future major version will require a capture token here.)`;
|
|
538
|
+
// Quality nudge only when the untokened deprecation nudge isn't already
|
|
539
|
+
// grilling — one advisory voice per response, never two.
|
|
540
|
+
const quality = gated || capture_token ? qualityNudge(rec) : "";
|
|
504
541
|
const supNote = superseded ? ` Superseded ${superseded.id} (window closed at ${rec.valid_from}).` : "";
|
|
505
542
|
const note = decision.commit && !fullSha ? ` (note: commit "${decision.commit}" could not be resolved — recorded as a standalone decision, not linked to a commit)` : "";
|
|
506
543
|
const where = decision.private
|
|
507
544
|
? ` [PRIVATE overlay — not committed to this repo]${flushed}`
|
|
508
545
|
: home === "private" ? ` [SHARED store — one source of truth for the whole team]${flushed}` : flushed;
|
|
509
|
-
return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${where}${supNote}${note}${captureNote}`);
|
|
546
|
+
return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${where}${supNote}${note}${captureNote}${quality}`);
|
|
510
547
|
}
|
|
511
548
|
catch (e) {
|
|
512
549
|
return err(`Failed to record decision: ${e.message}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
6
6
|
"description": "Architectural Conformance for AI-generated code: a git-native graph that deterministically blocks AI changes which break your architecture — the semantic invariants (layering, must-reach, dependency direction) pattern-SAST can't express — grounded in the decisions and bugs behind each rule, across any MCP assistant (Claude Code, Cursor, Copilot, Windsurf, Codex).",
|