@davesheffer/hunch 1.8.1 → 1.8.2

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/README.md CHANGED
@@ -1,388 +1,108 @@
1
1
  # Hunch
2
2
 
3
- ## Your AI can write code. Hunch makes it remember the consequences.
3
+ ## Your repo remembers why.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@davesheffer/hunch?color=2742ff&label=npm)](https://www.npmjs.com/package/@davesheffer/hunch)
6
6
  [![GitHub stars](https://img.shields.io/github/stars/davesheffer/hunch?color=2742ff&label=%E2%98%85%20star)](https://github.com/davesheffer/hunch)
7
7
  [![license](https://img.shields.io/npm/l/@davesheffer/hunch?color=2742ff)](LICENSE)
8
8
 
9
- **Hunch is engineering memory and a deterministic Change Gate for AI-assisted codebases.**
10
- It captures the decisions, rejected approaches, and bug history behind your codethen gives every
11
- assistant the same evidence before it changes anything.
9
+ Hunch is engineering memory for AI-assisted codebases. It records the decisions, constraints,
10
+ rejected approaches, and bug history behind your code, then gives every connected assistant the
11
+ relevant context before it makes a change.
12
12
 
13
- Not another chat history. Not a wiki your team must remember to update. A git-native reasoning
14
- graph that turns “we already learned this the hard way” into something your tools can actually use.
13
+ Memory starts **advisory**. Nothing blocks until you explicitly trust a precise rule and choose
14
+ strict enforcement.
15
+
16
+ ## Start in five minutes
17
+
18
+ Requires Node 22.13+ and a git repository.
15
19
 
16
20
  ```bash
17
21
  npm i -g @davesheffer/hunch
18
22
  cd your-repo
19
23
  hunch init
24
+ hunch backfill --since 90d # optional: seed memory from recent history
20
25
  ```
21
26
 
22
- Then ask any connected assistant: **“Why is this built this way?”**
23
-
24
- ## The problem Hunch solves
25
-
26
- AI can read your current code. It does not know the decision you made six months ago, the incident
27
- that made it necessary, or the approach you deliberately rejected yesterday.
28
-
29
- That gap is where architectural drift starts:
27
+ Reload your coding assistant, then ask:
30
28
 
31
- | Without Hunch | With Hunch |
32
- | --- | --- |
33
- | A refactor passes tests but bypasses a hard-won service boundary. | The change is checked against the decision, its constraint, and the incident behind it. |
34
- | A new coding session starts from scratch. | Claude Code, Cursor, Copilot, Windsurf, Antigravity, and Codex retrieve the same project memory over MCP. |
35
- | A correction disappears into a chat transcript. | “Never do that again” becomes a scoped, auditable guard. |
36
- | Code review sees a diff, not the reason behind it. | Change Gate produces a PASS / WARN / BLOCK receipt with causal evidence. |
29
+ > Why is this built this way?
37
30
 
38
- ## What you get in five minutes
31
+ `hunch init` indexes the repo, installs the local memory hooks, and connects supported assistants
32
+ to the same graph. It merges into existing configuration instead of replacing it.
39
33
 
40
- ```bash
41
- hunch init # index code + wire supported assistants
42
- hunch backfill --since 90d # optional: seed memory from recent history
43
- hunch check --working --strict # review the whole working tree before a commit
44
- ```
34
+ ## What Hunch gives you
45
35
 
46
- Hunch creates a local graph of:
47
-
48
- - **Decisions** what was chosen, why, and what alternatives were rejected.
49
- - **Constraints** — the invariants a change must not violate.
50
- - **Bug lineage** the root cause behind fixes, recurrences, and regression guards.
51
- - **Architecture** — symbols, components, dependencies, blast radius, and fragility.
52
- Deep code-structure parsing covers **TypeScript, JavaScript, and Python** (via a language
53
- registry each new language is one entry); the "why" layer works for any language.
54
-
55
- It then puts that context where work happens: MCP tools, the CLI, a VS Code Change Gate, git hooks,
56
- and an optional pull-request guard.
57
-
58
- ## Memory that runs itself (v1.8)
59
-
60
- There is no review queue to manage. Hunch's memory loop is fully automated, and the rare decision
61
- that genuinely needs a human is asked **inline, at the moment** — never parked in a backlog:
62
-
63
- - **Auto-trust** — every captured decision enters the graph as live advisory memory the moment it
64
- lands. It grounds and ranks immediately; it can never hard-block anything until a human
65
- explicitly vouches for it. Migrate an old draft backlog once with `hunch adopt-drafts`.
66
- - **A source-control panel for memory** — the VS Code **Hunch Memory** view shows every move
67
- Hunch makes (capture / adopt / supersede / prune / repair) as a timeline: click for the diff,
68
- right-click to revert locally. `hunch log` is the same spine in the terminal.
69
- - **Inline escalations** — `hunch escalations` (and the `hunch_escalations` MCP tool) lists only
70
- what the graph cannot resolve itself: a topic conflict, a candidate rule awaiting review, a
71
- proposed rule ready to activate. Each entry is a question with its resolution verb. Normally
72
- empty.
73
- - **Self-repair** — rename a file and the next sync automatically heals every decision binding,
74
- tripwire scope, constraint scope, and policy selector that matched it exactly (git's own rename
75
- detection, zero guessing). Repairs land as revertable timeline moves; a repaired *policy* asks
76
- once, inline, for a fresh proof.
77
- - **Local-first by design** — memory auto-commits locally and rides your next push;
78
- `hunch push` (or the panel's Approve-to-push) is the one deliberate outward step.
36
+ - **Durable context** decisions and corrections survive the chat session that produced them.
37
+ - **One memory for every assistant** — Claude Code, Cursor, Copilot, Windsurf, Antigravity, Codex,
38
+ and any MCP client see the same evidence.
39
+ - **Change receipts** — review a working tree, commit, or branch against recorded intent and get a
40
+ cited PASS / WARN / BLOCK result.
41
+ - **Bug lineage** — understand which old incident a line fixed before accidentally undoing it.
42
+ - **Code awareness** TypeScript, JavaScript, and Python structure feed dependency, blast-radius,
43
+ and redundancy checks. The reasoning layer works with any language.
79
44
 
80
- ```bash
81
- hunch log # the memory timeline (what Hunch did, when, revertable)
82
- hunch escalations # the decisions only you can make — normally empty
83
- hunch repair --apply # heal bindings after a rename (sync does this automatically)
84
- ```
45
+ The source of truth is readable JSON in `.hunch/`. A local SQLite index makes retrieval fast but
46
+ is always rebuildable.
85
47
 
86
- ## One graph. Every assistant. No lock-in.
48
+ ## Day-to-day
87
49
 
88
- Hunch is agent-agnostic by design. It scaffolds MCP and grounding for Claude Code, Cursor, VS Code / Copilot,
89
- Windsurf, Google Antigravity, Codex, and any agent that can read `AGENTS.md`; where a client exposes hooks,
90
- it adds a native lifecycle adapter too.
50
+ Most memory work happens automatically after commits. These commands cover the common manual paths:
91
51
 
92
- Your memory is plain JSON that you own. Hunch adds a SQLite index only as a rebuildable derived
93
- layer—your decisions never disappear into a proprietary hosted memory system.
94
-
95
- Synthesis is just as portable: Hunch can use Claude Code, Codex, or Cursor through the subscription
96
- CLI you choose. It never guesses which of several installed subscriptions to bill—set your local,
97
- gitignored preference with `hunch provider codex-cli` (or `claude-cli` / `cursor-agent`); otherwise
98
- Hunch uses a subscription only when exactly one is available, and falls back to deterministic local
99
- drafting when the choice is ambiguous.
100
-
101
- ```text
102
- Claude Code ─┐
103
- Cursor ├── MCP ──> .hunch/ reasoning graph ──> deterministic checks
104
- Copilot ┤
105
- Codex ┤
106
- Windsurf ┤
107
- Antigravity ┘
108
- ```
109
-
110
- ## The Change Gate: review intent, not just code
111
-
112
- Before you commit, ask Hunch to review staged files, your working tree, or a branch against its
113
- base. It returns a receipt your human reviewer or any coding agent can understand:
114
-
115
- ```text
116
- BLOCK src/payments/charge.ts
117
-
118
- [blocking] Controllers must not reach the database directly
119
- why: dec_service_boundary → bug_n_plus_one_2025
120
- evidence: charge() now imports dbQuery
121
- ```
52
+ | Command | Use it for |
53
+ | --- | --- |
54
+ | `hunch why <file>` | Decisions, bugs, constraints, and blast radius behind a file |
55
+ | `hunch query "<question>"` | Search project memory |
56
+ | `hunch check --working` | Review all current changes against recorded intent |
57
+ | `hunch log` | See the memory timeline and its reversible moves |
58
+ | `hunch escalations` | See the rare decisions only a human can make |
59
+ | `hunch doctor` | Diagnose setup, provider, index, or private-overlay problems |
122
60
 
123
- The gate is deterministic: no prompt-quality lottery and no model call in the enforcement path.
124
- Start advisory. Turn on strictness only when the rules have earned it.
61
+ Corrections can become scoped rules, but captured memory cannot hard-block on its own. Enforcement is
62
+ deterministic and opt-in:
125
63
 
126
64
  ```bash
127
65
  hunch firmness strict
128
66
  hunch check --staged --strict
129
- hunch conform --strict
130
67
  ```
131
68
 
132
- ### Hunch Constitution experimental Gate G1 + evidence bootstrap
69
+ ## Synthesis without surprise billing
133
70
 
134
- Hunch can now lift one structured architectural decision into versioned Policy IR, prove its
135
- deterministic behavior with a clean baseline plus a mutation, and require an explicit human event
136
- before it becomes enforceable:
71
+ Hunch can draft structured memory through:
137
72
 
138
- ```bash
139
- hunch constitution bootstrap --public-only --since 90d --max-candidates 3
140
- hunch constitution ingest --public-only --since 90d
141
- hunch constitution ingest --public-only --instructions --from pr-export.json
142
- hunch constitution delta dec_fix_or_revert --public-only
143
- hunch constitution bootstrap --history --public-only --since 90d --max-candidates 3
144
- hunch policy compile dec_service_boundary --through OrderService
145
- hunch policy corpus pol_… --import corpus.json
146
- hunch policy plan pol_… --history 20 --mutations 3
147
- hunch policy prove pol_…
148
- hunch policy history pol_…
149
- hunch policy history pol_… --commit <full-sha> --classify true_positive_actionable --actor github:your-name --reason "Confirmed historical violation"
150
- hunch policy shadow pol_… --record
151
- hunch policy shadow pol_…
152
- hunch policy card pol_…
153
- hunch policy relations pol_…
154
- hunch policy accept pol_… --blocking --actor github:your-name
155
- hunch policy evaluate pol_… --json
156
- hunch policy evaluate pol_… --working --json
157
- hunch policy evaluate pol_… --staged --json
158
- ```
159
-
160
- Private G2 dogfood stays evidence-only until a separate human signoff. An exact private plan selects
161
- at least ten P3+ policies and seven category-specific runbooks; drills write no evidence by
162
- themselves, rehearsals bind the resulting hashes, and historical shadow backfill commits only after
163
- the complete policy/commit matrix has produced no unknown or error result:
164
-
165
- ```bash
166
- hunch constitution g2 --plan private-g2-plan.json
167
- hunch constitution g2 --drill all
168
- hunch constitution g2 --rehearse rb_g2_evaluator_error_01 --result passed \
169
- --actor human:owner --evidence sha1:… --notes "Exact recovery drill passed."
170
- hunch constitution g2 --backfill 20
171
- hunch constitution g2 --queue 20
172
- hunch constitution g2 --strict
173
- ```
174
-
175
- Backfill uses distinct real first-parent commits, excludes commits predating an executable policy's
176
- fixing commit, and deduplicates by exact policy/proof/repository/graph identity. A failed preflight
177
- writes nothing. Neither a ready packet nor a passed drill activates, warns, blocks, or claims G2;
178
- the strongest machine result is `eligible_for_human_g2_signoff`.
179
-
180
- The model-free bootstrap considers only current, accepted, human-confirmed decisions carrying
181
- exactly one structured `conformance` predicate. It normalizes auditable evidence and keeps a bounded
182
- queue of at most three unreviewed candidates. Re-running is idempotent, private evidence inherits
183
- private storage, and every candidate starts with `authority: null`—bootstrap can never activate or
184
- block on its own.
185
-
186
- The opt-in history path reads the exact blobs for a human-confirmed fix/revert commit and its first
187
- parent—without checking out code or running hooks. It also admits an architectural replacement
188
- when a human-confirmed decision carries an explicit `retired.deps` entry that exactly matches a
189
- removed external package. It enumerates only assertions the current graph can bind exactly and
190
- whose symbol/call/package identifiers the human judgment explicitly names. One
191
- supported meaning may become a compiled candidate; zero, multiple, missing,
192
- or ambiguous meanings are stored as `uncompilable`, never silently approximated. `constitution
193
- delta` previews that evidence and candidate set without writing policy state.
194
-
195
- The external-import slice is deliberately narrow: removing a human-named static ESM package
196
- specifier can compile into a file-scoped `not-reaches(..., external:<package>)` boundary. Package
197
- subpaths canonicalize to their package root, the anchor must exist before and after the change, and
198
- an explicitly retired dependency filters unrelated call/symbol facts from the same replacement
199
- commit. Exact relative JS/TS imports across two derived components can also compile to direct
200
- `reaches`/`not-reaches` policies over `depends_on` edges when both component meanings are explicitly
201
- human-grounded. Same-component imports, ambiguous/missing targets, import-map aliases, `require()`,
202
- dynamic `import()`, runtime loading, and positive external-package requirements remain visibly
203
- unsupported.
204
-
205
- Local correction, incident, and test-failure records can be normalized with `constitution ingest`.
206
- The adapter stores references and hashes, inherits private storage, and creates no policy authority.
207
- Add `--instructions` to hash committed AGENTS/CLAUDE/Copilot/Cursor/Windsurf instructions and ADRs
208
- from immutable Git blobs. Add `--from` for one or more strict local review, conversation, or PR
209
- export JSON files. Raw prose is never copied into EvidenceEvents; only bounded metadata, references,
210
- and content hashes persist. Mixed batches validate before the first write, public-only mode refuses
211
- private/secret items, and unsupported meaning stays explicitly `uncompilable` instead of being
212
- approximated into a policy.
213
-
214
- ```json
215
- {
216
- "version": 1,
217
- "source": "pr_export",
218
- "items": [{
219
- "id": "pr-431-review-7",
220
- "kind": "review",
221
- "occurred_at": "2026-07-10T10:10:00Z",
222
- "actor": "maintainer:alice",
223
- "commit": "abc1234",
224
- "files": ["src/orders.ts"],
225
- "text": "Use OrderService; do not call persistence here.",
226
- "data_class": "private",
227
- "maintainer_confirmed": true
228
- }]
229
- }
230
- ```
231
-
232
- `policy plan` then writes a content-addressed ProofPlan before execution: exact source/current commits,
233
- known-good/known-bad corpus, deterministic mutation operators, expectations, and resource budgets.
234
- `policy corpus --import` accepts bounded labeled `known_bad`/`known_good` Git refs, resolves them
235
- once to full immutable commit SHAs, stores the manifest in the policy's public/private home, and
236
- hash-binds it into every resulting plan. A commit cannot be labeled both good and bad, and stale
237
- policy-bound corpora must be re-imported after semantic policy changes. A `known_good` fixture may
238
- also carry an explicit human attestation—`{ "actor": "github:your-name", "reason": "…" }`—to
239
- document why that accepted variant belongs in the corpus. Attested fixtures are replayed once as
240
- named corpus evidence and excluded from accepted-history sampling; the attestation cannot waive a
241
- policy result or create authority.
242
- `policy relations` is a read-only view of those explicit exception-parent links. It shows the
243
- selected policy, its parent if any, linked narrower exceptions, and a visible missing-parent marker
244
- for a manually damaged record. When the broad parent is planned, proved, or evaluated, every linked
245
- exception descendant is instead hash-bound into one canonical composition. The deepest applicable
246
- explicit scope selects the result; equal-scope ambiguity, missing bindings, or unsupported component
247
- path precision stays unknown/error rather than falling through to the broad rule.
248
- `policy consolidation` is also read-only. When a compiler-produced advisory scope suggestion exists,
249
- it groups only matching narrow policies with the same assertion/data class and syntactically contained
250
- scopes, then requires three independent decision references with no exception, active-policy,
251
- conflict, or counterexample signal before marking the packet reviewable. It never merges or widens a
252
- policy.
253
- `policy history` inspects every violated accepted-history receipt and its current human disposition.
254
- Disposition records are strict, content-addressed, stored in the policy's public/private home, and
255
- bound to the exact policy hash, proof, plan, commit, and replay receipt. Corrections append a new
256
- record with `--supersedes`; prior judgments remain auditable. Only `true_positive_actionable` clears
257
- the history-evidence gate. False positives, insufficient-parser unknowns, and accepted exceptions
258
- remain blockers until the policy/evaluator or combined exception semantics are repaired and
259
- re-proved. A disposition never activates policy: blocking still requires a separate explicit human
260
- `policy accept`, and a later corrected disposition is rechecked on every gate evaluation.
261
- `policy shadow --record` appends one content-addressed evaluation for the exact current graph and
262
- deduplicates retries of the same receipt. Violations use a separate append-only human disposition
263
- chain, while `policy shadow` reports raw recent-window counts, confirmed and lower-bound precision,
264
- unknown/error rate, mutation sensitivity, thresholds, and P4-review eligibility. Shadow records
265
- never warn, block, change proof class, or activate policy; the MCP shadow tool is read-only.
266
- Planning runs no replay, test, model, or activation; `policy prove` binds its receipt to that plan.
267
- Proof execution checks out each unique immutable commit into a disposable worktree with hooks,
268
- user-global Git configuration, private-overlay discovery, and provider selection disabled. It
269
- indexes cold snapshots through a bounded four-worker pool (hard maximum eight), reuses
270
- content-validated data-class-separated graph caches, records canonical current/known-good/
271
- known-bad/accepted-history receipts, then removes every checkout and transient graph. Scheduling
272
- and cache statistics never enter proof hashes. Project code, builds, and tests are never executed.
273
- Timeouts, worker failures, unresolved refs, unknowns, and errors remain explicit.
274
-
275
- Level-1 evaluators include `must-pass-through` (every statically discovered path from A to C must
276
- contain B), exact external-package boundaries, and component/component-id selectors over static
277
- `depends_on` edges. CLI, MCP (`hunch_policy_evaluate`), and strict CI share the exact canonical
278
- receipt. Direct `reaches`/`not-reaches` contradictions are stored as conflict evidence without
279
- minting a second policy or changing authority. Candidate records retain the exact alternatives,
280
- unsupported facts, incumbent, and conflict IDs for proof-card review. Equivalent evidence enriches
281
- the incumbent idempotently without changing its assertion, scope, proof, lifecycle, or authority.
282
- Three independently grounded component-policy sources may add an advisory common-path scope
283
- suggestion, while same-named behavior outside a narrow symbol scope is surfaced as a counterexample.
284
- Suggestions and counterexamples are review evidence only: the compiled scope is never widened
285
- automatically.
286
-
287
- Executable-behavior policies keep their proof and history replay bound to immutable commits and
288
- exact dependency snapshots. For advisory delivery, `policy evaluate --staged`, `--working`, the MCP
289
- workspace option, and the pre-commit `check` path materialize the selected pending snapshot in a
290
- disposable checkout before running the same hash-pinned test. The receipt binds the base commit,
291
- snapshot hash, and changed paths; untracked regular files are included, repository hooks and global
292
- Git configuration remain disabled, and dependency-manifest changes return an explicit error rather
293
- than using stale dependencies. Advisory violations warn but never block, and `--public-only` never
294
- loads private policies.
295
- An intentional narrow opposite can be linked explicitly with `hunch policy exception <child>
296
- --parent <parent> --actor human:<identity> --reason "…"`. The relationship requires identical
297
- bindings/relation, opposite `reaches` semantics, matching data class/home, and a strictly contained
298
- scope. Linking invalidates the child's prior proof and authority and returns it to non-blocking
299
- `compiled` state. The broad parent remains the enforcement unit: its plan, proof, replay and mutation
300
- receipts bind the full exception tree, and any later exception change retracts blocking eligibility
301
- until a fresh composite proof is generated. Proof never activates the parent; a separate human
302
- `policy accept` remains mandatory.
303
- Models do not participate in evaluation or activation. Plan-bound proofs cover the committed current
304
- baseline, known-good/known-bad fixtures, bounded accepted history, and a canonical mutation
305
- manifest. The primary mutation is applied to an immutable disposable source checkout, must remain
306
- parseable, and persists its exact Git diff plus resulting graph diff. Comment/string parser and
307
- same-name ambiguity controls remain separate; any failed required receipt prevents blocking review.
308
- Optional project build/test status is reported separately and is never required for evaluator
309
- sensitivity. Historical hits are
310
- not called false positives until classified, and unclassified hits or replay errors prevent
311
- blocking approval. Shadow evidence and broader compiler inference remain follow-on work and are
312
- reported as limitations in the proof artifact.
313
-
314
- `policy card` (also `hunch_policy_card` over MCP) renders the same deterministic review surface for
315
- every client: exact assertion and scope, raw evidence vector, unclassified hits, unknown/errors,
316
- blocking readiness, current authority, limitations, and next actions. It never averages evidence
317
- into a confidence score and never grants authority.
318
-
319
- The versioned 20-case EXP-03 compiler bank is recomputed with:
73
+ - a selected Claude Code, Codex, or Cursor subscription CLI;
74
+ - an opt-in OpenAI-compatible local endpoint such as Ollama, vLLM, LM Studio, or llama.cpp; or
75
+ - the built-in deterministic fallback when no model is available.
320
76
 
321
- ```bash
322
- hunch constitution scorecard
323
- ```
324
-
325
- The scorecard reports the raw numerator/denominator, the absolute difference from the preregistered
326
- 70% threshold, a Wilson 95% interval, per-outcome counts, and silent semantic substitutions. One
327
- unsupported-to-assertion substitution fails the gate even when the aggregate rate remains above the
328
- threshold. This curated scorecard measures deterministic compiler classification, not real-user
329
- authoring speed or acceptance; those human-review arms remain a separate EXP-03 study.
330
-
331
- ## Private when the reasoning is sensitive
332
-
333
- Open-source the code without open-sourcing the reasoning.
77
+ When several subscription CLIs are installed, Hunch does not guess which plan to use:
334
78
 
335
79
  ```bash
336
- hunch private ~/work/hunch-private/.hunch
337
- hunch record-bug --private --test "billing regression" --message "…"
338
- hunch review --private
80
+ hunch provider codex-cli
339
81
  ```
340
82
 
341
- Private decisions, bugs, constraints, and wiki pages live in a separate overlay you control.
342
- Local checks enforce them; public CI reports use `--public-only`, so private memory never appears in
343
- a pull-request comment or committed grounding file. Private captures default to deterministic local
344
- synthesis, keeping sensitive diffs and failure messages out of subscription-model drafting.
345
-
346
- ## A workflow your team can trust
347
-
348
- Hunch is deliberately conservative:
349
-
350
- - **Human-confirmed rules get the teeth.** Drafted memory advises; confirmed, precise invariants can block.
351
- - **Every result carries receipts.** Decisions, constraints, bugs, confidence, and evidence are connected.
352
- - **Drift is visible.** `hunch doctor` catches stale references, stale generated docs, and broken overlay pointers.
353
- - **Public surfaces are public-only.** Private overlay data stays local unless you explicitly choose to share it.
354
- - **No magic rewrite bot.** Hunch proposes and checks; you decide what becomes truth.
355
-
356
- ## Try the moment it earns its keep
83
+ Local and private-network endpoints work without a billing flag. Every public remote requires the
84
+ explicit `HUNCH_SYNTH_ALLOW_METERED=1` opt-in, because Hunch cannot infer cost from a hostname.
85
+ See [Synthesis & billing](https://hunch-pi.vercel.app/docs#synthesis) for setup details.
357
86
 
358
- Imagine an assistant “simplifies” a controller by querying the database directly. Linters are green.
359
- Unit tests pass. The architecture is still wrong.
87
+ ## Local-first and portable
360
88
 
361
- Hunch can answer with the actual context: this boundary exists because of the N+1 incident, the
362
- service layer was the chosen repair, and the direct import violates a confirmed constraint. That is
363
- the missing layer between fast code generation and durable engineering judgment.
89
+ Hunch has no hosted memory service or telemetry. Your graph travels with git and speaks MCP, so it
90
+ is not tied to one editor or model provider.
364
91
 
365
- ## Certify a release candidate
92
+ Sensitive reasoning can live in a separate private overlay:
366
93
 
367
94
  ```bash
368
- npm run gate:release
95
+ hunch private --repo git@github.com:you/project-memory.git
369
96
  ```
370
97
 
371
- The same fail-closed runner gates pull requests, main, and npm publication. It binds the package
372
- version and optional release tag to HEAD; runs typecheck, the full suite, core and VS Code builds,
373
- a fresh public repository index plus strict architectural conformance, a clean-installed tarball replay/privacy rehearsal, and the
374
- production dependency audit; then writes a content-addressed receipt under
375
- `.hunch-cache/release/`. A failed prerequisite stops later commands, a tag/version/commit mismatch
376
- refuses before execution, and publication remains unreachable unless the exact tagged checkout is
377
- clean and every gate passes. The receipt includes the prior-version rollback command; it never
378
- activates, promotes, warns, or blocks a Constitution policy.
98
+ Local tools see the combined graph; public CI and committed documentation stay public-only.
379
99
 
380
100
  ## Learn more
381
101
 
382
102
  - [Full documentation](https://hunch-pi.vercel.app/docs)
383
- - [Interactive product site](https://hunch-pi.vercel.app)
103
+ - [Copy-paste cookbook](https://hunch-pi.vercel.app/cookbook)
384
104
  - [VS Code extension](vscode-extension/README.md)
105
+ - [Contributing](CONTRIBUTING.md)
385
106
  - [Architecture benchmark](bench/architectural-conformance.md)
386
- - [15-second demo](demo/architectural-conformance.sh)
387
107
 
388
108
  Apache-2.0
package/dist/cli/index.js CHANGED
@@ -29,7 +29,7 @@ import { selectEmbedder } from "../store/embedder.js";
29
29
  import { indexRepo } from "../extractors/indexer.js";
30
30
  import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
31
31
  import { parseTestReport } from "../extractors/testreport.js";
32
- import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, } from "../synthesis/provider.js";
32
+ import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, normalizeProviderName, } from "../synthesis/provider.js";
33
33
  import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, workingFiles, commitFiles, asOfDate, stagedDiff, workingDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, revParse, commitAndPushHunch, pullHunch, gitUntrackCached, gitCommonDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges } from "../extractors/git.js";
34
34
  import { parseMemoryLog } from "../core/memorylog.js";
35
35
  import { renamesOf, planRepair, repairDecision, repairConstraint } from "../core/repair.js";
@@ -85,7 +85,7 @@ import { movePublicMemoryToPrivate } from "../store/privateMigrate.js";
85
85
  import { ENTITY_KINDS } from "../core/types.js";
86
86
  import { planCompaction } from "../store/compact.js";
87
87
  import { repairDecisionReference } from "../core/refrepair.js";
88
- import { resolveInvocation } from "./invocation.js";
88
+ import { resolveInvocation, dim, synthesisStatusLines, maybeWarnOllamaContext } from "./invocation.js";
89
89
  const program = new Command();
90
90
  program.name("hunch").description("Hunch — an Engineering Memory OS: a git-native reasoning graph for your codebase.").version(HUNCH_VERSION);
91
91
  let openStore = null;
@@ -272,8 +272,8 @@ program
272
272
  .option("--since <spec>", "how far back, e.g. 90d", "90d")
273
273
  .option("--max <n>", "max commits to process", "40")
274
274
  .option("--concurrency <n>", "commits to synthesize in parallel (the LLM call is the bottleneck)", "4")
275
- .option("--deep", "Deep Synthesis: ensemble every available subscription CLI per commit and reconcile their drafts (slower, higher-quality; advisory)")
276
- .option("--verify", "Critic pass: audit each draft against its commit, prune unsupported alternatives/consequences, down-weight weak grounding (extra subscription call; advisory)")
275
+ .option("--deep", "Deep Synthesis: ensemble every available LLM provider per commit and reconcile their drafts (slower, higher-quality; advisory)")
276
+ .option("--verify", "Critic pass: audit each draft against its commit, prune unsupported alternatives/consequences, down-weight weak grounding (extra provider call; advisory)")
277
277
  .option("--samples <n>", "self-consistency depth when only one CLI is installed: sample it n times per commit and reconcile (default 2 under --deep)")
278
278
  .action(async (opts) => {
279
279
  const { store, root } = storeFor();
@@ -283,6 +283,15 @@ program
283
283
  const commits = logSince(opts.since, root, Number(opts.max));
284
284
  const conc = Math.max(1, Math.min(16, Number(opts.concurrency) || 4));
285
285
  console.log(`Backfilling from ${commits.length} commit(s) since ${opts.since} (concurrency ${conc})…`);
286
+ // Best-effort context-window advisory (issue #11): printed ONCE, before any
287
+ // commit is drafted — not per-commit, and not under --deep (an ensemble may
288
+ // fan out to several distinct workers, each with its own configuration).
289
+ if (!opts.deep && commits.length > 0) {
290
+ const ctxProvider = await selectProvider();
291
+ const ctxWarning = await maybeWarnOllamaContext(ctxProvider.name, process.env);
292
+ if (ctxWarning)
293
+ console.log(ctxWarning);
294
+ }
286
295
  let written = 0, skipped = 0, llm = 0, heuristic = 0;
287
296
  // The per-commit cost is the Claude synthesis spawn; run several at once. Safe:
288
297
  // each commit drafts independently and writes its OWN decision file atomically,
@@ -322,8 +331,8 @@ program
322
331
  .option("--overlay", "alias of --private")
323
332
  .option("--commit", "after a capture, also git add+commit the repo the decision landed in (default: follows auto-commit, ON unless opted out) — the overlay is also pushed; the public .hunch/ rides your next push")
324
333
  .option("--no-commit", "skip the auto-commit for this capture even when auto-commit is on")
325
- .option("--deep", "Deep Synthesis: ensemble every available subscription CLI and reconcile their drafts (agreement-weighted, advisory). Slower; subscription-only")
326
- .option("--verify", "Critic pass: audit the draft against its commit, prune unsupported alternatives/consequences, down-weight weak grounding (extra subscription call; advisory)")
334
+ .option("--deep", "Deep Synthesis: ensemble every available LLM provider and reconcile their drafts (agreement-weighted, advisory). Slower; uses configured subscriptions/local endpoint")
335
+ .option("--verify", "Critic pass: audit the draft against its commit, prune unsupported alternatives/consequences, down-weight weak grounding (extra provider call; advisory)")
327
336
  .option("--samples <n>", "self-consistency depth when only one CLI is installed: sample it n times and reconcile (default 2 under --deep)")
328
337
  .action(async (sha, opts) => {
329
338
  const { store, root } = storeFor();
@@ -2744,15 +2753,15 @@ program
2744
2753
  const next = writeConfig(paths, { firmness: level }).firmness;
2745
2754
  console.log(`✓ firmness set to ${next} (takes effect on the next agent edit — no restart needed).`);
2746
2755
  });
2747
- // ---- provider (per-user synthesis subscription choice) -------------------
2756
+ // ---- provider (per-user synthesis provider choice) -----------------------
2748
2757
  program
2749
2758
  .command("provider")
2750
- .description("Show or set the local coding-assistant subscription Hunch may use for synthesis. Never changes team config.")
2759
+ .description("Show or set the local LLM provider Hunch may use for synthesis. Never changes team config.")
2751
2760
  .argument("[name]", `auto | ${SYNTH_PREFERENCES.filter((p) => p !== "auto").join(" | ")} (omit to inspect)`)
2752
2761
  .action(async (value) => {
2753
2762
  const root = findRoot();
2754
2763
  if (value != null) {
2755
- const preference = value.trim();
2764
+ const preference = normalizeProviderName(value.trim()) ?? value.trim();
2756
2765
  if (!SYNTH_PREFERENCES.includes(preference)) {
2757
2766
  return fail(`provider must be one of: ${SYNTH_PREFERENCES.join(", ")}`);
2758
2767
  }
@@ -2765,7 +2774,7 @@ program
2765
2774
  console.log(`✓ local synthesis preference set to ${preference} (gitignored; it never changes a teammate's billing choice).`);
2766
2775
  }
2767
2776
  const resolution = await resolveSynthesisProvider({ root });
2768
- const envValue = process.env.HUNCH_SYNTH_PROVIDER?.trim();
2777
+ const envValue = normalizeProviderName(process.env.HUNCH_SYNTH_PROVIDER?.trim());
2769
2778
  const local = readSynthesisPreference(root);
2770
2779
  const hasValidEnv = !!envValue && SYNTH_PREFERENCES.includes(envValue);
2771
2780
  console.log(`selected: ${resolution.provider.name} (${resolution.source})`);
@@ -2779,7 +2788,7 @@ program
2779
2788
  }
2780
2789
  if (resolution.source === "ambiguous") {
2781
2790
  const choices = resolution.statuses.filter((s) => s.name !== "deterministic" && s.available).map((s) => `hunch provider ${s.name}`);
2782
- console.log(dim("Multiple subscription CLIs are available, so Hunch uses the free deterministic fallback rather than guessing which plan to spend."));
2791
+ console.log(dim("Multiple LLM providers are available, so Hunch uses the free deterministic fallback rather than guessing which subscription or endpoint to use."));
2783
2792
  console.log(`choose one: ${choices.join(" or ")}`);
2784
2793
  }
2785
2794
  else if (resolution.source === "unavailable-preference") {
@@ -3313,7 +3322,7 @@ program
3313
3322
  console.log("✓ No drafts to auto-review.");
3314
3323
  return;
3315
3324
  }
3316
- // Delegate relevance to the harness (subscription CLI) — feature-detected.
3325
+ // Delegate relevance to the configured LLM provider — feature-detected.
3317
3326
  // A dry-run may remain partial (missing verdicts are kept), but --apply is
3318
3327
  // all-or-nothing when judgment was requested: a provider outage must never
3319
3328
  // turn an incomplete batch into an apparently safe mutation plan.
@@ -3342,7 +3351,7 @@ program
3342
3351
  }
3343
3352
  }
3344
3353
  else {
3345
- console.log(dim("No subscription CLI available — relevance judgment skipped (dedup + grounding only)."));
3354
+ console.log(dim("No LLM synthesis provider available — relevance judgment skipped (dedup + grounding only)."));
3346
3355
  judgmentFailures.push(...drafts.map((d) => ({ id: d.id, error: "no subscription relevance judge available" })));
3347
3356
  }
3348
3357
  }
@@ -3779,7 +3788,7 @@ program
3779
3788
  // ---- wiki (generated component wiki — a derived VIEW of the graph) ----------
3780
3789
  program
3781
3790
  .command("wiki")
3782
- .description("Generate a component wiki from the graph — pages are a derived VIEW (the graph stays the source of truth), pinned with hunch:topic anchors and freshness-hashed into a wiki-manifest. Stale pages surface as wiki-stale in `hunch drift`; --heal regenerates ONLY those. Prose via a subscription CLI when available; deterministic template otherwise. Default: PUBLIC-store records only, written to <repo>/wiki/. With --private: the FULL graph (overlay included), written into the private overlay repo — never committed here.")
3791
+ .description("Generate a component wiki from the graph — pages are a derived VIEW (the graph stays the source of truth), pinned with hunch:topic anchors and freshness-hashed into a wiki-manifest. Stale pages surface as wiki-stale in `hunch drift`; --heal regenerates ONLY those. Prose via the configured LLM provider when available; deterministic template otherwise. Default: PUBLIC-store records only, written to <repo>/wiki/. With --private: the FULL graph (overlay included), written into the private overlay repo — never committed here.")
3783
3792
  .option("--dir <dir>", "output directory (default: wiki/, or the manifest's dir once adopted)")
3784
3793
  .option("--heal", "regenerate only new/stale pages (manifest hash mismatch) and remove orphans")
3785
3794
  .option("--check", "report stale pages and exit non-zero (CI gate); writes nothing")
@@ -3844,8 +3853,8 @@ program
3844
3853
  // otherwise: drift says "remove with --heal", --heal refuses to run).
3845
3854
  if (!status.entries.length && !opts.heal)
3846
3855
  return fail("no active components in the graph — run `hunch index` first.");
3847
- // Prose is optional garnish on the deterministic skeleton: subscription CLI
3848
- // only (same rule as synthesis), feature-detected, and any failure degrades
3856
+ // Prose is optional garnish on the deterministic skeleton: configured LLM
3857
+ // provider only (same guards as synthesis), feature-detected, and any failure degrades
3849
3858
  // to a template page — generation never depends on a model being present.
3850
3859
  if (opts.proseHeal && opts.llm === false)
3851
3860
  return fail("--prose-heal needs the LLM — drop --no-llm.");
@@ -3854,13 +3863,13 @@ program
3854
3863
  if (opts.llm !== false) {
3855
3864
  const provider = await selectProvider({ root });
3856
3865
  if (provider.draftProse) {
3857
- console.log(`Prose via ${provider.name} (subscription); the drift-bearing skeleton stays deterministic.`);
3866
+ console.log(`Prose via ${provider.name}; the drift-bearing skeleton stays deterministic.`);
3858
3867
  prose = (pack, excerpts) => provider.draftProse(wikiPrompt(pack, excerpts));
3859
3868
  if (opts.proseHeal)
3860
3869
  adoptionProse = (doc, content) => provider.draftProse(adoptProsePrompt(doc, content, status.decisions));
3861
3870
  }
3862
3871
  else {
3863
- console.log(`No subscription CLI available — deterministic template pages${opts.proseHeal ? " (prose-heal skipped)" : ""}.`);
3872
+ console.log(`No LLM synthesis provider available — deterministic template pages${opts.proseHeal ? " (prose-heal skipped)" : ""}.`);
3864
3873
  }
3865
3874
  }
3866
3875
  const res = await generateWiki(store, root, home, {
@@ -4087,22 +4096,16 @@ program
4087
4096
  const resolution = await resolveSynthesisProvider({ root });
4088
4097
  const provider = resolution.provider;
4089
4098
  console.log(`synthesis: ${provider.name} (${resolution.source})`);
4090
- const selected = resolution.statuses.find((s) => s.name === provider.name);
4091
- if (selected?.subscription) {
4092
- console.log(` ↳ LLM synthesis uses your ${selected.subscription}; provider API credentials are not used.`);
4093
- }
4094
- else if (resolution.source === "ambiguous") {
4095
- const names = resolution.statuses.filter((s) => s.name !== "deterministic" && s.available).map((s) => s.name);
4096
- console.log(dim(` ↳ ${names.join(", ")} are available; Hunch will not guess which subscription to spend.`));
4097
- console.log(dim(` choose one locally: ${names.map((name) => `hunch provider ${name}`).join(" or ")}`));
4098
- }
4099
- else if (resolution.source === "unavailable-preference") {
4100
- console.log(dim(` ↳ ${resolution.preference} was selected but is unavailable; using the offline heuristic.`));
4101
- }
4102
- else {
4103
- console.log(dim(` ↳ no assistant CLI found — synthesis uses the offline heuristic (advisory, low-confidence).`));
4104
- console.log(dim(` install or log into Claude Code, Codex, or Cursor; then select one with \`hunch provider <name>\`.`));
4105
- }
4099
+ // Synthesis uses the user's SUBSCRIPTION via a coding-assistant CLI or a
4100
+ // configured local/self-hosted endpoint. Public remotes require the named
4101
+ // metered opt-in. Surface which one or what's missing (issue #9:
4102
+ // openai-compat has no `subscription` and must not fall through to the
4103
+ // "no assistant CLI found" branch).
4104
+ for (const line of synthesisStatusLines(resolution, process.env))
4105
+ console.log(line);
4106
+ const ctxWarning = await maybeWarnOllamaContext(provider.name, process.env);
4107
+ if (ctxWarning)
4108
+ console.log(ctxWarning);
4106
4109
  const c = store.reindex().counts;
4107
4110
  console.log(`hunch: ${c.symbols} symbols, ${c.edges} edges, ${c.components} components, ${c.decisions} decisions, ${c.bugs} bugs, ${c.constraints} constraints`);
4108
4111
  try {
@@ -4199,9 +4202,6 @@ function reportClaudeConfigHeal() {
4199
4202
  }
4200
4203
  console.log(dim(` ↳ backup: ${res.backup}`));
4201
4204
  }
4202
- function dim(s) {
4203
- return `\x1b[2m${s}\x1b[0m`;
4204
- }
4205
4205
  function fail(msg) {
4206
4206
  console.error(`error: ${msg}`);
4207
4207
  process.exitCode = 1;
@@ -1,8 +1,61 @@
1
- /** Figures out how to re-invoke this CLI from a git hook / .mcp.json, working
2
- * both when running the built dist (plain node) and in dev via tsx. */
1
+ /** Side-effect-free shared CLI logic safe for any module (including tests)
2
+ * to import, unlike src/cli/index.ts, which runs the whole program at
3
+ * import time. Holds: how to re-invoke this CLI from a git hook / .mcp.json
4
+ * (working both when running the built dist and in dev via tsx), plus small
5
+ * formatting helpers (dim(), doctor's synthesisStatusLines()) that need the
6
+ * same import-safety to be unit-testable. */
3
7
  import { fileURLToPath } from "node:url";
8
+ import { probeOllamaNumCtx } from "../synthesis/provider.js";
4
9
  /** Published package name — used for OS-agnostic invocations (see below). */
5
10
  const PKG = "@davesheffer/hunch";
11
+ export function dim(s) {
12
+ return `\x1b[2m${s}\x1b[0m`;
13
+ }
14
+ /** The doctor command's synthesis-status line(s) for a resolved provider.
15
+ * Exported for testing — the previous version (a bare provider-name switch,
16
+ * before the resolveSynthesisProvider preference system existed) had zero
17
+ * test coverage, which is how issue #8 (openai-compat misreported as "no
18
+ * assistant CLI found") shipped unnoticed through three review passes. That
19
+ * bug resurfaces here for the same reason: resolution.statuses carries a
20
+ * `subscription` field for the CLI providers but openai-compat's is null (it
21
+ * isn't a subscription), so it must be special-cased explicitly rather than
22
+ * falling through to the "no assistant CLI" branch. */
23
+ export function synthesisStatusLines(resolution, env) {
24
+ const provider = resolution.provider;
25
+ const selected = resolution.statuses.find((s) => s.name === provider.name);
26
+ if (selected?.subscription) {
27
+ return [` ↳ LLM synthesis uses your ${selected.subscription}; provider API credentials are not used.`];
28
+ }
29
+ if (provider.name === "openai-compat") {
30
+ const base = env.HUNCH_SYNTH_BASE_URL ?? "(unset)";
31
+ const model = env.HUNCH_SYNTH_MODEL ?? "(unset)";
32
+ const keyNote = env.HUNCH_SYNTH_API_KEY ? " (HUNCH_SYNTH_API_KEY set)" : " (no API key)";
33
+ return [` ↳ LLM synthesis via local/self-hosted endpoint ${base} (model: ${model})${keyNote}`];
34
+ }
35
+ if (resolution.source === "ambiguous") {
36
+ const names = resolution.statuses.filter((s) => s.name !== "deterministic" && s.available).map((s) => s.name);
37
+ return [
38
+ dim(` ↳ ${names.join(", ")} are available; Hunch will not guess which provider to use.`),
39
+ dim(` choose one locally: ${names.map((name) => `hunch provider ${name}`).join(" or ")}`),
40
+ ];
41
+ }
42
+ if (resolution.source === "unavailable-preference") {
43
+ return [dim(` ↳ ${resolution.preference} was selected but is unavailable; using the offline heuristic.`)];
44
+ }
45
+ return [
46
+ dim(` ↳ no assistant CLI found — synthesis uses the offline heuristic (advisory, low-confidence).`),
47
+ dim(` install or log into Claude Code, Codex, or Cursor; then select one with \`hunch provider <name>\`.`),
48
+ ];
49
+ }
50
+ /** Gate + fetch the Ollama context-window advisory (issue #11): only relevant
51
+ * for the openai-compat provider, so every other provider is a no-op. Kept
52
+ * separate from synthesisStatusLines (sync, already fully covered) because
53
+ * this one makes a best-effort network call. */
54
+ export async function maybeWarnOllamaContext(providerName, env) {
55
+ if (providerName !== "openai-compat")
56
+ return null;
57
+ return probeOllamaNumCtx(env.HUNCH_SYNTH_BASE_URL ?? "", env.HUNCH_SYNTH_MODEL ?? "");
58
+ }
6
59
  export function resolveInvocation() {
7
60
  const entry = fileURLToPath(import.meta.url).replace(/invocation\.(js|ts)$/, "index.$1");
8
61
  const isDev = entry.endsWith(".ts");
@@ -3,7 +3,7 @@
3
3
  * "add embeddings once keyword search proves insufficient" upgrade).
4
4
  *
5
5
  * Embeddings are LOCAL and FREE. Anthropic has no embeddings endpoint and the
6
- * project is subscription-only (see synthesis/provider.ts), so we run a small
6
+ * project avoids implicit metered inference (see synthesis/provider.ts), so we run a small
7
7
  * sentence-transformer locally via transformers.js. That library is an OPTIONAL
8
8
  * dependency, dynamically imported — if it isn't installed, `selectEmbedder()`
9
9
  * returns null and the whole feature degrades to pure FTS (the lean-install
@@ -2,10 +2,11 @@
2
2
  * Pluggable synthesis provider for the WRITE path (DESIGN.md §4 / §7).
3
3
  *
4
4
  * LLM synthesis is driven by the user's chosen coding-assistant subscription
5
- * CLI never a pay-per-token API. Claude Code, Codex, and Cursor use different
6
- * auth surfaces, but every provider returns the same shape. When more than one
7
- * subscription CLI is available, Hunch deliberately does NOT guess whose plan
8
- * to spend: the user chooses once with `hunch provider <name>` (stored locally)
5
+ * CLI or an explicitly configured OpenAI-compatible endpoint. Claude Code,
6
+ * Codex, and Cursor use different auth surfaces, but every provider returns the
7
+ * same shape. When more than one non-deterministic provider is available, Hunch
8
+ * deliberately does NOT guess which one to use: the user chooses once with
9
+ * `hunch provider <name>` (stored locally)
9
10
  * or overrides per shell with HUNCH_SYNTH_PROVIDER. Ambiguous auto mode stays
10
11
  * deterministic and free.
11
12
  *
@@ -13,11 +14,19 @@
13
14
  * child env wherever the CLI would otherwise prefer them. There is intentionally
14
15
  * NO direct API-key provider.
15
16
  *
17
+ * A fourth, OPT-IN provider (name "openai-compat", alias "ollama") speaks the
18
+ * OpenAI chat-completions format over HTTP to a self-hosted endpoint instead of a
19
+ * subscription CLI. It stays off unless HUNCH_SYNTH_BASE_URL and HUNCH_SYNTH_MODEL
20
+ * are both explicitly set. Local/LAN endpoints work directly; every public remote
21
+ * requires HUNCH_SYNTH_ALLOW_METERED=1 because billing cannot be inferred safely
22
+ * from a hostname — con_2ce3f2a547's spirit is "never silently bill."
23
+ *
16
24
  * Every provider returns the same shape so the rest of the system never knows
17
25
  * (or cares) which one ran.
18
26
  */
19
27
  import { spawn } from "node:child_process";
20
28
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
29
+ import { isIP } from "node:net";
21
30
  import { tmpdir } from "node:os";
22
31
  import { dirname, join } from "node:path";
23
32
  import { writeFileAtomic } from "../core/io.js";
@@ -100,13 +109,16 @@ export function pexecIn(cmd, args, opts = {}) {
100
109
  });
101
110
  }
102
111
  /** Every selectable synthesis mode. `auto` is a preference value rather than a
103
- * provider: it uses a subscription only when exactly one usable CLI is found. */
104
- export const SYNTH_PROVIDER_NAMES = ["claude-cli", "codex-cli", "cursor-agent", "deterministic"];
112
+ * provider: it uses a subscription only when exactly one usable CLI is found.
113
+ * "openai-compat" is the opt-in local/self-hosted HTTP provider (Ollama, vLLM,
114
+ * LM Studio, ...) — not a subscription, but explicitly selectable like one. */
115
+ export const SYNTH_PROVIDER_NAMES = ["claude-cli", "codex-cli", "cursor-agent", "openai-compat", "deterministic"];
105
116
  export const SYNTH_PREFERENCES = ["auto", ...SYNTH_PROVIDER_NAMES];
106
117
  const PROVIDER_INFO = {
107
118
  "claude-cli": { label: "Claude Code", subscription: "Claude subscription" },
108
119
  "codex-cli": { label: "Codex", subscription: "ChatGPT subscription" },
109
120
  "cursor-agent": { label: "Cursor Agent", subscription: "Cursor subscription" },
121
+ "openai-compat": { label: "Self-hosted / local model (Ollama, vLLM, LM Studio, ...)", subscription: null },
110
122
  deterministic: { label: "Deterministic local fallback", subscription: null },
111
123
  };
112
124
  const SYSTEM = `You are the synthesis engine of an Engineering Memory OS. You turn raw
@@ -179,14 +191,7 @@ const VERIFY_TOOL = {
179
191
  required: ["grounded", "unsupported_alternatives", "unsupported_claims"],
180
192
  },
181
193
  };
182
- // --------------------------------------------------------------------------
183
- // Base for headless-CLI SUBSCRIPTION providers. Each one drives a coding-assistant
184
- // CLI billed to the user's own subscription (never a pay-per-token API key — see
185
- // dec_65b058de66). The prompt always goes over STDIN (never argv — keeps untrusted
186
- // diff content out of any shell pexecIn uses on Windows), and the CLI's text output
187
- // is handed to the SAME mappers, so the rest of the system is provider-agnostic.
188
- // --------------------------------------------------------------------------
189
- class CliSynthProvider {
194
+ class PromptSynthProvider {
190
195
  /** Run a CLI with the prompt on stdin, stripping API-key env vars so the tool
191
196
  * falls through to its SUBSCRIPTION credentials. Shared by codex/cursor. */
192
197
  async runCli(bin, args, stripEnv, prompt, timeoutMs = 120_000) {
@@ -203,7 +208,7 @@ class CliSynthProvider {
203
208
  return stdout;
204
209
  }
205
210
  async draftDecision(input) {
206
- const text = await this.run(`${SYSTEM}\n\n${commitPrompt(input)}\n\n${jsonInstruction(DECISION_TOOL.input_schema)}`);
211
+ const text = await this.run(`${SYSTEM}\n\n${commitPrompt(input)}\n\n${jsonInstruction(DECISION_TOOL.input_schema)}`, "json");
207
212
  const draft = decisionDraftFromText(text, input.subject);
208
213
  // No usable LLM JSON (truncation, refusal, prose-only, or a CLI whose output
209
214
  // shape we misread) → THROW so the safe wrapper falls back to the deterministic
@@ -218,37 +223,37 @@ class CliSynthProvider {
218
223
  return draft;
219
224
  }
220
225
  async draftBug(input) {
221
- const text = await this.run(`${SYSTEM}\n\n${failurePrompt(input)}\n\n${jsonInstruction(BUG_TOOL.input_schema)}`);
226
+ const text = await this.run(`${SYSTEM}\n\n${failurePrompt(input)}\n\n${jsonInstruction(BUG_TOOL.input_schema)}`, "json");
222
227
  const draft = bugDraftFromText(text, input.test, input.message);
223
228
  if (!draft)
224
229
  throw new Error(`${this.name}: no usable bug JSON in output`);
225
230
  return draft;
226
231
  }
227
- /** Grounded prose for the wiki. Same subscription-only run() path (API keys
228
- * stripped). Throws on empty output so the caller falls back to its
229
- * deterministic template page. */
232
+ /** Grounded prose for the wiki. Uses text mode rather than the structured JSON
233
+ * mode required by the record mappers. Throws on empty output so the caller
234
+ * falls back to its deterministic template page. */
230
235
  async draftProse(prompt) {
231
- const text = (await this.run(prompt)).trim();
236
+ const text = (await this.run(prompt, "text")).trim();
232
237
  if (!text)
233
238
  throw new Error(`${this.name}: empty prose output`);
234
239
  return text;
235
240
  }
236
- /** The Critic pass: audit a draft against its commit. Same subscription-only
237
- * run() path (API keys stripped), so this never bills the pay-per-token API.
241
+ /** The Critic pass: audit a draft against its commit through the provider's
242
+ * guarded transport.
238
243
  * Throws on unusable output so verifyDecisionSafe degrades to the un-audited
239
244
  * draft (a verifier failure must never lose the draft — dec_18a81c8291). */
240
245
  async verifyDecision(input, draft) {
241
- const text = await this.run(`${VERIFY_SYSTEM}\n\n${verifyPrompt(input, draft)}\n\n${jsonInstruction(VERIFY_TOOL.input_schema)}`);
246
+ const text = await this.run(`${VERIFY_SYSTEM}\n\n${verifyPrompt(input, draft)}\n\n${jsonInstruction(VERIFY_TOOL.input_schema)}`, "json");
242
247
  const verdict = verdictFromText(text);
243
248
  if (!verdict)
244
249
  throw new Error(`${this.name}: no usable verdict JSON in output`);
245
250
  return verdict;
246
251
  }
247
252
  /** Judge whether an auto-drafted decision is worth keeping (for auto-review).
248
- * Same subscription-only run() path (API keys stripped). Throws on unusable
253
+ * Uses the provider's guarded transport. Throws on unusable
249
254
  * output so the caller can degrade to a keep-for-human verdict. */
250
255
  async judgeDraft(draft, existing) {
251
- const text = await this.run(`${RELEVANCE_SYSTEM}\n\n${relevancePrompt(draft, existing)}\n\n${jsonInstruction(RELEVANCE_TOOL.input_schema)}`);
256
+ const text = await this.run(`${RELEVANCE_SYSTEM}\n\n${relevancePrompt(draft, existing)}\n\n${jsonInstruction(RELEVANCE_TOOL.input_schema)}`, "json");
252
257
  const verdict = relevanceFromText(text);
253
258
  if (!verdict)
254
259
  throw new Error(`${this.name}: no usable relevance JSON in output`);
@@ -267,10 +272,28 @@ const MODEL_RE = /^[A-Za-z0-9._:/-]+$/;
267
272
  export function safeModel(v, fallback) {
268
273
  return v && MODEL_RE.test(v) ? v : fallback;
269
274
  }
275
+ // A timeout comes from a HUNCH_*_TIMEOUT_MS env var and feeds AbortController's
276
+ // delay directly (never a shell argv token, unlike safeModel's model id) — but a
277
+ // non-numeric or nonsensical value (negative, zero, NaN, Infinity) would either
278
+ // abort immediately or never abort at all, so validate the same way: fall back to
279
+ // the provider's default rather than propagate garbage.
280
+ export function safeTimeout(v, fallback) {
281
+ const n = Number(v);
282
+ return v && Number.isFinite(n) && n > 0 ? n : fallback;
283
+ }
284
+ // max_tokens caps OUTPUT length (never a shell argv token, unlike safeModel's
285
+ // model id) — same failure modes as a timeout, so validate the same way: fall
286
+ // back to a safe default rather than propagate garbage into the request body
287
+ // (issue #11; orthogonal to the context-window/truncation problem that issue
288
+ // is mainly about — this only bounds how much the model is allowed to WRITE).
289
+ export function safeMaxTokens(v, fallback) {
290
+ const n = Number(v);
291
+ return v && Number.isFinite(n) && n > 0 ? n : fallback;
292
+ }
270
293
  // --------------------------------------------------------------------------
271
294
  // Provider A: headless `claude -p` CLI — billed to the user's Claude subscription
272
295
  // --------------------------------------------------------------------------
273
- class ClaudeCliProvider extends CliSynthProvider {
296
+ class ClaudeCliProvider extends PromptSynthProvider {
274
297
  name = "claude-cli";
275
298
  // Default to the `haiku` alias (cheap/fast, and survives model retirements)
276
299
  // rather than a pinned dated id; override with HUNCH_SYNTH_MODEL if needed.
@@ -330,7 +353,7 @@ class ClaudeCliProvider extends CliSynthProvider {
330
353
  // --------------------------------------------------------------------------
331
354
  // Provider B1: OpenAI Codex CLI (`codex exec`) — billed to the ChatGPT subscription
332
355
  // --------------------------------------------------------------------------
333
- class CodexCliProvider extends CliSynthProvider {
356
+ class CodexCliProvider extends PromptSynthProvider {
334
357
  name = "codex-cli";
335
358
  model = safeModel(process.env.HUNCH_CODEX_MODEL, undefined); // omit → codex uses its configured default
336
359
  async available() {
@@ -354,7 +377,7 @@ class CodexCliProvider extends CliSynthProvider {
354
377
  // --------------------------------------------------------------------------
355
378
  // Provider B2: Cursor Agent CLI (`cursor-agent -p`) — billed to the Cursor subscription
356
379
  // --------------------------------------------------------------------------
357
- class CursorCliProvider extends CliSynthProvider {
380
+ class CursorCliProvider extends PromptSynthProvider {
358
381
  name = "cursor-agent";
359
382
  model = safeModel(process.env.HUNCH_CURSOR_MODEL, undefined);
360
383
  async available() {
@@ -377,6 +400,174 @@ class CursorCliProvider extends CliSynthProvider {
377
400
  }
378
401
  }
379
402
  // --------------------------------------------------------------------------
403
+ // Provider D: OpenAI-compatible / local model endpoint (Ollama, vLLM, LM
404
+ // Studio, llama.cpp server, ...) — opt-in, NOT a subscription CLI. Speaks the
405
+ // OpenAI chat-completions wire format over HTTP, so ONE implementation covers
406
+ // any self-hosted server that implements it (Ollama's /v1 compatibility layer
407
+ // included — no separate native /api/chat client). Off by default: available()
408
+ // requires BOTH HUNCH_SYNTH_BASE_URL and HUNCH_SYNTH_MODEL, so an installation
409
+ // with neither set behaves exactly as it did before this provider existed.
410
+ //
411
+ // Exported (unlike the CLI providers) so tests can construct fresh instances and
412
+ // read process.env at CALL time — see run()/available() below, which read env
413
+ // vars directly rather than caching them in constructor fields. That mirrors
414
+ // selectProvider()'s own style (it re-reads HUNCH_SYNTH_PROVIDER on every call)
415
+ // and avoids a stale-field trap: a module-level PROVIDERS singleton constructed
416
+ // once at import time would otherwise never see env vars a test (or a long-lived
417
+ // process) sets afterward.
418
+ // --------------------------------------------------------------------------
419
+ // con_2ce3f2a547's boundary is "never silently bill." A denylist cannot enforce
420
+ // that boundary: new OpenAI-compatible paid providers appear continually, and a
421
+ // fully-qualified trailing DNS dot can even evade a naive exact-host comparison.
422
+ // Fail closed instead. Loopback, private/link-local IPs, and conventional LAN DNS
423
+ // names work without ceremony; every public remote requires the deliberate,
424
+ // named HUNCH_SYNTH_ALLOW_METERED=1 opt-in. Publicly hosted self-managed servers
425
+ // use that same flag because billing cannot be inferred reliably from a hostname.
426
+ function normalizedHostname(url) {
427
+ return url.hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
428
+ }
429
+ /** Parse the exact base-URL shape this provider can safely compose with
430
+ * `/chat/completions`. Credentials belong in HUNCH_SYNTH_API_KEY; query strings
431
+ * and fragments are rejected because appending a path to either is ambiguous. */
432
+ function parseOpenAICompatBaseUrl(baseUrl) {
433
+ try {
434
+ const url = new URL(baseUrl);
435
+ if (url.protocol !== "http:" && url.protocol !== "https:")
436
+ return null;
437
+ if (!url.hostname || url.username || url.password || url.search || url.hash)
438
+ return null;
439
+ return url;
440
+ }
441
+ catch {
442
+ return null;
443
+ }
444
+ }
445
+ function isPrivateIpv4(hostname) {
446
+ const [a = -1, b = -1] = hostname.split(".").map(Number);
447
+ return a === 0
448
+ || a === 10
449
+ || a === 127
450
+ || (a === 100 && b >= 64 && b <= 127) // shared space, including common tailnets
451
+ || (a === 169 && b === 254)
452
+ || (a === 172 && b >= 16 && b <= 31)
453
+ || (a === 192 && b === 168);
454
+ }
455
+ function isPrivateIpv6(hostname) {
456
+ const host = hostname.toLowerCase();
457
+ if (host === "::" || host === "::1")
458
+ return true;
459
+ if (host.startsWith("::ffff:")) {
460
+ const mapped = host.slice("::ffff:".length);
461
+ return isIP(mapped) === 4 && isPrivateIpv4(mapped);
462
+ }
463
+ const first = host.split(":", 1)[0] ?? "";
464
+ return first.startsWith("fc")
465
+ || first.startsWith("fd")
466
+ || /^fe[89ab]/.test(first);
467
+ }
468
+ function isLocalOrPrivateHost(hostname) {
469
+ if (isIP(hostname) === 4)
470
+ return isPrivateIpv4(hostname);
471
+ if (isIP(hostname) === 6)
472
+ return isPrivateIpv6(hostname);
473
+ if (hostname === "localhost" || !hostname.includes("."))
474
+ return true;
475
+ return [".localhost", ".local", ".lan", ".internal", ".home.arpa"].some((suffix) => hostname.endsWith(suffix));
476
+ }
477
+ function requiresMeteredOptIn(url) {
478
+ return !isLocalOrPrivateHost(normalizedHostname(url));
479
+ }
480
+ export function meteredHostsAllowed(env = process.env) {
481
+ return env.HUNCH_SYNTH_ALLOW_METERED === "1";
482
+ }
483
+ export class OpenAICompatProvider extends PromptSynthProvider {
484
+ name = "openai-compat";
485
+ async available() {
486
+ const baseUrl = process.env.HUNCH_SYNTH_BASE_URL;
487
+ const endpoint = baseUrl ? parseOpenAICompatBaseUrl(baseUrl) : null;
488
+ if (!endpoint || !safeModel(process.env.HUNCH_SYNTH_MODEL, undefined))
489
+ return false;
490
+ return meteredHostsAllowed() || !requiresMeteredOptIn(endpoint);
491
+ }
492
+ async run(prompt, output = "json") {
493
+ const baseUrl = process.env.HUNCH_SYNTH_BASE_URL;
494
+ const model = safeModel(process.env.HUNCH_SYNTH_MODEL, undefined);
495
+ if (!baseUrl || !model)
496
+ throw new Error("openai-compat: HUNCH_SYNTH_BASE_URL/HUNCH_SYNTH_MODEL not set");
497
+ const endpoint = parseOpenAICompatBaseUrl(baseUrl);
498
+ if (!endpoint) {
499
+ throw new Error("openai-compat: HUNCH_SYNTH_BASE_URL must be an http(s) base URL without credentials, a query, or a fragment");
500
+ }
501
+ if (requiresMeteredOptIn(endpoint) && !meteredHostsAllowed()) {
502
+ throw new Error(`openai-compat: refusing to call public remote ${normalizedHostname(endpoint)} — it may be metered, and con_2ce3f2a547 blocks silent pay-per-token billing. Set HUNCH_SYNTH_ALLOW_METERED=1 if this is deliberate.`);
503
+ }
504
+ endpoint.pathname = `${endpoint.pathname.replace(/\/+$/, "")}/chat/completions`;
505
+ const apiKey = process.env.HUNCH_SYNTH_API_KEY;
506
+ const timeoutMs = safeTimeout(process.env.HUNCH_SYNTH_TIMEOUT_MS, 300_000);
507
+ const controller = new AbortController();
508
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
509
+ try {
510
+ const res = await fetch(endpoint, {
511
+ method: "POST",
512
+ headers: {
513
+ "content-type": "application/json",
514
+ ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
515
+ },
516
+ body: JSON.stringify({
517
+ model,
518
+ messages: [{ role: "user", content: prompt }],
519
+ ...(output === "json" ? { response_format: { type: "json_object" } } : {}),
520
+ stream: false,
521
+ max_tokens: safeMaxTokens(process.env.HUNCH_SYNTH_MAX_TOKENS, 2048),
522
+ }),
523
+ signal: controller.signal,
524
+ });
525
+ if (!res.ok) {
526
+ throw new Error(`openai-compat endpoint returned ${res.status}: ${(await res.text()).slice(0, 300)}`);
527
+ }
528
+ const body = (await res.json());
529
+ const content = body.choices?.[0]?.message?.content;
530
+ if (!content)
531
+ throw new Error("openai-compat endpoint returned no message content");
532
+ return content;
533
+ }
534
+ finally {
535
+ clearTimeout(timer);
536
+ }
537
+ }
538
+ }
539
+ /** Best-effort: does the configured openai-compat endpoint look like Ollama with
540
+ * an UNSET num_ctx? Returns
541
+ * an advisory warning string when so, or null when the endpoint isn't reachable,
542
+ * doesn't look like Ollama's /api/show shape, or already has num_ctx set — this
543
+ * is diagnostics only, never thrown, never blocking. Deliberately does NOT try to
544
+ * report the model's effective context length: modern Ollama defaults may come
545
+ * from server configuration or VRAM tiers, and model_info keys are not a stable
546
+ * parse target. We therefore report only the observed fact — whether num_ctx is
547
+ * pinned in the model — without guessing an effective token count. */
548
+ export async function probeOllamaNumCtx(baseUrl, model) {
549
+ try {
550
+ const root = baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
551
+ const res = await fetch(`${root}/api/show`, {
552
+ method: "POST",
553
+ headers: { "content-type": "application/json" },
554
+ body: JSON.stringify({ name: model }),
555
+ signal: AbortSignal.timeout(5_000),
556
+ });
557
+ if (!res.ok)
558
+ return null;
559
+ const body = (await res.json());
560
+ if (typeof body.parameters !== "string")
561
+ return null;
562
+ if (/^num_ctx\s+\d+/m.test(body.parameters))
563
+ return null; // already configured — nothing to warn about
564
+ return "⚠ This Ollama model does not pin num_ctx; its effective context depends on server/VRAM defaults. For stable large-diff synthesis, see https://hunch-pi.vercel.app/cookbook and pin num_ctx via a custom Modelfile.";
565
+ }
566
+ catch {
567
+ return null; // not Ollama, unreachable, or an unexpected response shape — advisory only, never throw
568
+ }
569
+ }
570
+ // --------------------------------------------------------------------------
380
571
  // Provider C: deterministic fallback (no LLM, always available)
381
572
  // --------------------------------------------------------------------------
382
573
  export class DeterministicProvider {
@@ -467,12 +658,16 @@ const PROVIDERS = [
467
658
  new ClaudeCliProvider(),
468
659
  new CodexCliProvider(),
469
660
  new CursorCliProvider(),
661
+ new OpenAICompatProvider(),
470
662
  new DeterministicProvider(),
471
663
  ];
472
664
  // Availability rarely changes within a process (a CLI doesn't get installed mid-run),
473
665
  // and selection runs on every sync/recordFailure. Cache by object identity rather than
474
666
  // name so injected test registries never inherit a stale result from another provider.
475
- const availCache = new WeakMap();
667
+ // A plain Map (not WeakMap): __resetAvailabilityCacheForTests below needs .clear(),
668
+ // which WeakMap doesn't support — the module's singleton PROVIDERS array is the only
669
+ // thing that ever populates this in production, so there's no unbounded-growth risk.
670
+ const availCache = new Map();
476
671
  function isAvailable(p) {
477
672
  let v = availCache.get(p);
478
673
  if (!v) {
@@ -481,8 +676,27 @@ function isAvailable(p) {
481
676
  }
482
677
  return v;
483
678
  }
679
+ /** Test-only: clears the availability memoization cache so a test that toggles
680
+ * env vars mid-process (e.g. HUNCH_SYNTH_BASE_URL) isn't served a stale result
681
+ * cached by an earlier call in the same process. Never call from production code. */
682
+ export function __resetAvailabilityCacheForTests() {
683
+ availCache.clear();
684
+ }
685
+ /** "ollama" is accepted as an alias for "openai-compat" — the provider is not
686
+ * Ollama-specific (it speaks the OpenAI chat-completions format any self-hosted
687
+ * server can implement), but Ollama is the most common self-hosted target and
688
+ * users reach for that name first. Applied to the HUNCH_SYNTH_PROVIDER env var in
689
+ * resolveSynthesisProvider below, and exported so the `hunch provider <name>` CLI
690
+ * command (index.ts) normalizes it the same way before validating/persisting a
691
+ * local preference — the two paths must agree, or a user who sets one and reads
692
+ * the other back gets a confusing "unknown provider" message for a name that
693
+ * actually works. */
694
+ export function normalizeProviderName(v) {
695
+ return v === "ollama" ? "openai-compat" : v;
696
+ }
484
697
  function isSynthPreference(value) {
485
- return !!value && SYNTH_PREFERENCES.includes(value);
698
+ const normalized = normalizeProviderName(value);
699
+ return !!normalized && SYNTH_PREFERENCES.includes(normalized);
486
700
  }
487
701
  function fallbackProvider(providers) {
488
702
  return providers.find((p) => p.name === "deterministic") ?? new DeterministicProvider();
@@ -499,9 +713,8 @@ export function readSynthesisPreference(root) {
499
713
  if (!existsSync(file))
500
714
  return "auto";
501
715
  const parsed = JSON.parse(readFileSync(file, "utf8"));
502
- return typeof parsed.synthProvider === "string" && isSynthPreference(parsed.synthProvider)
503
- ? parsed.synthProvider
504
- : "auto";
716
+ const normalized = typeof parsed.synthProvider === "string" ? normalizeProviderName(parsed.synthProvider) : undefined;
717
+ return isSynthPreference(normalized) ? normalized : "auto";
505
718
  }
506
719
  catch {
507
720
  return "auto";
@@ -510,7 +723,8 @@ export function readSynthesisPreference(root) {
510
723
  /** Persist the user's provider choice only in `.hunch/local.json`, which is never a
511
724
  * repository policy. That means each developer controls their own subscription spend. */
512
725
  export function writeSynthesisPreference(root, preference) {
513
- if (!isSynthPreference(preference))
726
+ const normalized = normalizeProviderName(preference);
727
+ if (!isSynthPreference(normalized))
514
728
  throw new Error(`unknown synthesis provider preference: ${preference}`);
515
729
  const file = localPreferencePath(root);
516
730
  let local = {};
@@ -529,7 +743,7 @@ export function writeSynthesisPreference(root, preference) {
529
743
  }
530
744
  }
531
745
  mkdirSync(dirname(file), { recursive: true });
532
- writeFileAtomic(file, `${JSON.stringify({ ...local, synthProvider: preference }, null, 2)}\n`);
746
+ writeFileAtomic(file, `${JSON.stringify({ ...local, synthProvider: normalized }, null, 2)}\n`);
533
747
  }
534
748
  async function statusesFor(providers) {
535
749
  const statuses = [];
@@ -555,7 +769,7 @@ export async function resolveSynthesisProvider(opts = {}) {
555
769
  const provider = find(name);
556
770
  return provider && await isAvailable(provider) ? provider : undefined;
557
771
  };
558
- const environment = env.HUNCH_SYNTH_PROVIDER?.trim();
772
+ const environment = normalizeProviderName(env.HUNCH_SYNTH_PROVIDER?.trim());
559
773
  if (environment && isSynthPreference(environment) && environment !== "auto") {
560
774
  const selected = await usable(environment);
561
775
  if (selected)
@@ -591,18 +805,22 @@ export async function resolveSynthesisProvider(opts = {}) {
591
805
  export async function selectProvider(opts = {}) {
592
806
  return (await resolveSynthesisProvider(opts)).provider;
593
807
  }
594
- // ---- Deep Synthesis: ensemble of subscription CLIs ------------------------
595
- // Opt-in (backfill/sync --deep): fan a commit out to EVERY available subscription
596
- // CLI, drop failures, and reconcile the drafts. Subscription-only (the workers are
597
- // the same CLI providers, so ANTHROPIC_API_KEY stripping is inherited). NEVER used on
598
- // the guard path; confidence is capped below the strict gate so output stays advisory.
599
- /** All available subscription-CLI workers (claude/codex/cursor), excluding the
600
- * deterministic fallback the pool Deep Synthesis fans a commit out to. */
808
+ // ---- Deep Synthesis: ensemble of subscription CLIs (+ opt-in openai-compat) ----
809
+ // Opt-in (backfill/sync --deep): fan a commit out to EVERY available worker —
810
+ // the subscription CLIs (ANTHROPIC_API_KEY stripping inherited from them) plus the
811
+ // opt-in openai-compat HTTP provider when configured, which is outside that
812
+ // stripping scope entirely (con_2ce3f2a547 governs the Anthropic API specifically,
813
+ // not a user-configured self-hosted endpoint) — drop failures, reconcile the
814
+ // drafts. NEVER used on the guard path; confidence is capped below the strict gate
815
+ // so output stays advisory.
816
+ /** All available subscription-CLI workers (claude/codex/cursor, plus the opt-in
817
+ * openai-compat), excluding the deterministic fallback — the pool Deep Synthesis
818
+ * fans a commit out to. */
601
819
  export async function selectWorkers(opts = {}) {
602
820
  const out = [];
603
821
  for (const p of opts.providers ?? PROVIDERS) {
604
822
  if (p.name === "deterministic")
605
- continue; // workers are real subscription CLIs only
823
+ continue; // workers are real LLM providers only
606
824
  if (await isAvailable(p))
607
825
  out.push(p);
608
826
  }
@@ -650,8 +868,8 @@ export function mergeDecisionDrafts(drafts) {
650
868
  agreement: Math.round(agreement * 100) / 100,
651
869
  };
652
870
  }
653
- // Default self-consistency depth when only ONE subscription CLI is installed (the
654
- // common case): sample it this many times and reconcile, so single-CLI users get
871
+ // Default self-consistency depth when only ONE LLM provider is available (the
872
+ // common case): sample it this many times and reconcile, so single-provider users get
655
873
  // ensemble-like robustness. Tunable per-call via `--samples`.
656
874
  const DEFAULT_SAMPLES = 2;
657
875
  export class EnsembleProvider {
@@ -678,7 +896,7 @@ export class EnsembleProvider {
678
896
  }
679
897
  async draftDecision(input) {
680
898
  if (!this.workers.length)
681
- throw new Error("ensemble: no subscription CLI workers available");
899
+ throw new Error("ensemble: no LLM provider workers available");
682
900
  const settled = await Promise.allSettled(this.decisionTasks(input).map((t) => t()));
683
901
  const drafts = settled.flatMap((s) => (s.status === "fulfilled" ? [s.value] : []));
684
902
  if (!drafts.length)
@@ -696,9 +914,9 @@ export class EnsembleProvider {
696
914
  throw new Error("ensemble: all workers failed for bug");
697
915
  }
698
916
  }
699
- /** Build the Deep-Synthesis provider, or null if no subscription CLI is available
917
+ /** Build the Deep-Synthesis provider, or null if no LLM provider is available
700
918
  * (the caller then falls back to the normal single-provider path). `samples` sets
701
- * the self-consistency depth for the single-CLI case. */
919
+ * the self-consistency depth for the single-provider case. */
702
920
  export async function selectEnsemble(opts = {}) {
703
921
  const workers = await selectWorkers(opts);
704
922
  // The self-consistency policy default (DEFAULT_SAMPLES) is applied HERE, not in the
@@ -706,8 +924,10 @@ export async function selectEnsemble(opts = {}) {
706
924
  // construction stays passthrough. `--samples 1` opts back out.
707
925
  return workers.length ? new EnsembleProvider(workers, { samples: opts.samples ?? DEFAULT_SAMPLES }) : null;
708
926
  }
709
- /** Pick a CLI provider to run the Critic pass (subscription-only, like the workers).
710
- * Returns null when no assistant CLI is installed verification then no-ops and the
927
+ /** Pick a provider to run the Critic pass the same resolved provider normal
928
+ * synthesis would use (subscription CLI or the opt-in openai-compat endpoint),
929
+ * honoring the same env/local-preference/auto policy. Returns null when that
930
+ * resolves to the deterministic fallback — verification then no-ops and the
711
931
  * un-audited draft stands (graceful degradation; dec_18a81c8291). */
712
932
  export async function selectVerifier(opts = {}) {
713
933
  const { provider } = await resolveSynthesisProvider(opts);
@@ -95,13 +95,14 @@ export async function syncCommit(store, root, sha, opts = {}) {
95
95
  // Significance gate: reserve the paid LLM for substantive commits; trivial ones
96
96
  // get the FREE deterministic draft (honestly labeled "inferred"/low-confidence,
97
97
  // so the Hunch stays accurate-by-provenance). --force always uses the provider.
98
- // Deep Synthesis (--deep): ensemble every available subscription CLI and reconcile
98
+ // Deep Synthesis (--deep): ensemble every available guarded LLM provider and reconcile
99
99
  // their drafts (agreement-weighted, confidence capped below the strict gate). Falls
100
100
  // back to the normal single-provider path when no CLI is available. Opt-in only.
101
101
  // --verify forces the LLM provider (auditing a deterministic draft is pointless) and,
102
- // like --deep, runs the Critic pass below. Subscription-only throughout (con_2ce3f2a547).
102
+ // like --deep, runs the Critic pass below. Public remotes stay behind the explicit
103
+ // metered opt-in throughout (con_2ce3f2a547).
103
104
  // An explicit private capture is storage-private AND local-only by default:
104
- // never send a sensitive diff to a subscription CLI just to create a draft.
105
+ // never send a sensitive diff to any LLM provider just to create a draft.
105
106
  // Shared mode remains an explicit team policy and keeps its existing provider
106
107
  // behavior unless the caller asked for a private capture.
107
108
  const localOnly = opts.localOnly ?? !!opts.private;
package/dist/wiki/wiki.js CHANGED
@@ -20,8 +20,8 @@
20
20
  * (`store.recs`, overlay included). Nothing lands in the public
21
21
  * repo; the manifest lives inside the overlay's .hunch/.
22
22
  *
23
- * The prose "Overview" section is optional LLM output (subscription CLI via
24
- * SynthProvider.draftProse — never a pay-per-token API); everything drift-bearing
23
+ * The prose "Overview" section is optional LLM output (a guarded configured
24
+ * SynthProvider.draftProse); everything drift-bearing
25
25
  * (anchors, invariants, structure) is rendered deterministically around it, so a
26
26
  * missing/failed CLI degrades to a complete template page, and the input hash
27
27
  * covers graph inputs only — LLM nondeterminism can never fake staleness.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.8.1",
3
+ "version": "1.8.2",
4
4
  "license": "Apache-2.0",
5
5
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
6
6
  "description": "Architectural Conformance for AI-generated code: a git-native graph that deterministically blocks AI changes which break your architecture — the semantic invariants (layering, must-reach, dependency direction) pattern-SAST can't express — grounded in the decisions and bugs behind each rule, across any MCP assistant (Claude Code, Cursor, Copilot, Windsurf, Antigravity, Codex).",