@davesheffer/hunch 0.5.0 → 0.9.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.
@@ -4,6 +4,7 @@ import { RESET_SQL, embedHash } from "./schema.js";
4
4
  import { selectEmbedder } from "./embedder.js";
5
5
  import { JsonStore } from "./jsonStore.js";
6
6
  import { pathMatchesGlob } from "../core/glob.js";
7
+ import { edgeId } from "../core/ids.js";
7
8
  export class HunchStore {
8
9
  paths;
9
10
  json;
@@ -273,13 +274,17 @@ export class HunchStore {
273
274
  return [...acc.values()].sort((a, b) => b.score - a.score).slice(0, limit).map((e) => ({ ...e.hit, score: e.score }));
274
275
  }
275
276
  /** All decisions/bugs/constraints/symbols/components touching a file path or
276
- * symbol name (hunch_why). */
277
- why(target) {
277
+ * symbol name (hunch_why). Pass `{ asOf }` (an ISO instant) to TIME-TRAVEL:
278
+ * return only decisions/constraints whose valid-time window contained that
279
+ * instant — "what did we believe as of commit X?". Omit `asOf` for the full,
280
+ * history-inclusive view (backward-compatible default). */
281
+ why(target, opts = {}) {
278
282
  const decisions = this.json.loadAll("decisions");
279
283
  const bugs = this.json.loadAll("bugs");
280
284
  const constraints = this.json.loadAll("constraints");
281
285
  const symbols = this.json.loadAll("symbols");
282
286
  const components = this.json.loadAll("components");
287
+ const asOf = opts.asOf;
283
288
  const matchedSymbols = symbols.filter((s) => s.file === target || s.name === target || s.id === target || s.file.endsWith(target));
284
289
  const symIds = new Set(matchedSymbols.map((s) => s.id));
285
290
  const fileSet = new Set(matchedSymbols.map((s) => s.file));
@@ -287,9 +292,11 @@ export class HunchStore {
287
292
  const fileMatch = (files) => files.some((f) => f === target || (isPath && (f.endsWith(target) || target.endsWith(f))) || fileSet.has(f));
288
293
  return {
289
294
  target,
290
- decisions: decisions.filter((d) => fileMatch(d.related_files) || d.related_components.some((c) => components.find((x) => x.id === c && fileMatch(x.paths)))),
295
+ decisions: decisions.filter((d) => (fileMatch(d.related_files) || d.related_components.some((c) => components.find((x) => x.id === c && fileMatch(x.paths))))
296
+ && inWindow(d.valid_from, d.valid_to, asOf)),
291
297
  bugs: bugs.filter((b) => fileMatch(b.affected_files) || b.affected_symbols.some((s) => symIds.has(s))),
292
- constraints: constraints.filter((c) => c.scope.some((g) => pathMatchesGlob(target, g) || [...fileSet].some((f) => pathMatchesGlob(f, g)))),
298
+ constraints: constraints.filter((c) => c.scope.some((g) => pathMatchesGlob(target, g) || [...fileSet].some((f) => pathMatchesGlob(f, g)))
299
+ && inWindow(c.valid_from, c.valid_to, asOf)),
293
300
  symbols: matchedSymbols,
294
301
  components: components.filter((c) => c.paths.some((g) => pathMatchesGlob(target, g) || [...fileSet].some((f) => pathMatchesGlob(f, g)))),
295
302
  };
@@ -357,13 +364,114 @@ export class HunchStore {
357
364
  }
358
365
  return [...out.values()].sort((a, b) => a.depth - b.depth || a.file.localeCompare(b.file));
359
366
  }
360
- /** Constraints whose scope glob matches a path/glob (hunch_check_constraints). */
361
- checkConstraints(scope) {
367
+ /** Constraints whose scope glob matches a path/glob (hunch_check_constraints).
368
+ * By default only ACTIVE invariants are returned — a retired constraint is no
369
+ * longer enforced. Pass `{ asOf }` to instead return the invariants in force at
370
+ * that instant (time-travel: "what must I not have broken as of commit X?"). */
371
+ checkConstraints(scope, opts = {}) {
362
372
  const all = this.json.loadAll("constraints");
373
+ const asOf = opts.asOf;
363
374
  return all
364
375
  .filter((c) => c.scope.some((g) => pathMatchesGlob(scope, g) || pathMatchesGlob(g, scope) || g === scope))
376
+ .filter((c) => (asOf ? inWindow(c.valid_from, c.valid_to, asOf) : c.status !== "retired"))
365
377
  .sort((a, b) => sev(b.severity) - sev(a.severity));
366
378
  }
379
+ /** Time-travel: the decision history for a target — every decision touching it,
380
+ * newest-first, with its valid-time window and supersession links. Answers
381
+ * "what did we believe, and when/why did it change?" (hunch_timeline). */
382
+ timeline(target) {
383
+ return this.why(target).decisions.sort((a, b) => (b.valid_from ?? b.date).localeCompare(a.valid_from ?? a.date));
384
+ }
385
+ /** Invalidate, don't delete (Zep edge-invalidation): close `oldId`'s valid-time
386
+ * window at the superseding decision's `valid_from`, mark it superseded + linked,
387
+ * and write a `supersedes` edge. Returns the updated old decision, or null if it
388
+ * doesn't exist. All writes are atomic via json.put (con_902759b3dc). */
389
+ supersede(oldId, by) {
390
+ const old = this.json.get("decisions", oldId);
391
+ if (!old || old.id === by.id)
392
+ return null;
393
+ const closed = {
394
+ ...old,
395
+ status: "superseded",
396
+ superseded_by: by.id,
397
+ valid_to: old.valid_to ?? by.valid_from ?? null,
398
+ };
399
+ this.json.put("decisions", closed);
400
+ const edge = {
401
+ id: edgeId(by.id, oldId, "supersedes"),
402
+ from: by.id,
403
+ to: oldId,
404
+ type: "supersedes",
405
+ reason: `${by.id} supersedes ${oldId}`,
406
+ strength: 1,
407
+ provenance: { source: "derived", confidence: 1, evidence: [by.id, oldId] },
408
+ };
409
+ this.json.put("edges", edge);
410
+ return closed;
411
+ }
412
+ /** Regression Guard: detect a change RE-INTRODUCING something an in-force
413
+ * decision deliberately removed. Matches the added symbols/deps of a diff
414
+ * against the `retired` signal of decisions concerning the touched files. A hit
415
+ * is `blocking` when the retiring decision is tied to an ACTIVE blocking
416
+ * constraint (via source_decision) — that's the only case the strict guard
417
+ * fails the commit on; everything else is an advisory warning. */
418
+ regressionHits(added, files) {
419
+ const addedSyms = new Set(added.symbols);
420
+ const addedDeps = new Set(added.deps);
421
+ if (!addedSyms.size && !addedDeps.size)
422
+ return [];
423
+ const fileRelevant = (related) => related.some((f) => files.some((x) => pathRelated(x, f)));
424
+ const decisions = this.json.loadAll("decisions");
425
+ // decisions tied to an active blocking constraint via source_decision
426
+ const blockingDec = new Set(this.json.loadAll("constraints")
427
+ .filter((c) => c.severity === "blocking" && c.status !== "retired" && c.source_decision)
428
+ .map((c) => c.source_decision));
429
+ const out = [];
430
+ const seen = new Set(); // dedup by kind+name: report each resurrected item once
431
+ const add = (d, kind, name) => {
432
+ const key = `${kind}:${name}`;
433
+ if (seen.has(key))
434
+ return;
435
+ seen.add(key);
436
+ out.push({ decision: d.id, title: d.title, kind, name, blocking: blockingDec.has(d.id), reason: d.decision || d.title });
437
+ };
438
+ // Blocking-linked decisions first, so a deduped hit keeps the higher-severity
439
+ // attribution (the strict guard fails on `blocking`).
440
+ const ordered = [...decisions].sort((a, b) => Number(blockingDec.has(b.id)) - Number(blockingDec.has(a.id)));
441
+ for (const d of ordered) {
442
+ // Only IN-FORCE decisions: re-adding what an OUTDATED (superseded) decision
443
+ // removed is not a regression against the current design.
444
+ if (d.superseded_by || d.status === "superseded")
445
+ continue;
446
+ if (!d.retired.symbols.length && !d.retired.deps.length)
447
+ continue;
448
+ if (!fileRelevant(d.related_files))
449
+ continue;
450
+ for (const s of d.retired.symbols)
451
+ if (addedSyms.has(s))
452
+ add(d, "symbol", s);
453
+ for (const dep of d.retired.deps)
454
+ if (addedDeps.has(dep))
455
+ add(d, "dep", dep);
456
+ }
457
+ return out;
458
+ }
459
+ /** The symbols/deps an in-force decision deliberately RETIRED from a file — the
460
+ * agent-hook grounding ("don't re-add X here; dec_Y removed it"). No diff is
461
+ * available at edit time, so this surfaces the risk as context, not a block. */
462
+ retiredForFile(file) {
463
+ const out = [];
464
+ for (const d of this.json.loadAll("decisions")) {
465
+ if (d.superseded_by || d.status === "superseded")
466
+ continue;
467
+ if (!d.retired.symbols.length && !d.retired.deps.length)
468
+ continue;
469
+ if (!d.related_files.some((f) => pathRelated(f, file)))
470
+ continue;
471
+ out.push({ decision: d.id, title: d.title, symbols: d.retired.symbols, deps: d.retired.deps });
472
+ }
473
+ return out;
474
+ }
367
475
  /** Bugs matching a symptom (FTS over bugs) or a symbol, with lineage (hunch_bug_lineage). */
368
476
  bugLineage(symptomOrSymbol) {
369
477
  const bugs = this.json.loadAll("bugs");
@@ -450,8 +558,8 @@ export class HunchStore {
450
558
  /** The Context Assembler (DESIGN §2.1/§6): the MINIMAL relevant Hunch slice for
451
559
  * a task on `target`, ordered by what matters most — invariants first, then the
452
560
  * why, then blast radius and bug history — trimmed to a rough token budget. */
453
- assembleContext(target, budget = 1500) {
454
- const w = this.why(target);
561
+ assembleContext(target, budget = 1500, opts = {}) {
562
+ const w = this.why(target, opts);
455
563
  const symIds = w.symbols.map((s) => s.id);
456
564
  const blast = new Map();
457
565
  for (const id of symIds) {
@@ -477,6 +585,25 @@ export class HunchStore {
477
585
  function sev(s) {
478
586
  return { blocking: 3, warning: 2, advisory: 1 }[s] ?? 0;
479
587
  }
588
+ /** Is a valid-time window open at `asOf`? `valid_from` undefined = always-started
589
+ * (legacy records). `valid_to` null = still in force. `asOf` undefined disables
590
+ * filtering (the history-inclusive default). Half-open [from, to) so a record and
591
+ * the one that supersedes it never both match at the supersession instant. */
592
+ /** Do two repo paths refer to the same file? Exact match, or one is a trailing
593
+ * path-SEGMENT suffix of the other (e.g. "x.ts" vs "src/x.ts") — anchored at a
594
+ * "/" boundary so "re.ts" never matches "store.ts" (the bare-endsWith hazard). */
595
+ function pathRelated(a, b) {
596
+ return a === b || a.endsWith("/" + b) || b.endsWith("/" + a);
597
+ }
598
+ function inWindow(valid_from, valid_to, asOf) {
599
+ if (!asOf)
600
+ return true;
601
+ if (valid_from && valid_from > asOf)
602
+ return false;
603
+ if (valid_to != null && asOf >= valid_to)
604
+ return false;
605
+ return true;
606
+ }
480
607
  function round(n) {
481
608
  return Math.round(n * 100) / 100;
482
609
  }
@@ -132,9 +132,55 @@ const BUG_TOOL = {
132
132
  },
133
133
  };
134
134
  // --------------------------------------------------------------------------
135
+ // Base for headless-CLI SUBSCRIPTION providers. Each one drives a coding-assistant
136
+ // CLI billed to the user's own subscription (never a pay-per-token API key — see
137
+ // dec_5a7c0733f7). The prompt always goes over STDIN (never argv — keeps untrusted
138
+ // diff content out of any shell pexecIn uses on Windows), and the CLI's text output
139
+ // is handed to the SAME mappers, so the rest of the system is provider-agnostic.
140
+ // --------------------------------------------------------------------------
141
+ class CliSynthProvider {
142
+ /** Run a CLI with the prompt on stdin, stripping API-key env vars so the tool
143
+ * falls through to its SUBSCRIPTION credentials. Shared by codex/cursor. */
144
+ async runCli(bin, args, stripEnv, prompt, timeoutMs = 120_000) {
145
+ const env = { ...process.env };
146
+ for (const k of stripEnv)
147
+ delete env[k];
148
+ const { stdout } = await pexecIn(bin, args, {
149
+ input: prompt,
150
+ env,
151
+ cwd: tmpdir(),
152
+ maxBuffer: 16 * 1024 * 1024,
153
+ timeout: timeoutMs,
154
+ });
155
+ return stdout;
156
+ }
157
+ async draftDecision(input) {
158
+ const text = await this.run(`${SYSTEM}\n\n${commitPrompt(input)}\n\n${jsonInstruction(DECISION_TOOL.input_schema)}`);
159
+ const draft = decisionDraftFromText(text, input.subject);
160
+ // No usable LLM JSON (truncation, refusal, prose-only, or a CLI whose output
161
+ // shape we misread) → THROW so the safe wrapper falls back to the deterministic
162
+ // provider, whose draft is honestly labeled ("inferred", low confidence).
163
+ if (!draft)
164
+ throw new Error(`${this.name}: no usable decision JSON in output`);
165
+ // For a LARGE diff the model only saw the structured summary + a sample — haircut
166
+ // the confidence and tag the source so provenance stays honest.
167
+ if (input.diff.length > LARGE_DIFF_CHARS) {
168
+ return { ...draft, confidence: Math.min(draft.confidence, 0.5), source: `${draft.source}+summary` };
169
+ }
170
+ return draft;
171
+ }
172
+ async draftBug(input) {
173
+ const text = await this.run(`${SYSTEM}\n\n${failurePrompt(input)}\n\n${jsonInstruction(BUG_TOOL.input_schema)}`);
174
+ const draft = bugDraftFromText(text, input.test, input.message);
175
+ if (!draft)
176
+ throw new Error(`${this.name}: no usable bug JSON in output`);
177
+ return draft;
178
+ }
179
+ }
180
+ // --------------------------------------------------------------------------
135
181
  // Provider A: headless `claude -p` CLI — billed to the user's Claude subscription
136
182
  // --------------------------------------------------------------------------
137
- class ClaudeCliProvider {
183
+ class ClaudeCliProvider extends CliSynthProvider {
138
184
  name = "claude-cli";
139
185
  // Default to the `haiku` alias (cheap/fast, and survives model retirements)
140
186
  // rather than a pinned dated id; override with HUNCH_SYNTH_MODEL if needed.
@@ -190,31 +236,54 @@ class ClaudeCliProvider {
190
236
  }
191
237
  return envelope.result ?? stdout;
192
238
  }
193
- async draftDecision(input) {
194
- const text = await this.run(`${SYSTEM}\n\n${commitPrompt(input)}\n\n${jsonInstruction(DECISION_TOOL.input_schema)}`);
195
- const draft = decisionDraftFromText(text, input.subject);
196
- // No usable LLM JSON (truncation, refusal, prose-only) → THROW so the safe
197
- // wrapper falls back to the deterministic provider, whose diff-structured
198
- // draft is both more useful AND honestly labeled ("inferred", low confidence)
199
- // than a hollow record mislabeled as an LLM draft.
200
- if (!draft)
201
- throw new Error("claude-cli: no usable decision JSON in output");
202
- // For a LARGE diff the model only saw the structured summary + a sample, not
203
- // the full patch (commitPrompt → renderDiff). The "why" is therefore lower-
204
- // fidelity than a draft made from the whole diff, so haircut the confidence
205
- // and tag the source — keeping provenance honest (a summary-sourced draft must
206
- // not masquerade as a full-fidelity llm_draft at 0.65).
207
- if (input.diff.length > LARGE_DIFF_CHARS) {
208
- return { ...draft, confidence: Math.min(draft.confidence, 0.5), source: `${draft.source}+summary` };
239
+ }
240
+ // --------------------------------------------------------------------------
241
+ // Provider B1: OpenAI Codex CLI (`codex exec`) — billed to the ChatGPT subscription
242
+ // --------------------------------------------------------------------------
243
+ class CodexCliProvider extends CliSynthProvider {
244
+ name = "codex-cli";
245
+ model = process.env.HUNCH_CODEX_MODEL; // omit codex uses its configured default
246
+ async available() {
247
+ try {
248
+ await pexecIn("codex", ["--version"], { timeout: 8000 });
249
+ return true;
250
+ }
251
+ catch {
252
+ return false;
209
253
  }
210
- return draft;
211
254
  }
212
- async draftBug(input) {
213
- const text = await this.run(`${SYSTEM}\n\n${failurePrompt(input)}\n\n${jsonInstruction(BUG_TOOL.input_schema)}`);
214
- const draft = bugDraftFromText(text, input.test, input.message);
215
- if (!draft)
216
- throw new Error("claude-cli: no usable bug JSON in output");
217
- return draft;
255
+ async run(prompt) {
256
+ // `codex exec --json -` reads the prompt from STDIN (the `-`), emits JSONL
257
+ // events. Strip OPENAI_API_KEY so it uses ChatGPT (subscription) auth, not the
258
+ // pay-per-token API — consistent with the subscription-only rule.
259
+ const args = ["exec", "--json", ...(this.model ? ["-m", this.model] : []), "-"];
260
+ const out = await this.runCli("codex", args, ["OPENAI_API_KEY"], prompt);
261
+ return extractCodexText(out);
262
+ }
263
+ }
264
+ // --------------------------------------------------------------------------
265
+ // Provider B2: Cursor Agent CLI (`cursor-agent -p`) — billed to the Cursor subscription
266
+ // --------------------------------------------------------------------------
267
+ class CursorCliProvider extends CliSynthProvider {
268
+ name = "cursor-agent";
269
+ model = process.env.HUNCH_CURSOR_MODEL;
270
+ async available() {
271
+ try {
272
+ await pexecIn("cursor-agent", ["--version"], { timeout: 8000 });
273
+ return true;
274
+ }
275
+ catch {
276
+ return false;
277
+ }
278
+ }
279
+ async run(prompt) {
280
+ // `-p --output-format text` → final answer as plain text (no event stream to
281
+ // parse). `--trust` so it runs non-interactively. Prompt over stdin. Cursor's
282
+ // CLI uses the user's Cursor login (subscription) — no API key to strip.
283
+ const args = ["-p", "--output-format", "text", "--trust", ...(this.model ? ["-m", this.model] : [])];
284
+ // Shorter timeout than the others: cursor-agent -p is reported to hang in some
285
+ // headless setups; cap the stall before degrading to the deterministic provider.
286
+ return this.runCli("cursor-agent", args, [], prompt, 45_000);
218
287
  }
219
288
  }
220
289
  // --------------------------------------------------------------------------
@@ -269,23 +338,71 @@ export class DeterministicProvider {
269
338
  };
270
339
  }
271
340
  }
272
- const PROVIDERS = [new ClaudeCliProvider(), new DeterministicProvider()];
341
+ /** Extract the final assistant message from `codex exec --json` output (newline-
342
+ * delimited JSON events). Codex tags assistant turns as `item.type ==="agent_message"`,
343
+ * but it ALSO emits `item.text` for reasoning and may append trailing events — so we
344
+ * prefer the last AGENT message and only fall back to the last any-text when none is
345
+ * tagged. If nothing parses, hand the raw output to the mapper (→ it finds the JSON
346
+ * draft or throws → deterministic fallback). Tolerant by design: drift degrades, never crashes. */
347
+ export function extractCodexText(out) {
348
+ const texts = [];
349
+ const agentTexts = [];
350
+ for (const line of out.split(/\r?\n/)) {
351
+ const t = line.trim();
352
+ if (!t.startsWith("{"))
353
+ continue;
354
+ try {
355
+ const o = JSON.parse(t);
356
+ const item = o.item;
357
+ const cand = (item?.text ?? o.text ?? o.message);
358
+ if (typeof cand === "string" && cand.trim()) {
359
+ texts.push(cand);
360
+ const ty = (item?.type ?? o.type);
361
+ if (typeof ty === "string" && /agent|assistant|message\b/.test(ty) && !/reason/.test(ty))
362
+ agentTexts.push(cand);
363
+ }
364
+ }
365
+ catch {
366
+ /* not a JSON event line — skip */
367
+ }
368
+ }
369
+ if (agentTexts.length)
370
+ return agentTexts[agentTexts.length - 1];
371
+ return texts.length ? texts[texts.length - 1] : out;
372
+ }
373
+ // Priority order: try each subscription CLI, then the always-available heuristic.
374
+ // HUNCH_SYNTH_PROVIDER forces one by name (claude-cli / codex-cli / cursor-agent /
375
+ // deterministic).
376
+ const PROVIDERS = [
377
+ new ClaudeCliProvider(),
378
+ new CodexCliProvider(),
379
+ new CursorCliProvider(),
380
+ new DeterministicProvider(),
381
+ ];
382
+ // Availability rarely changes within a process (a CLI doesn't get installed mid-run),
383
+ // and selectProvider() runs on every sync/recordFailure — so memoize each probe.
384
+ // Especially matters in the long-lived MCP server and on machines with NO assistant
385
+ // CLI, where an uncached pass spawns one failing `--version` per provider every time.
386
+ const availCache = new Map();
387
+ function isAvailable(p) {
388
+ let v = availCache.get(p.name);
389
+ if (!v) {
390
+ v = p.available().catch(() => false);
391
+ availCache.set(p.name, v);
392
+ }
393
+ return v;
394
+ }
273
395
  /** Choose the first available provider, honoring HUNCH_SYNTH_PROVIDER override. */
274
396
  export async function selectProvider() {
275
397
  const forced = process.env.HUNCH_SYNTH_PROVIDER;
276
398
  if (forced) {
277
399
  const p = PROVIDERS.find((x) => x.name === forced);
278
- if (p && (await p.available()))
400
+ if (p && (await isAvailable(p)))
279
401
  return p;
280
402
  }
281
403
  for (const p of PROVIDERS) {
282
- try {
283
- if (await p.available())
284
- return p;
285
- }
286
- catch {
287
- /* try next */
288
- }
404
+ if (await isAvailable(p))
405
+ return p;
289
406
  }
290
407
  return new DeterministicProvider();
291
408
  }
@@ -93,8 +93,17 @@ export async function syncCommit(store, root, sha, opts = {}) {
93
93
  related_components: relatedComponents,
94
94
  related_files: codeFiles,
95
95
  supersedes: existing?.supersedes ?? null,
96
+ superseded_by: existing?.superseded_by ?? null,
96
97
  caused_by_bug: existing?.caused_by_bug ?? null,
97
98
  commit: meta.shortSha,
99
+ // Valid-time window is git-anchored: the decision takes effect at its commit
100
+ // date and stays in force until a later decision supersedes it (preserve any
101
+ // window an earlier sync/supersession already set on this same commit's record).
102
+ valid_from: existing?.valid_from ?? meta.date,
103
+ valid_to: existing?.valid_to ?? null,
104
+ // What this commit DELETED — the Regression Guard later matches a re-adding
105
+ // diff against this (recompute from the fresh analysis, even on --force).
106
+ retired: { symbols: analysis.removedSymbols.map((s) => s.name), deps: analysis.removedDeps },
98
107
  provenance: {
99
108
  source: draft.source,
100
109
  confidence: draft.confidence,
@@ -227,6 +236,9 @@ function promoteConstraint(store, bug) {
227
236
  rationale: `Derived from ${bug.id}: ${bug.root_cause || bug.symptom}`,
228
237
  source_decision: null,
229
238
  violations: [],
239
+ status: "active",
240
+ valid_from: new Date().toISOString(),
241
+ valid_to: null,
230
242
  provenance: { source: "derived", confidence: Math.min(0.9, bug.provenance.confidence + 0.2), evidence: [`bug:${bug.id}`] },
231
243
  };
232
244
  return store.json.put("constraints", con);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "0.5.0",
3
+ "version": "0.9.0",
4
4
  "license": "MIT",
5
5
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
6
6
  "description": "Hunch — an Engineering Memory OS: a persistent, git-native reasoning graph over a codebase, exposed to Claude Code via MCP.",