@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.
- package/LICENSE +21 -21
- package/README.md +81 -13
- package/dist/cli/index.js +287 -20
- package/dist/core/config.js +35 -0
- package/dist/core/hookpolicy.js +22 -0
- package/dist/core/migrate.js +36 -2
- package/dist/core/paths.js +1 -0
- package/dist/core/types.js +21 -0
- package/dist/extractors/git.js +16 -0
- package/dist/extractors/parse.js +13 -13
- package/dist/integrations/claudemd.js +13 -10
- package/dist/integrations/providers.js +229 -0
- package/dist/integrations/scaffold.js +57 -1
- package/dist/mcp/server.js +47 -10
- package/dist/store/hunchStore.js +135 -8
- package/dist/synthesis/provider.js +150 -33
- package/dist/synthesis/synthesize.js +12 -0
- package/package.json +1 -1
package/dist/store/hunchStore.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
|
400
|
+
if (p && (await isAvailable(p)))
|
|
279
401
|
return p;
|
|
280
402
|
}
|
|
281
403
|
for (const p of PROVIDERS) {
|
|
282
|
-
|
|
283
|
-
|
|
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.
|
|
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.",
|