@davesheffer/hunch 1.10.6 → 1.10.7

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
@@ -39,7 +39,7 @@ import { writeTeamConfig, ensureTeamOverlay, readTeamConfig, safeGitUrl, safeTea
39
39
  import { runbookId, decisionId } from "../core/ids.js";
40
40
  import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
41
41
  import { extractInlineIntent } from "../extractors/comments.js";
42
- import { renderText, renderMarkdown, renderImpact, reportFailsStrict } from "../core/checkreport.js";
42
+ import { renderText, renderMarkdown, renderSarif, renderImpact, reportFailsStrict } from "../core/checkreport.js";
43
43
  import { partitionReview, isReviewDraft, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
44
44
  import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
45
45
  import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
@@ -70,6 +70,7 @@ import { generateWiki, wikiStatus, wikiPrompt, publicHome, privateHome, readWiki
70
70
  import { adoptProsePrompt } from "../wiki/adopt.js";
71
71
  import { topicCollisions, renderGrounding, isInForce } from "../core/topics.js";
72
72
  import { pendingEscalations, policyEscalations } from "../core/escalations.js";
73
+ import { premiseEscalations } from "../core/premises.js";
73
74
  import { parseDocAnchors, renderDocGrounding } from "../core/docanchors.js";
74
75
  import { compareCandidates } from "../core/compare.js";
75
76
  import { checkConformance } from "../core/conformance.js";
@@ -3193,7 +3194,7 @@ program
3193
3194
  .option("--commit <sha>", "check a specific commit's files")
3194
3195
  .option("--base <ref>", "check a PR/branch: files changed vs <ref> (e.g. origin/main) — for CI")
3195
3196
  .option("--strict", "exit non-zero ONLY on a direct, high-confidence, non-stale blocking invariant (near/stale/low-confidence stay advisory)")
3196
- .option("--format <fmt>", "output: text (default) | markdown (a PR comment)", "text")
3197
+ .option("--format <fmt>", "output: text (default) | markdown (a PR comment) | sarif (SARIF 2.1.0 for code scanning)", "text")
3197
3198
  .option("--blast", "also print the dependency blast radius of the changed files")
3198
3199
  .option("--public-only", "exclude the private overlay (HUNCH_PRIVATE_DIR) from the report — use for any output that may be posted publicly (the CI PR comment passes this)")
3199
3200
  .action((opts) => {
@@ -3201,6 +3202,9 @@ program
3201
3202
  if (sources.length > 1)
3202
3203
  return fail(`pick one of --staged / --working / --commit / --base (got ${sources.join(", ")})`);
3203
3204
  const markdown = opts.format === "markdown";
3205
+ // SARIF collects every gate family into ONE JSON document at the end, so all
3206
+ // interleaved section prints are suppressed for this format.
3207
+ const sarif = opts.format === "sarif";
3204
3208
  const emptyReport = { fileCount: 0, strict: !!opts.strict, direct: [], near: [], regressions: [], vetoes: [], redundant: [], strictBlockers: 0, regBlocking: 0, vetoBlocking: 0 };
3205
3209
  const { store, root, teamPullStatus } = storeFor({ requireFreshTeamMemory: !!opts.strict && !opts.publicOnly });
3206
3210
  const teamFreshnessFailure = teamPullStatus !== null
@@ -3246,7 +3250,7 @@ program
3246
3250
  ? sourceGraphSnapshot(root, graphScan.source, graphScan.symbols, graphScan.edges, graphScan.components)
3247
3251
  : undefined;
3248
3252
  const semanticIssues = graphScan?.issues ?? [];
3249
- if (semanticIssues.length) {
3253
+ if (semanticIssues.length && !sarif) {
3250
3254
  if (markdown) {
3251
3255
  console.log(`\n### ‼ Incomplete semantic source scan — ${semanticIssues.length} file(s) rejected\n`);
3252
3256
  for (const issue of semanticIssues)
@@ -3281,7 +3285,7 @@ program
3281
3285
  publicOnly: !!opts.publicOnly,
3282
3286
  })
3283
3287
  : emptyReport;
3284
- if (opts.blast && !markdown && files.length) {
3288
+ if (opts.blast && !markdown && !sarif && files.length) {
3285
3289
  console.log(`Blast radius of ${files.length} changed file(s):`);
3286
3290
  for (const f of files) {
3287
3291
  const b = store.blastRadiusFiles(f);
@@ -3290,9 +3294,11 @@ program
3290
3294
  }
3291
3295
  console.log("");
3292
3296
  }
3293
- console.log(files.length
3294
- ? (markdown ? renderMarkdown(report) : renderText(report))
3295
- : (markdown ? renderMarkdown(emptyReport) : "No changed files to check."));
3297
+ if (!sarif) {
3298
+ console.log(files.length
3299
+ ? (markdown ? renderMarkdown(report) : renderText(report))
3300
+ : (markdown ? renderMarkdown(emptyReport) : "No changed files to check."));
3301
+ }
3296
3302
  // ARCHITECTURAL CONFORMANCE: does the RESULTING code still satisfy every recorded
3297
3303
  // architectural invariant? This is graph-reachability, not a diff — so it catches semantic
3298
3304
  // violations a pattern-matcher / SAST can't express (a controller that now reaches the DB
@@ -3305,7 +3311,7 @@ program
3305
3311
  graph: { symbols: graphScan.symbols, edges: graphScan.edges },
3306
3312
  }).filter((c) => !c.satisfied)
3307
3313
  : [];
3308
- if (confViolations.length) {
3314
+ if (confViolations.length && !sarif) {
3309
3315
  if (markdown) {
3310
3316
  console.log(`\n### ⛔ Architectural conformance — ${confViolations.length} invariant(s) violated\n`);
3311
3317
  for (const c of confViolations) {
@@ -3339,7 +3345,7 @@ program
3339
3345
  const policyResults = hasActivePolicies
3340
3346
  ? constitution.evaluate({ activeOnly: true, publicOnly: !!opts.publicOnly, behavior, snapshot: staticSnapshot })
3341
3347
  : [];
3342
- if (policyResults.length) {
3348
+ if (policyResults.length && !sarif) {
3343
3349
  if (markdown) {
3344
3350
  console.log(`\n### 📜 Hunch Constitution — ${policyResults.length} policy receipt(s)\n`);
3345
3351
  for (const r of policyResults) {
@@ -3354,6 +3360,24 @@ program
3354
3360
  renderPolicyEvaluations(policyResults).forEach((line) => console.log(line));
3355
3361
  }
3356
3362
  }
3363
+ if (sarif) {
3364
+ const extras = {
3365
+ conformance: confViolations.map((c) => {
3366
+ const dec = store.json.get("decisions", c.decision);
3367
+ return { decision: c.decision, title: c.title, detail: c.detail, ...(dec?.context ? { why: dec.context } : {}), ...(dec?.caused_by_bug ? { bug: dec.caused_by_bug } : {}) };
3368
+ }),
3369
+ policies: policyResults.map((r) => ({
3370
+ id: r.policy.id,
3371
+ result: r.evaluation.result,
3372
+ explanation: r.evaluation.explanation,
3373
+ blocks: r.blocks,
3374
+ receipt: r.evaluation.deterministic_hash,
3375
+ ...(r.gate_error ? { gateError: r.gate_error } : {}),
3376
+ })),
3377
+ scanIssues: semanticIssues.map((s) => ({ path: s.path, detail: s.detail, code: s.code })),
3378
+ };
3379
+ console.log(renderSarif(report, HUNCH_VERSION, extras));
3380
+ }
3357
3381
  const constitutionFails = policyResults.some((r) => r.blocks || r.strict_error);
3358
3382
  if (teamFreshnessFailure)
3359
3383
  console.error(`error: ${teamFreshnessFailure}`);
@@ -3807,6 +3831,7 @@ program
3807
3831
  if (pendingReview > 0)
3808
3832
  L.push(`${pendingReview} legacy un-vouched draft(s) — adopt as advisory memory with \`hunch adopt-drafts\` (new captures auto-trust).`);
3809
3833
  const escalations = pendingEscalations(decisions);
3834
+ escalations.push(...premiseEscalations(decisions, { now: new Date().toISOString(), exists: (p) => existsSync(join(paths.root, p)) }));
3810
3835
  try {
3811
3836
  // Constitution human moments ride the same line; a broken policy store
3812
3837
  // must never take session-start orientation down (fail open). Public
@@ -4378,7 +4403,11 @@ program
4378
4403
  .action(async (opts) => {
4379
4404
  const { store, root } = storeFor();
4380
4405
  try {
4381
- const items = pendingEscalations(store.recs("decisions"));
4406
+ const decisionsForEsc = store.recs("decisions");
4407
+ const items = pendingEscalations(decisionsForEsc);
4408
+ // Premise decay rides the same inline surface: a decision whose recorded
4409
+ // reason died is a QUESTION for the human — authority never changes here.
4410
+ items.push(...premiseEscalations(decisionsForEsc, { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) }));
4382
4411
  // Constitution moments ride the same inline surface (§59.5.3) — never a queue.
4383
4412
  // Fail open: a broken policy store must not take the memory escalations down.
4384
4413
  try {
@@ -211,4 +211,79 @@ export function renderMarkdown(r) {
211
211
  out.push(`\n<sub>🧠 Hunch · engineering memory · run \`hunch why <file>\` for the full reasoning.</sub>`);
212
212
  return out.join("\n");
213
213
  }
214
+ const sarifLoc = (file) => file ? [{ physicalLocation: { artifactLocation: { uri: file.replace(/\\/g, "/") } } }] : undefined;
215
+ /** Render the full check verdict as a SARIF 2.1.0 document. Level mapping is
216
+ * severity-truth, independent of the exit code: `error` = would fail --strict
217
+ * (strict-blocking invariants, blocking-linked regressions/vetoes, conformance
218
+ * violations, authorized policy blocks, incomplete scans), `warning` = blocking
219
+ * records the strict gate downgraded plus warning-severity hits, `note` = advisory
220
+ * (near, redundant, non-blocking receipts). */
221
+ export function renderSarif(r, version, extras = {}) {
222
+ const rules = new Map();
223
+ const results = [];
224
+ const add = (ruleId, level, text, ruleText, file) => {
225
+ if (!rules.has(ruleId))
226
+ rules.set(ruleId, { id: ruleId, shortDescription: { text: clip(ruleText, 300) }, defaultConfiguration: { level } });
227
+ results.push({ ruleId, level, message: { text }, ...(sarifLoc(file) ? { locations: sarifLoc(file) } : {}) });
228
+ };
229
+ for (const c of r.direct) {
230
+ const level = c.strictBlocks ? "error" : c.severity === "advisory" ? "note" : "warning";
231
+ let text = `[${c.severity}] ${c.statement}`;
232
+ if (c.rationale)
233
+ text += ` — ${c.rationale}`;
234
+ if (c.downgrade)
235
+ text += ` (advisory under strict: ${c.downgrade})`;
236
+ if (c.why?.decision)
237
+ text += `\nwhy: “${c.why.decision.title}” (${c.why.decision.id})`;
238
+ if (c.why?.bug)
239
+ text += `\nguards against: ${c.why.bug.title} (${c.why.bug.id})`;
240
+ add(c.id, level, text, c.statement, c.files[0]);
241
+ }
242
+ for (const n of r.near) {
243
+ add(n.id, "note", `[${n.severity}] near via blast radius: ${n.statement}${n.via[0] ? `\nvia ${n.via[0]}` : ""}`, n.statement);
244
+ }
245
+ for (const h of r.regressions) {
246
+ add(h.decision, h.blocking ? "error" : "warning", `re-adds ${h.kind} \`${h.name}\` — ${h.decision} deliberately removed it: “${h.title}”. ${h.reason}`, h.title);
247
+ }
248
+ for (const v of r.vetoes) {
249
+ add(v.decision, v.blocking ? "error" : "warning", `reverses rejected approach: you rejected “${clip(v.alternative)}”; you chose “${clip(v.chosen)}” (${v.decision})`, v.title);
250
+ }
251
+ for (const x of r.redundant) {
252
+ add("hunch/redundant-symbol", "note", `adds ${x.kind} \`${x.name}\` — already defined in ${x.existingFile}`, "Possibly re-implements an existing symbol", x.existingFile);
253
+ }
254
+ for (const c of extras.conformance ?? []) {
255
+ let text = `architectural conformance violated: ${c.detail} (“${c.title}”)`;
256
+ if (c.why)
257
+ text += `\nwhy: ${c.why}`;
258
+ if (c.bug)
259
+ text += `\nprevents recurrence of: ${c.bug}`;
260
+ add(c.decision, "error", text, c.title);
261
+ }
262
+ for (const p of extras.policies ?? []) {
263
+ const level = p.blocks || p.gateError ? "error" : p.result === "satisfied" ? "note" : "warning";
264
+ let text = `policy ${p.result}${p.blocks ? " (authorized block)" : ""}: ${p.explanation}\nreceipt: ${p.receipt}`;
265
+ if (p.gateError)
266
+ text += `\ngate error: ${p.gateError}`;
267
+ add(p.id, level, text, `Constitution policy ${p.id}`);
268
+ }
269
+ for (const s of extras.scanIssues ?? []) {
270
+ add("hunch/incomplete-scan", "error", `semantic source scan rejected ${s.path}: ${s.detail} [${s.code}] — an omitted file could hide a violation, so strict fails closed`, "Incomplete semantic source scan", s.path);
271
+ }
272
+ const doc = {
273
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
274
+ version: "2.1.0",
275
+ runs: [{
276
+ tool: {
277
+ driver: {
278
+ name: "hunch",
279
+ informationUri: "https://github.com/davesheffer/hunch",
280
+ version,
281
+ rules: [...rules.values()],
282
+ },
283
+ },
284
+ results,
285
+ }],
286
+ };
287
+ return JSON.stringify(doc, null, 2);
288
+ }
214
289
  //# sourceMappingURL=checkreport.js.map
@@ -16,6 +16,7 @@ import { existsSync, readFileSync } from "node:fs";
16
16
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
17
17
  import { toPosixTarget } from "./paths.js";
18
18
  import { currentForTopic, isLive } from "./topics.js";
19
+ import { evaluatePremises } from "./premises.js";
19
20
  import { parseDocAnchors } from "./docanchors.js";
20
21
  import { markdownDocs, STALE_MARKER, SRC_REF } from "./docscan.js";
21
22
  import { computeWikiDrift } from "../wiki/wiki.js";
@@ -28,6 +29,7 @@ export function computeDrift(store, root) {
28
29
  // anchor-stale. Keeps the doc≠graph gate's false-positive rate ~zero: a routine
29
30
  // narrowing supersession (successor lists fewer files) never flags files still governed.
30
31
  const liveFiles = new Set(decisions.filter(isLive).flatMap((d) => (d.related_files ?? []).map(toPosixTarget)));
32
+ const premiseEnv = { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) };
31
33
  for (const d of decisions) {
32
34
  // 1. DEAD-REFERENCE — only for in-force decisions; a superseded one referencing
33
35
  // a since-deleted file is legitimate history, not drift.
@@ -76,6 +78,18 @@ export function computeDrift(store, root) {
76
78
  }
77
79
  }
78
80
  }
81
+ // 7. PREMISE-STALE (world≠graph) — the decision's recorded REASON no longer
82
+ // holds while code and docs may be perfectly in sync. Advisory like every
83
+ // kind here, and authority is NEVER changed by a dead premise — the
84
+ // escalation surface asks the human (a self-relaxing gate could be
85
+ // disarmed by the very actor it guards against).
86
+ if (isLive(d) && d.premises?.length) {
87
+ for (const v of evaluatePremises(d, premiseEnv)) {
88
+ if (!v.holds) {
89
+ findings.push({ kind: "premise-stale", id: d.id, detail: `premise "${v.claim}" no longer holds — ${v.reason}. Re-attest, supersede, or retire; authority unchanged until a human decides.` });
90
+ }
91
+ }
92
+ }
79
93
  }
80
94
  // One markdown pass feeds both prose checks (3 + 5): read each doc once.
81
95
  for (const doc of markdownDocs(root)) {
@@ -0,0 +1,72 @@
1
+ import { isLive } from "./topics.js";
2
+ const clip = (s, n = 90) => (s.length > n ? s.slice(0, n - 1).trimEnd() + "…" : s);
3
+ /** Repo-relative only — a premise check is not an escape hatch into arbitrary
4
+ * local files (same containment rule as private doc references). */
5
+ function validRel(p) {
6
+ return !!p && !p.startsWith("/") && !/^[A-Za-z]:[\\/]/.test(p) && !p.split(/[\\/]/).includes("..");
7
+ }
8
+ function checkPath(claim, rel, wantExists, env) {
9
+ if (!validRel(rel)) {
10
+ return { claim, holds: false, reason: `unevaluable: path must be repo-relative ("${rel}") — fix the premise record` };
11
+ }
12
+ const exists = env.exists(rel);
13
+ if (wantExists) {
14
+ return exists
15
+ ? { claim, holds: true, reason: `"${rel}" still exists` }
16
+ : { claim, holds: false, reason: `"${rel}" no longer exists` };
17
+ }
18
+ return exists
19
+ ? { claim, holds: false, reason: `"${rel}" now exists` }
20
+ : { claim, holds: true, reason: `"${rel}" still absent` };
21
+ }
22
+ function checkOne(p, env) {
23
+ if (p.path_absent !== undefined)
24
+ return checkPath(p.claim, p.path_absent, false, env);
25
+ if (p.path_exists !== undefined)
26
+ return checkPath(p.claim, p.path_exists, true, env);
27
+ if (p.review_by !== undefined) {
28
+ const due = Date.parse(p.review_by);
29
+ if (!Number.isFinite(due)) {
30
+ return { claim: p.claim, holds: false, reason: `unevaluable: review_by is not a date ("${p.review_by}") — fix the premise record` };
31
+ }
32
+ if (Date.parse(env.now) > due) {
33
+ const attested = p.attested ? ` (last attested ${p.attested.slice(0, 10)})` : "";
34
+ return { claim: p.claim, holds: false, reason: `attestation expired ${p.review_by.slice(0, 10)}${attested} — needs a human re-attest` };
35
+ }
36
+ return { claim: p.claim, holds: true, reason: `attested until ${p.review_by.slice(0, 10)}` };
37
+ }
38
+ // Claim-only: documents the reason, can never fire. Deliberately allowed —
39
+ // an honest unwatchable premise beats a fake check.
40
+ return { claim: p.claim, holds: true, reason: "documented only (no check attached)" };
41
+ }
42
+ /** Evaluate every recorded premise of one decision. Empty when none recorded. */
43
+ export function evaluatePremises(d, env) {
44
+ return (d.premises ?? []).map((p) => checkOne(p, env));
45
+ }
46
+ /** One inline escalation per LIVE decision with ≥1 dead premise — the question
47
+ * a human must answer, framed with its resolution verbs. Rejected alternatives
48
+ * are MENTIONED (count only), never re-litigated into agent context: reopening
49
+ * a settled rejection is a human call, and consistency alone is a valid reason
50
+ * to keep a decision whose original premise died. */
51
+ export function premiseEscalations(decisions, env) {
52
+ const out = [];
53
+ for (const d of decisions) {
54
+ if (!isLive(d) || !d.premises?.length)
55
+ continue;
56
+ const dead = evaluatePremises(d, env).filter((v) => !v.holds);
57
+ if (!dead.length)
58
+ continue;
59
+ const rejected = d.alternatives_rejected.length;
60
+ out.push({
61
+ kind: "premise-stale",
62
+ topic: d.topic ?? d.id,
63
+ decisionIds: [d.id],
64
+ question: `Decision "${clip(d.title)}" (${d.id}) rests on ${dead.length} premise(s) that no longer hold — is it still right?`,
65
+ detail: dead.map((v) => `"${clip(v.claim, 80)}" — ${v.reason}`).join(" · ")
66
+ + (rejected ? ` · ${rejected} rejected alternative(s) on record — reopening them is your call, and keeping the decision for consistency is a valid answer` : ""),
67
+ resolution: "re-attest (update the premise's review_by/attested), supersede via /capture, or retire the decision — its authority is UNCHANGED until you decide.",
68
+ });
69
+ }
70
+ return out;
71
+ }
72
+ //# sourceMappingURL=premises.js.map
@@ -78,7 +78,17 @@ export function renderGrounding(fileDecisions, allDecisions = fileDecisions) {
78
78
  const disputed = [...new Set(anchored.filter((d) => contested.has(d.topic)).map((d) => d.topic))].sort();
79
79
  const lines = settled.map((d) => {
80
80
  const rej = d.alternatives_rejected.length ? ` (rejected: ${d.alternatives_rejected.join("; ")})` : "";
81
- return `• "${d.topic}": ${d.decision || d.title} [${d.id}]${rej}`;
81
+ // Memory supply chain: an agent-recorded decision (no capture interview, no
82
+ // human countersign) is TESTIMONY. It still grounds — but never with the same
83
+ // voice as a human-confirmed record, because this block is delivered with
84
+ // doc-precedence framing ("follow the graph") and would otherwise launder an
85
+ // unvouched write into the most trusted context the next agent sees.
86
+ // Token-aware match (mirrors strictgate.isHumanConfirmed; not imported — that
87
+ // module imports this one).
88
+ const testimony = d.provenance.source.split("+").includes("agent_recorded")
89
+ ? " — ⚠ agent-recorded testimony, no human countersign yet (/capture confirms it)"
90
+ : "";
91
+ return `• "${d.topic}": ${d.decision || d.title} [${d.id}]${rej}${testimony}`;
82
92
  });
83
93
  // Name the conflict instead of silently dropping it: an unexplained absence would read
84
94
  // as "nothing is recorded here", which is how a contested topic gets re-decided by
@@ -106,6 +106,22 @@ export const ConformancePredicateSchema = z.object({
106
106
  object: z.string().optional().describe("required (calls/imports) or forbidden (not-*) target"),
107
107
  transitive: z.boolean().default(false).describe("allow an indirect path over the dependency graph"),
108
108
  });
109
+ // A premise: the WHY under the decision, as a checkable record. Decisions decay
110
+ // when their REASONS die, not (only) when code changes — a premise makes one
111
+ // recorded reason watchable. Exactly one check per premise (or none: claim-only
112
+ // premises document the reason but can never fire). Checks are deterministic and
113
+ // explicit — path presence or a dated human attestation — never semantic guesses.
114
+ // A failing premise NEVER changes authority; it only raises an inline escalation
115
+ // (the human renews, supersedes, or retires — same ethos as topic anchors).
116
+ export const PremiseSchema = z.object({
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"),
119
+ path_exists: z.string().optional().describe("premise holds while this repo-relative path exists"),
120
+ review_by: z.string().optional().describe("dated attestation: premise holds until this ISO date, then needs re-attesting"),
121
+ attested: z.string().optional().describe("ISO date a human last attested the claim (informational)"),
122
+ }).refine((p) => [p.path_absent, p.path_exists, p.review_by].filter((x) => x !== undefined).length <= 1, {
123
+ message: "a premise carries at most one check (path_absent | path_exists | review_by)",
124
+ });
109
125
  export const DecisionSchema = z.object({
110
126
  id: z.string().describe("dec_*"),
111
127
  title: z.string(),
@@ -137,6 +153,12 @@ export const DecisionSchema = z.object({
137
153
  valid_to: z.string().nullable().default(null).describe("ISO instant it was superseded (null = in force)"),
138
154
  retired: RetiredSignalSchema.default({ symbols: [], deps: [] }),
139
155
  conformance: z.array(ConformancePredicateSchema).optional().describe("deterministic intent-conformance checks over the graph"),
156
+ // Optional like `topic`/`conformance`: absent = today's behavior exactly (no
157
+ // migration, no new burden). Intended to be RARE — blocking constraints and
158
+ // contested decisions, not every record (attestation fatigue kills reminder
159
+ // systems). A decision with premises is "conditioned on [these]", never
160
+ // "verified valid" — the system watches recorded reasons only.
161
+ premises: z.array(PremiseSchema).optional().describe("the checkable reasons this decision rests on; a dead premise escalates, never auto-relaxes"),
140
162
  provenance: ProvenanceSchema,
141
163
  date: z.string(),
142
164
  });
@@ -33,6 +33,7 @@ import { HUNCH_VERSION } from "../core/version.js";
33
33
  import { assertCompleteRepoScan, indexRepo, scanRepo } from "../extractors/indexer.js";
34
34
  import { liveForTopic, historyForTopic, rejectedForTopic, captureConflicts } from "../core/topics.js";
35
35
  import { pendingEscalations, policyEscalations } from "../core/escalations.js";
36
+ import { premiseEscalations } from "../core/premises.js";
36
37
  import { issueCaptureToken as issueToken, consumeCaptureToken as consumeToken } from "../core/capturetoken.js";
37
38
  import { randomUUID } from "node:crypto";
38
39
  import { existsSync } from "node:fs";
@@ -607,6 +608,7 @@ export function buildServerWithRootControl(initialRoot) {
607
608
  if (pendingReview > 0)
608
609
  L.push("", `${pendingReview} legacy un-vouched draft(s) — \`hunch adopt-drafts\` auto-trusts them as advisory (new captures land trusted automatically).`);
609
610
  const escalations = pendingEscalations(store.advisoryRecs("decisions"));
611
+ escalations.push(...premiseEscalations(store.advisoryRecs("decisions"), { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) }));
610
612
  if (escalations.length) {
611
613
  L.push("", `⚖ ${escalations.length} decision(s) need the human's call — ASK inline (never queue): ${escalations.map((e) => e.question).join(" · ")}`);
612
614
  }
@@ -621,10 +623,13 @@ export function buildServerWithRootControl(initialRoot) {
621
623
  // (con_e04226bd05): no Claude-specific behavior.
622
624
  server.registerTool("hunch_escalations", {
623
625
  title: "Decisions the human must make now (ask inline, not a queue)",
624
- description: "The rare decisions the graph cannot resolve on its own — surfaced so you ASK THE USER in the prompt at the moment, then act. Auto-captured memory is trusted automatically and never appears here; this returns topic conflicts (>1 live decision for one topic) and Constitution human moments (candidate policies awaiting review, proposed policies awaiting an activation decision). Normally empty. Raise each question with the user; do NOT decide it for them — an entry is a question, never an approval. Reads the public store, or the unified overlay when the repo is in shared mode (where the overlay IS the store) — never private-mode overlay records.",
626
+ description: "The rare decisions the graph cannot resolve on its own — surfaced so you ASK THE USER in the prompt at the moment, then act. Auto-captured memory is trusted automatically and never appears here; this returns topic conflicts (>1 live decision for one topic), premise-stale decisions (a live decision whose recorded REASON no longer holds — its authority is unchanged until the human re-attests, supersedes, or retires), and Constitution human moments (candidate policies awaiting review, proposed policies awaiting an activation decision). Normally empty. Raise each question with the user; do NOT decide it for them — an entry is a question, never an approval. Reads the public store, or the unified overlay when the repo is in shared mode (where the overlay IS the store) — never private-mode overlay records.",
625
627
  inputSchema: {},
626
628
  }, async () => {
627
629
  const items = pendingEscalations(store.advisoryRecs("decisions"));
630
+ // Premise decay: a live decision whose recorded reason died. Question-framed
631
+ // like every entry here — authority never changes until the human answers.
632
+ items.push(...premiseEscalations(store.advisoryRecs("decisions"), { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) }));
628
633
  try {
629
634
  items.push(...policyEscalations(new ConstitutionService(store, root).list({ publicOnly: true }).map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
630
635
  }
@@ -726,6 +731,14 @@ export function buildServerWithRootControl(initialRoot) {
726
731
  related_files: z.array(z.string()).optional(),
727
732
  related_components: z.array(z.string()).optional(),
728
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
+ 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."),
729
742
  status: z.enum(["proposed", "accepted", "rejected", "superseded"]).optional(),
730
743
  commit: z.string().optional(),
731
744
  supersedes: z.string().optional().describe("id of a decision this one replaces — closes its valid-time window (invalidate, don't delete)"),
@@ -758,17 +771,51 @@ export function buildServerWithRootControl(initialRoot) {
758
771
  const sameHumanIdentity = existing?.topic && decision.topic
759
772
  ? decision.topic === existing.topic
760
773
  : existing?.title === decision.title;
761
- const conflictsWithHuman = !!existing?.provenance.source.includes("human_confirmed")
762
- && !sameHumanIdentity;
774
+ // CURATED slots are overwrite-protected, not just human-confirmed ones:
775
+ // agent_recorded testimony carries session content a silent replace would
776
+ // destroy (issue #23's harm, one tier down). Only regenerable machine
777
+ // drafts (llm_draft/inferred synthesis, deterministically re-derivable
778
+ // from the commit) stay upgradeable by a different identity. Same-identity
779
+ // re-record remains the countersign/refine path for every tier.
780
+ const curated = ["human_confirmed", "agent_recorded"].some((t) => existing?.provenance.source.split("+").includes(t));
781
+ // AUTHORSHIP STAMP (memory supply chain): only a consumed capture token — proof a
782
+ // grilling interview preceded this write — mints human_confirmed. Any agent can
783
+ // CALL this tool mid-session, possibly steered by untrusted content it read;
784
+ // "the human probably asked me to" is testimony, not a signature.
785
+ //
786
+ // Resolved HERE, before the overwrite guard, because the guard's answer depends on
787
+ // it: testimony must yield to a signature. (Consuming before a possible refusal
788
+ // burns the token, which is the safe direction — a re-run of /capture mints another.)
789
+ const gated = consumeCaptureToken(capture_token);
790
+ const existingTiers = existing?.provenance.source.split("+") ?? [];
791
+ const existingIsHuman = existingTiers.includes("human_confirmed");
792
+ // A slot held only by AGENT TESTIMONY must not block a later human capture — the
793
+ // stamp's own contract says so ("never lock the id slot against a later human
794
+ // capture"), but including agent_recorded in `curated` did exactly that. A
795
+ // human_confirmed slot stays protected as before (issue #23): a signature is never
796
+ // displaced by a differently-identified record, vouched or not.
797
+ const conflictsWithHuman = curated && !sameHumanIdentity && !(gated && !existingIsHuman);
763
798
  if (conflictsWithHuman) {
764
- return err(`Decision id ${id} already identifies a different human-confirmed decision: ` +
799
+ return err(`Decision id ${id} already identifies a different curated decision: ` +
765
800
  `"${existing.title}"${existing.topic ? ` (topic "${existing.topic}")` : ""}. ` +
766
801
  `Refusing to overwrite it with "${decision.title}"${decision.topic ? ` (topic "${decision.topic}")` : ""}. ` +
767
802
  "Record the additional decision without commit, or reuse the incumbent topic/title when refining the same decision.");
768
803
  }
804
+ // Un-token'd writes land as agent_recorded: fully functional advisory memory that
805
+ // never carries human authority (strict/veto gates key on human_confirmed) and
806
+ // surfaces with a testimony marker. Re-record through /capture to countersign.
807
+ //
808
+ // A signature already on this slot is INHERITED, never erased. The un-token'd path
809
+ // is exactly what the nudge below tells an agent to do ("re-record… supersedes"),
810
+ // and the tier expression preserved `llm_draft` while dropping `human_confirmed` —
811
+ // so an un-vouched agent write silently stripped human authority from a decision a
812
+ // human had vouched for. That inverts the whole point of the stamp: it exists to
813
+ // stop an agent CLAIMING human authority, not to let one DESTROY it. Downgrading a
814
+ // signature is a human act (`hunch review --reject`, or supersede via /capture).
815
+ const tier = gated || existingIsHuman ? "human_confirmed" : "agent_recorded";
769
816
  const source = existing && existing.provenance.source.includes("llm_draft")
770
- ? "llm_draft+human_confirmed"
771
- : "human_confirmed";
817
+ ? `llm_draft+${tier}`
818
+ : tier;
772
819
  const now = new Date().toISOString();
773
820
  const rec = {
774
821
  id,
@@ -780,6 +827,14 @@ export function buildServerWithRootControl(initialRoot) {
780
827
  consequences: decision.consequences ?? [],
781
828
  alternatives_rejected: decision.alternatives_rejected ?? [],
782
829
  rejected_tripwires: existing?.rejected_tripwires ?? [], // preserve confirmed tripwires across re-record
830
+ // Premises survive a re-record for the same reason tripwires do. Rebuilding the
831
+ // record field-by-field WITHOUT them silently deleted the decision's recorded
832
+ // reasons — so the escalation for a dead premise stopped firing while the
833
+ // decision kept full authority, and nothing reported the loss. That is the exact
834
+ // fail-open premise decay exists to prevent, relocated from the evaluator to the
835
+ // writer — and the escalation's own advice ("re-attest… or re-record") walked
836
+ // straight into it. Caller-supplied premises win; otherwise the incumbent's carry.
837
+ premises: decision.premises ?? existing?.premises ?? [],
783
838
  related_components: decision.related_components ?? existing?.related_components ?? [],
784
839
  related_files: (decision.related_files ?? existing?.related_files ?? []).map(toPosixTarget),
785
840
  supersedes: decision.supersedes ?? existing?.supersedes ?? null,
@@ -789,7 +844,7 @@ export function buildServerWithRootControl(initialRoot) {
789
844
  valid_from: existing?.valid_from ?? now,
790
845
  valid_to: existing?.valid_to ?? null,
791
846
  retired: existing?.retired ?? { symbols: [], deps: [] },
792
- provenance: { source, confidence: 0.95, evidence: (decision.related_files ?? existing?.provenance.evidence ?? []).map(toPosixTarget) },
847
+ provenance: { source, confidence: gated ? 0.95 : 0.75, evidence: (decision.related_files ?? existing?.provenance.evidence ?? []).map(toPosixTarget) },
793
848
  date: now,
794
849
  };
795
850
  // Where this write will actually land (see captureHome). Resolved BEFORE the
@@ -843,16 +898,17 @@ export function buildServerWithRootControl(initialRoot) {
843
898
  // (commit only — it rides the user's next push, never auto-pushing their code branch).
844
899
  const flush = flushCapture(store, hunchPaths(root).hunch, !!decision.private, `hunch: capture ${id}`, startupTeamRoute ?? undefined);
845
900
  const flushed = flushNote(flush, home, store.mode);
846
- // Capture-session gate (staged deprecation, §9.3): a token proves an interview
847
- // preceded the write. No token still writes (non-breaking), but returns a nudge
848
- // toward /capture so the un-interviewed bypass is visible, not silent. A token
849
- // presented but unknown to THIS process (server restart/expiry) is not shamed.
850
- const gated = consumeCaptureToken(capture_token);
901
+ // Capture-session gate (staged deprecation, §9.3): the token was consumed
902
+ // above (it also decides the provenance tier). No token still writes
903
+ // (non-breaking) but lands as agent_recorded with a nudge toward /capture.
904
+ // A token presented but unknown to THIS process (server restart/expiry) is
905
+ // not shamed — but it also cannot be VERIFIED, so the record still lands
906
+ // agent_recorded with a note saying how to countersign.
851
907
  const captureNote = gated
852
908
  ? " [via capture front door]"
853
909
  : capture_token
854
- ? ""
855
- : `\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.)`;
910
+ ? `\n\nℹ The capture token could not be verified (server restart or expiry), so this record is stamped agent_recorded. Re-record through hunch_capture_decision → hunch_record_decision to countersign it as human_confirmed.`
911
+ : `\n\n⚠ Recorded WITHOUT a capture interview — the record stands as agent_recorded TESTIMONY (advisory: it never carries human authority; a /capture interview on the same topic/title countersigns it). 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 a /capture interview (hunch_capture_decision → hunch_record_decision(supersedes: ${id})), which countersigns the record as human_confirmed. (A future major version will require a capture token here.)`;
856
912
  // Quality nudge only when the untokened deprecation nudge isn't already
857
913
  // grilling — one advisory voice per response, never two.
858
914
  const quality = gated || capture_token ? qualityNudge(rec) : "";
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.10.6",
3
+ "version": "1.10.7",
4
+ "mcpName": "io.github.davesheffer/hunch",
4
5
  "license": "Apache-2.0",
5
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
6
7
  "description": "Engineering memory and a deterministic Change Gate for AI-assisted codebases: decisions, rejected approaches, constraints, and bug lineage become portable context and opt-in enforcement for every MCP assistant.",
@@ -18,6 +19,7 @@
18
19
  },
19
20
  "files": [
20
21
  "dist/**/*.js",
22
+ "server.json",
21
23
  "bench/constitution-exp03-v1.json",
22
24
  "tooling/competitive-watch.mjs",
23
25
  "tooling/md1-benchmark.mjs",
package/server.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
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.",
5
+ "repository": {
6
+ "url": "https://github.com/davesheffer/hunch",
7
+ "source": "github"
8
+ },
9
+ "websiteUrl": "https://hunch-pi.vercel.app",
10
+ "version": "1.10.7",
11
+ "packages": [
12
+ {
13
+ "registryType": "npm",
14
+ "registryBaseUrl": "https://registry.npmjs.org",
15
+ "identifier": "@davesheffer/hunch",
16
+ "version": "1.10.7",
17
+ "runtimeHint": "npx",
18
+ "packageArguments": [
19
+ {
20
+ "type": "positional",
21
+ "value": "mcp",
22
+ "description": "Start the Hunch MCP server (stdio) for the current repository."
23
+ }
24
+ ],
25
+ "transport": {
26
+ "type": "stdio"
27
+ }
28
+ }
29
+ ]
30
+ }
@@ -18,16 +18,29 @@ const distinctivePhrases = [
18
18
  "deterministic Change Gate for AI-assisted codebases",
19
19
  ];
20
20
 
21
- const token = process.env.GITHUB_TOKEN?.trim();
22
- const headers = {
23
- Accept: "application/vnd.github+json",
24
- "User-Agent": "hunch-competitive-watch",
25
- "X-GitHub-Api-Version": "2022-11-28",
26
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
27
- };
21
+ let token = process.env.GITHUB_TOKEN?.trim();
22
+ // A rejected token must DEGRADE the run, never kill it: ambient CI/proxy tokens
23
+ // are often invalid for api.github.com, and the metadata half of this watch
24
+ // works unauthenticated. Flipped on the first 401 so every later call skips
25
+ // the bad credential instead of failing eight times.
26
+ let tokenRejected = false;
27
+
28
+ function headers() {
29
+ return {
30
+ Accept: "application/vnd.github+json",
31
+ "User-Agent": "hunch-competitive-watch",
32
+ "X-GitHub-Api-Version": "2022-11-28",
33
+ ...(token && !tokenRejected ? { Authorization: `Bearer ${token}` } : {}),
34
+ };
35
+ }
28
36
 
29
37
  async function github(path) {
30
- const response = await fetch(`https://api.github.com${path}`, { headers });
38
+ let response = await fetch(`https://api.github.com${path}`, { headers: headers() });
39
+ if (response.status === 401 && token && !tokenRejected) {
40
+ tokenRejected = true;
41
+ process.stderr.write("competitive-watch: GITHUB_TOKEN rejected (401) — continuing unauthenticated; phrase search will be skipped.\n");
42
+ response = await fetch(`https://api.github.com${path}`, { headers: headers() });
43
+ }
31
44
  if (!response.ok) {
32
45
  const detail = (await response.text()).slice(0, 300).replaceAll("\n", " ");
33
46
  throw new Error(`GitHub ${response.status} for ${path}: ${detail}`);
@@ -36,20 +49,26 @@ async function github(path) {
36
49
  }
37
50
 
38
51
  async function repoSnapshot(repo) {
39
- const data = await github(`/repos/${repo}`);
40
- return {
41
- repo,
42
- created: data.created_at,
43
- pushed: data.pushed_at,
44
- stars: data.stargazers_count,
45
- forks: data.forks_count,
46
- issues: data.open_issues_count,
47
- url: data.html_url,
48
- };
52
+ // One vanished/renamed repo is itself a signal worth a row — never a reason
53
+ // to lose the other seven.
54
+ try {
55
+ const data = await github(`/repos/${repo}`);
56
+ return {
57
+ repo,
58
+ created: data.created_at,
59
+ pushed: data.pushed_at,
60
+ stars: data.stargazers_count,
61
+ forks: data.forks_count,
62
+ issues: data.open_issues_count,
63
+ url: data.html_url,
64
+ };
65
+ } catch (error) {
66
+ return { repo, error: error instanceof Error ? error.message : String(error) };
67
+ }
49
68
  }
50
69
 
51
70
  async function phraseSnapshot(phrase) {
52
- const query = encodeURIComponent(`\"${phrase}\"`);
71
+ const query = encodeURIComponent(`"${phrase}"`);
53
72
  const data = await github(`/search/code?q=${query}&per_page=100`);
54
73
  const external = data.items
55
74
  .filter((item) => !item.repository.full_name.startsWith("davesheffer/"))
@@ -72,14 +91,25 @@ function render(snapshot, phrases) {
72
91
  ];
73
92
 
74
93
  for (const item of snapshot) {
94
+ if (item.error) {
95
+ lines.push(`| ${item.repo} | — | — | — | — | — |`);
96
+ continue;
97
+ }
75
98
  lines.push(
76
99
  `| [${item.repo}](${item.url}) | ${item.created.slice(0, 10)} | ${item.pushed.slice(0, 10)} | ${item.stars} | ${item.forks} | ${item.issues} |`,
77
100
  );
78
101
  }
102
+ const failed = snapshot.filter((item) => item.error);
103
+ if (failed.length) {
104
+ lines.push("");
105
+ for (const item of failed) lines.push(`- ⚠ ${item.repo}: ${item.error}`);
106
+ }
79
107
 
80
108
  lines.push("", "## Distinctive phrase search", "");
81
109
  if (!token) {
82
110
  lines.push("Skipped: set `GITHUB_TOKEN` to enable authenticated GitHub code search.");
111
+ } else if (tokenRejected) {
112
+ lines.push("Skipped: `GITHUB_TOKEN` was rejected by api.github.com (401) — repo metadata above was collected unauthenticated. Provide a valid token (e.g. `GITHUB_TOKEN=\"$(gh auth token)\"`) to run the phrase-copy check.");
83
113
  } else {
84
114
  for (const result of phrases) {
85
115
  lines.push(`- **${result.phrase}** — ${result.external.length} external indexed match(es)`);
@@ -97,8 +127,11 @@ function render(snapshot, phrases) {
97
127
  }
98
128
 
99
129
  try {
100
- const snapshot = await Promise.all(repos.map(repoSnapshot));
101
- const phrases = token
130
+ const snapshot = [];
131
+ // Sequential on purpose: the first call settles token validity before the
132
+ // rest, and unauthenticated rate limits are too small to burst against.
133
+ for (const repo of repos) snapshot.push(await repoSnapshot(repo));
134
+ const phrases = token && !tokenRejected
102
135
  ? await Promise.all(distinctivePhrases.map(phraseSnapshot))
103
136
  : [];
104
137
  process.stdout.write(render(snapshot, phrases));