@davesheffer/hunch 1.10.7 → 1.11.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 CHANGED
@@ -4939,6 +4939,17 @@ program
4939
4939
  console.log(`· ${f.id} — ${f.detail}`);
4940
4940
  console.log(`\nHeal: run \`hunch wiki --heal\` — regenerates only the stale pages (the wiki is a derived view; never edit it by hand).\n`);
4941
4941
  }
4942
+ // Every drift kind heals here — see bug_drift_heal_asymmetry above. premise-stale
4943
+ // shipped in the drift report without a section here, so a repo whose ONLY drift
4944
+ // was a dead premise got "N findings" from `hunch drift` and a bare closing line
4945
+ // from `hunch heal` — exactly the broken loop that bug is about.
4946
+ const premiseStale = kind("premise-stale");
4947
+ if (premiseStale.length) {
4948
+ console.log(`${premiseStale.length} decision(s) rest on a premise that no longer holds (world≠graph):\n`);
4949
+ for (const f of premiseStale)
4950
+ console.log(`· ${f.id} — ${f.detail}`);
4951
+ console.log(`\nHeal: this is a HUMAN call — the decision's authority is unchanged until you make it. Re-attest (update the premise's review_by/attested), supersede via /capture, or retire the decision. Keeping it for consistency is a valid answer.\n`);
4952
+ }
4942
4953
  console.log(`Hunch never rewrites prose for you; this is a read-only reconciliation report.`);
4943
4954
  }
4944
4955
  finally {
@@ -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 ?? "Captured from a human correction of the agent (Never Twice).",
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
- provenance: { source: "human_confirmed", confidence: 1, evidence: [], last_verified: now },
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
@@ -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
- /** Parse every hunch:topic marker out of a markdown document. */
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
- out.push({ topic: m[1], pin: m[2] ?? null, line: text.slice(0, m.index).split("\n").length });
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
  }
@@ -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,13 +15,39 @@ 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
- : { claim, holds: true, reason: `"${rel}" still absent` };
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) {
40
+ // The clock is an INJECTED input, so it is an unevaluable case like any other.
41
+ // `Date.parse("nope")` is NaN, and `NaN > due` is false — which fell through to
42
+ // "attested until …", i.e. HOLDS. The module's hard rule ("cannot-evaluate is
43
+ // never holds") was enforced for a bad review_by but not for a bad now, and the
44
+ // unenforced half is the one a future caller can get wrong. Checked once, here,
45
+ // so it covers every check kind rather than only the dated one.
46
+ if (!Number.isFinite(Date.parse(env.now))) {
47
+ return { claim: p.claim, holds: false, reason: `unevaluable: caller supplied a non-ISO clock ("${env.now}")` };
48
+ }
23
49
  if (p.path_absent !== undefined)
24
- return checkPath(p.claim, p.path_absent, false, env);
50
+ return checkPath(p.claim, p.path_absent, false, env, p.under);
25
51
  if (p.path_exists !== undefined)
26
52
  return checkPath(p.claim, p.path_exists, true, env);
27
53
  if (p.review_by !== undefined) {
@@ -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_*"),
@@ -731,14 +731,20 @@ export function buildServerWithRootControl(initialRoot) {
731
731
  related_files: z.array(z.string()).optional(),
732
732
  related_components: z.array(z.string()).optional(),
733
733
  topic: z.string().optional().describe("decision-grounding anchor — one topic per decision; enables doc≠graph drift detection for it. Omit to leave un-anchored."),
734
+ // FLAT, matching PremiseSchema exactly. A nested { check: {...} } shape is
735
+ // silently STRIPPED by Zod, leaving a claim-only premise — and a claim-only
736
+ // premise is "documented only (no check attached)", which ALWAYS HOLDS. An
737
+ // agent following a wrong schema would record a premise that can never fire:
738
+ // the exact fail-open this feature exists to prevent. Keep in lockstep with
739
+ // PremiseSchema in src/core/types.ts.
734
740
  premises: z.array(z.object({
735
- claim: z.string().describe("the checkable reason this decision rests on, in plain words"),
736
- check: z.object({
737
- kind: z.enum(["path_absent", "path_exists", "review_by"]),
738
- path: z.string().optional().describe("repo-relative path for path_absent / path_exists"),
739
- review_by: z.string().optional().describe("ISO date this attestation expires (review_by)"),
740
- }).optional().describe("at most one deterministic check; omit for an unchecked note"),
741
- })).optional().describe("the checkable reasons this decision rests on. A dead premise NEVER changes authority it raises an escalation for the human. Omit on re-record to keep the incumbent's premises."),
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. 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'."),
744
+ path_exists: z.string().optional().describe("premise holds while this repo-relative path exists"),
745
+ review_by: z.string().optional().describe("dated attestation: premise holds until this ISO date, then needs re-attesting"),
746
+ attested: z.string().optional().describe("ISO date a human last attested the claim (informational)"),
747
+ })).optional().describe("the checkable reasons this decision rests on — at most ONE check per premise (path_absent | path_exists | review_by). A dead premise NEVER changes authority; it raises an escalation for the human. Omit on re-record to keep the incumbent's premises."),
742
748
  status: z.enum(["proposed", "accepted", "rejected", "superseded"]).optional(),
743
749
  commit: z.string().optional(),
744
750
  supersedes: z.string().optional().describe("id of a decision this one replaces — closes its valid-time window (invalidate, don't delete)"),
@@ -936,6 +942,7 @@ export function buildServerWithRootControl(initialRoot) {
936
942
  rationale: z.string().optional().describe("Why it must hold."),
937
943
  source_decision: z.string().optional().describe("id of a decision this correction derives from."),
938
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'."),
939
946
  },
940
947
  }, async (input) => {
941
948
  try {
@@ -945,7 +952,12 @@ export function buildServerWithRootControl(initialRoot) {
945
952
  // paths (edit-tool payloads and MCP roots are absolute) and every consumer matches
946
953
  // repo-relative — without this the rule would be blocking-but-inert and would leak
947
954
  // the local filesystem path into the committed graph.
948
- const rec = buildCorrectionConstraint({ ...input, knownDeps: knownRepoDeps(root), root }, new Date().toISOString());
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());
949
961
  // Private corrections go to the overlay (enforced locally via the merged read,
950
962
  // never rendered into the public CI comment, which is public-only by construction).
951
963
  const home = store.captureHome(!!input.private);
@@ -978,7 +990,14 @@ export function buildServerWithRootControl(initialRoot) {
978
990
  // The Constraint itself is the durable retry queue. Normal `hunch index`
979
991
  // and post-commit sync rescan it; no in-process timer can be lost on exit.
980
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.";
981
- return ok(`${existing ? "Updated" : "Recorded"} ${rec.severity} constraint ${rec.id}: "${rec.statement}" (scope: ${rec.scope.join(", ")}).${where} It now ${enforce}.${reviewNote}`);
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}`);
982
1001
  }
983
1002
  catch (e) {
984
1003
  return err(`Failed to record correction: ${e.message}`);
@@ -571,7 +571,17 @@ export class HunchStore {
571
571
  if (m) {
572
572
  if (m.dead)
573
573
  w *= 0.6;
574
- w *= m.provenance.includes("human_confirmed") ? 1 : m.provenance.includes("llm_draft") ? 0.85 : 0.75;
574
+ // agent_recorded sits BETWEEN human_confirmed and llm_draft. It is testimony
575
+ // a human directed the capture but did not countersign it through /capture — so
576
+ // it must not carry human authority (strict/veto gates key on human_confirmed).
577
+ // But it is a deliberate, human-prompted write, and the unlabelled tier (0.75)
578
+ // is for extracted/inferred machine output. Without this it fell to 0.75 and
579
+ // ranked BELOW an llm_draft the model produced unprompted, which inverts what
580
+ // the authorship stamp is trying to express.
581
+ w *= m.provenance.includes("human_confirmed") ? 1
582
+ : m.provenance.includes("agent_recorded") ? 0.9
583
+ : m.provenance.includes("llm_draft") ? 0.85
584
+ : 0.75;
575
585
  if (m.at) {
576
586
  const ageDays = Math.max(0, now - Date.parse(m.at)) / 86400000;
577
587
  if (Number.isFinite(ageDays))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.10.7",
3
+ "version": "1.11.0",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
package/server.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
- "$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
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, rejected alternatives, bug lineage, and deterministic architectural gates, served git-natively over MCP.",
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.7",
10
+ "version": "1.11.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.10.7",
16
+ "version": "1.11.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {