@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 +56 -336
- package/dist/cli/index.js +37 -37
- package/dist/cli/invocation.js +55 -2
- package/dist/store/embedder.js +1 -1
- package/dist/synthesis/provider.js +271 -51
- package/dist/synthesis/synthesize.js +4 -3
- package/dist/wiki/wiki.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,388 +1,108 @@
|
|
|
1
1
|
# Hunch
|
|
2
2
|
|
|
3
|
-
## Your
|
|
3
|
+
## Your repo remembers why.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@davesheffer/hunch)
|
|
6
6
|
[](https://github.com/davesheffer/hunch)
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
- **
|
|
50
|
-
|
|
51
|
-
- **
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
##
|
|
48
|
+
## Day-to-day
|
|
87
49
|
|
|
88
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
69
|
+
## Synthesis without surprise billing
|
|
133
70
|
|
|
134
|
-
Hunch can
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
|
337
|
-
hunch record-bug --private --test "billing regression" --message "…"
|
|
338
|
-
hunch review --private
|
|
80
|
+
hunch provider codex-cli
|
|
339
81
|
```
|
|
340
82
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
359
|
-
Unit tests pass. The architecture is still wrong.
|
|
87
|
+
## Local-first and portable
|
|
360
88
|
|
|
361
|
-
Hunch
|
|
362
|
-
|
|
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
|
-
|
|
92
|
+
Sensitive reasoning can live in a separate private overlay:
|
|
366
93
|
|
|
367
94
|
```bash
|
|
368
|
-
|
|
95
|
+
hunch private --repo git@github.com:you/project-memory.git
|
|
369
96
|
```
|
|
370
97
|
|
|
371
|
-
|
|
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
|
-
- [
|
|
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
|
|
276
|
-
.option("--verify", "Critic pass: audit each draft against its commit, prune unsupported alternatives/consequences, down-weight weak grounding (extra
|
|
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
|
|
326
|
-
.option("--verify", "Critic pass: audit the draft against its commit, prune unsupported alternatives/consequences, down-weight weak grounding (extra
|
|
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
|
|
2756
|
+
// ---- provider (per-user synthesis provider choice) -----------------------
|
|
2748
2757
|
program
|
|
2749
2758
|
.command("provider")
|
|
2750
|
-
.description("Show or set the local
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
3848
|
-
// only (same
|
|
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}
|
|
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
|
|
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
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
console.log(
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
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;
|
package/dist/cli/invocation.js
CHANGED
|
@@ -1,8 +1,61 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
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");
|
package/dist/store/embedder.js
CHANGED
|
@@ -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
|
|
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
|
|
6
|
-
* auth surfaces, but every provider returns the
|
|
7
|
-
*
|
|
8
|
-
* to
|
|
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
|
-
|
|
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.
|
|
228
|
-
*
|
|
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
|
|
237
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
503
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
|
|
600
|
-
|
|
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
|
|
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
|
|
654
|
-
// common case): sample it this many times and reconcile, so single-
|
|
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
|
|
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
|
|
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-
|
|
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
|
|
710
|
-
*
|
|
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
|
|
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.
|
|
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
|
|
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 (
|
|
24
|
-
* SynthProvider.draftProse
|
|
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.
|
|
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).",
|