@davesheffer/hunch 1.6.0 → 1.7.1

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.
Files changed (65) hide show
  1. package/README.md +220 -0
  2. package/bench/constitution-exp03-v1.json +70 -0
  3. package/dist/cli/index.js +1278 -44
  4. package/dist/constitution/adapters.js +487 -0
  5. package/dist/constitution/behaviorAttestationBinding.js +17 -0
  6. package/dist/constitution/behaviorEvaluator.js +220 -0
  7. package/dist/constitution/behaviorProof.js +205 -0
  8. package/dist/constitution/behaviorWorkspace.js +124 -0
  9. package/dist/constitution/bootstrap.js +133 -0
  10. package/dist/constitution/canonical.js +51 -0
  11. package/dist/constitution/card.js +133 -0
  12. package/dist/constitution/compiler.js +176 -0
  13. package/dist/constitution/composition.js +101 -0
  14. package/dist/constitution/corpus.js +58 -0
  15. package/dist/constitution/delta.js +154 -0
  16. package/dist/constitution/disposition.js +141 -0
  17. package/dist/constitution/evaluator.js +435 -0
  18. package/dist/constitution/experiment.js +948 -0
  19. package/dist/constitution/experimentRunner.js +344 -0
  20. package/dist/constitution/g2.js +291 -0
  21. package/dist/constitution/g2BehaviorAttestation.js +209 -0
  22. package/dist/constitution/g2BehaviorCandidates.js +703 -0
  23. package/dist/constitution/g2BehaviorDependencies.js +379 -0
  24. package/dist/constitution/g2BehaviorMaterialization.js +171 -0
  25. package/dist/constitution/g2BehaviorPolicyMaterializer.js +241 -0
  26. package/dist/constitution/g2CandidateAttestation.js +179 -0
  27. package/dist/constitution/g2Candidates.js +195 -0
  28. package/dist/constitution/g2Drills.js +122 -0
  29. package/dist/constitution/g3.js +511 -0
  30. package/dist/constitution/g3Conformance.js +115 -0
  31. package/dist/constitution/lifecycle.js +189 -0
  32. package/dist/constitution/mutation.js +262 -0
  33. package/dist/constitution/nodeTestEvidence.js +47 -0
  34. package/dist/constitution/plan.js +172 -0
  35. package/dist/constitution/policyRuntime.js +8 -0
  36. package/dist/constitution/proof.js +166 -0
  37. package/dist/constitution/replay.js +361 -0
  38. package/dist/constitution/replayCache.js +89 -0
  39. package/dist/constitution/replayWorker.js +34 -0
  40. package/dist/constitution/repository.js +533 -0
  41. package/dist/constitution/schema.js +545 -0
  42. package/dist/constitution/scorecard.js +106 -0
  43. package/dist/constitution/service.js +1149 -0
  44. package/dist/constitution/shadow.js +235 -0
  45. package/dist/constitution/sourceMutation.js +316 -0
  46. package/dist/constitution/structural.js +601 -0
  47. package/dist/core/autoreview.js +27 -3
  48. package/dist/core/dupdetect.js +10 -3
  49. package/dist/core/events.js +61 -0
  50. package/dist/core/externalImports.js +24 -0
  51. package/dist/core/hookpolicy.js +3 -0
  52. package/dist/core/relativeImports.js +33 -0
  53. package/dist/core/stats.js +115 -0
  54. package/dist/extractors/git.js +81 -0
  55. package/dist/extractors/indexer.js +39 -38
  56. package/dist/extractors/nativeTreeSitter.js +108 -0
  57. package/dist/extractors/parse.js +5 -15
  58. package/dist/integrations/claudemd.js +8 -1
  59. package/dist/integrations/gitignore.js +8 -0
  60. package/dist/integrations/providers.js +32 -10
  61. package/dist/integrations/sync.js +16 -1
  62. package/dist/mcp/server.js +284 -0
  63. package/dist/synthesis/provider.js +145 -37
  64. package/dist/synthesis/synthesize.js +4 -4
  65. package/package.json +5 -1
@@ -1,23 +1,26 @@
1
1
  /**
2
2
  * Pluggable synthesis provider for the WRITE path (DESIGN.md §4 / §7).
3
3
  *
4
- * LLM synthesis is driven by the user's Claude **subscription** via the `claude`
5
- * CLI — never the pay-per-token Anthropic API. We try, in order:
6
- * claude-cli deterministic-fallback. The fallback always works (no creds, no
7
- * network) and emits a LOW-confidence draft, honoring the design rule that
8
- * auto-captured memory is advisory and cheap to discard.
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)
9
+ * or overrides per shell with HUNCH_SYNTH_PROVIDER. Ambiguous auto mode stays
10
+ * deterministic and free.
9
11
  *
10
- * Subscription, not API: Claude Code's auth precedence puts `ANTHROPIC_API_KEY`
11
- * (and `ANTHROPIC_AUTH_TOKEN`) ABOVE subscription OAuth, and in headless `-p`
12
- * mode the API key is *always* used when present. So we strip those vars from
13
- * the child env (see ClaudeCliProvider.run) to force the CLI down to subscription
14
- * OAuth / CLAUDE_CODE_OAUTH_TOKEN. There is intentionally NO API-key provider.
12
+ * Subscription, not API: provider-specific API credentials are removed from the
13
+ * child env wherever the CLI would otherwise prefer them. There is intentionally
14
+ * NO direct API-key provider.
15
15
  *
16
16
  * Every provider returns the same shape so the rest of the system never knows
17
17
  * (or cares) which one ran.
18
18
  */
19
19
  import { spawn } from "node:child_process";
20
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
20
21
  import { tmpdir } from "node:os";
22
+ import { dirname, join } from "node:path";
23
+ import { writeFileAtomic } from "../core/io.js";
21
24
  import { summarizeDiff } from "../extractors/diff.js";
22
25
  const IS_WIN = process.platform === "win32";
23
26
  /**
@@ -96,6 +99,16 @@ export function pexecIn(cmd, args, opts = {}) {
96
99
  child.stdin.end();
97
100
  });
98
101
  }
102
+ /** 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"];
105
+ export const SYNTH_PREFERENCES = ["auto", ...SYNTH_PROVIDER_NAMES];
106
+ const PROVIDER_INFO = {
107
+ "claude-cli": { label: "Claude Code", subscription: "Claude subscription" },
108
+ "codex-cli": { label: "Codex", subscription: "ChatGPT subscription" },
109
+ "cursor-agent": { label: "Cursor Agent", subscription: "Cursor subscription" },
110
+ deterministic: { label: "Deterministic local fallback", subscription: null },
111
+ };
99
112
  const SYSTEM = `You are the synthesis engine of an Engineering Memory OS. You turn raw
100
113
  developer activity (a git commit diff, or a test failure) into a single structured
101
114
  "why" record. Be precise and evidence-grounded; never invent facts not supported by
@@ -169,7 +182,7 @@ const VERIFY_TOOL = {
169
182
  // --------------------------------------------------------------------------
170
183
  // Base for headless-CLI SUBSCRIPTION providers. Each one drives a coding-assistant
171
184
  // CLI billed to the user's own subscription (never a pay-per-token API key — see
172
- // dec_5a7c0733f7). The prompt always goes over STDIN (never argv — keeps untrusted
185
+ // dec_65b058de66). The prompt always goes over STDIN (never argv — keeps untrusted
173
186
  // diff content out of any shell pexecIn uses on Windows), and the CLI's text output
174
187
  // is handed to the SAME mappers, so the rest of the system is provider-agnostic.
175
188
  // --------------------------------------------------------------------------
@@ -447,9 +460,9 @@ export function extractCodexText(out) {
447
460
  return agentTexts[agentTexts.length - 1];
448
461
  return texts.length ? texts[texts.length - 1] : out;
449
462
  }
450
- // Priority order: try each subscription CLI, then the always-available heuristic.
451
- // HUNCH_SYNTH_PROVIDER forces one by name (claude-cli / codex-cli / cursor-agent /
452
- // deterministic).
463
+ // This registry is deliberately NOT a priority order. Auto mode only spends a
464
+ // subscription when it can identify exactly one usable CLI; see
465
+ // resolveSynthesisProvider below.
453
466
  const PROVIDERS = [
454
467
  new ClaudeCliProvider(),
455
468
  new CodexCliProvider(),
@@ -457,31 +470,126 @@ const PROVIDERS = [
457
470
  new DeterministicProvider(),
458
471
  ];
459
472
  // Availability rarely changes within a process (a CLI doesn't get installed mid-run),
460
- // and selectProvider() runs on every sync/recordFailure so memoize each probe.
461
- // Especially matters in the long-lived MCP server and on machines with NO assistant
462
- // CLI, where an uncached pass spawns one failing `--version` per provider every time.
463
- const availCache = new Map();
473
+ // and selection runs on every sync/recordFailure. Cache by object identity rather than
474
+ // name so injected test registries never inherit a stale result from another provider.
475
+ const availCache = new WeakMap();
464
476
  function isAvailable(p) {
465
- let v = availCache.get(p.name);
477
+ let v = availCache.get(p);
466
478
  if (!v) {
467
479
  v = p.available().catch(() => false);
468
- availCache.set(p.name, v);
480
+ availCache.set(p, v);
469
481
  }
470
482
  return v;
471
483
  }
472
- /** Choose the first available provider, honoring HUNCH_SYNTH_PROVIDER override. */
473
- export async function selectProvider() {
474
- const forced = process.env.HUNCH_SYNTH_PROVIDER;
475
- if (forced) {
476
- const p = PROVIDERS.find((x) => x.name === forced);
477
- if (p && (await isAvailable(p)))
478
- return p;
484
+ function isSynthPreference(value) {
485
+ return !!value && SYNTH_PREFERENCES.includes(value);
486
+ }
487
+ function fallbackProvider(providers) {
488
+ return providers.find((p) => p.name === "deterministic") ?? new DeterministicProvider();
489
+ }
490
+ function localPreferencePath(root) {
491
+ return join(root, ".hunch", "local.json");
492
+ }
493
+ /** Read a per-user, gitignored choice. Invalid/missing local state is treated as auto;
494
+ * `writeSynthesisPreference` refuses to overwrite malformed data so this forgiveness
495
+ * never destroys someone else's local settings. */
496
+ export function readSynthesisPreference(root) {
497
+ try {
498
+ const file = localPreferencePath(root);
499
+ if (!existsSync(file))
500
+ return "auto";
501
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
502
+ return typeof parsed.synthProvider === "string" && isSynthPreference(parsed.synthProvider)
503
+ ? parsed.synthProvider
504
+ : "auto";
479
505
  }
480
- for (const p of PROVIDERS) {
481
- if (await isAvailable(p))
482
- return p;
506
+ catch {
507
+ return "auto";
508
+ }
509
+ }
510
+ /** Persist the user's provider choice only in `.hunch/local.json`, which is never a
511
+ * repository policy. That means each developer controls their own subscription spend. */
512
+ export function writeSynthesisPreference(root, preference) {
513
+ if (!isSynthPreference(preference))
514
+ throw new Error(`unknown synthesis provider preference: ${preference}`);
515
+ const file = localPreferencePath(root);
516
+ let local = {};
517
+ if (existsSync(file)) {
518
+ const raw = readFileSync(file, "utf8");
519
+ if (raw.trim()) {
520
+ try {
521
+ const parsed = JSON.parse(raw);
522
+ if (!parsed || Array.isArray(parsed) || typeof parsed !== "object")
523
+ throw new Error("not an object");
524
+ local = parsed;
525
+ }
526
+ catch {
527
+ throw new Error(`refusing to overwrite malformed local configuration: ${file}`);
528
+ }
529
+ }
530
+ }
531
+ mkdirSync(dirname(file), { recursive: true });
532
+ writeFileAtomic(file, `${JSON.stringify({ ...local, synthProvider: preference }, null, 2)}\n`);
533
+ }
534
+ async function statusesFor(providers) {
535
+ const statuses = [];
536
+ for (const provider of providers) {
537
+ if (!SYNTH_PROVIDER_NAMES.includes(provider.name))
538
+ continue;
539
+ const name = provider.name;
540
+ const info = PROVIDER_INFO[name];
541
+ statuses.push({ name, ...info, available: await isAvailable(provider) });
542
+ }
543
+ return statuses;
544
+ }
545
+ /** Resolve the provider without ever inferring which of several installed products is
546
+ * the one the user intends to spend. Precedence is deliberate: a one-shell override,
547
+ * then a per-user local preference, then safe auto-detection. */
548
+ export async function resolveSynthesisProvider(opts = {}) {
549
+ const providers = opts.providers ?? PROVIDERS;
550
+ const env = opts.env ?? process.env;
551
+ const statuses = await statusesFor(providers);
552
+ const fallback = fallbackProvider(providers);
553
+ const find = (name) => providers.find((p) => p.name === name);
554
+ const usable = async (name) => {
555
+ const provider = find(name);
556
+ return provider && await isAvailable(provider) ? provider : undefined;
557
+ };
558
+ const environment = env.HUNCH_SYNTH_PROVIDER?.trim();
559
+ if (environment && isSynthPreference(environment) && environment !== "auto") {
560
+ const selected = await usable(environment);
561
+ if (selected)
562
+ return { provider: selected, source: "environment", preference: environment, statuses };
563
+ return { provider: fallback, source: "unavailable-preference", preference: environment, statuses };
564
+ }
565
+ // `HUNCH_SYNTH_PROVIDER=auto` is useful in CI or a shell profile: it explicitly
566
+ // suppresses the local preference and re-enters the safe auto policy.
567
+ const preference = environment === "auto"
568
+ ? "auto"
569
+ : opts.root ? readSynthesisPreference(opts.root) : "auto";
570
+ if (preference !== "auto") {
571
+ const selected = await usable(preference);
572
+ if (selected)
573
+ return { provider: selected, source: "local", preference, statuses };
574
+ return { provider: fallback, source: "unavailable-preference", preference, statuses };
575
+ }
576
+ const available = statuses.filter((status) => status.name !== "deterministic" && status.available);
577
+ if (available.length === 1) {
578
+ const selected = await usable(available[0].name);
579
+ if (selected)
580
+ return { provider: selected, source: "single-available", preference, statuses };
483
581
  }
484
- return new DeterministicProvider();
582
+ return {
583
+ provider: fallback,
584
+ source: available.length > 1 ? "ambiguous" : "none",
585
+ preference,
586
+ statuses,
587
+ };
588
+ }
589
+ /** The provider used by normal synthesis. See `resolveSynthesisProvider` for a
590
+ * diagnosable result with the selection source and every candidate's availability. */
591
+ export async function selectProvider(opts = {}) {
592
+ return (await resolveSynthesisProvider(opts)).provider;
485
593
  }
486
594
  // ---- Deep Synthesis: ensemble of subscription CLIs ------------------------
487
595
  // Opt-in (backfill/sync --deep): fan a commit out to EVERY available subscription
@@ -490,9 +598,9 @@ export async function selectProvider() {
490
598
  // the guard path; confidence is capped below the strict gate so output stays advisory.
491
599
  /** All available subscription-CLI workers (claude/codex/cursor), excluding the
492
600
  * deterministic fallback — the pool Deep Synthesis fans a commit out to. */
493
- export async function selectWorkers() {
601
+ export async function selectWorkers(opts = {}) {
494
602
  const out = [];
495
- for (const p of PROVIDERS) {
603
+ for (const p of opts.providers ?? PROVIDERS) {
496
604
  if (p.name === "deterministic")
497
605
  continue; // workers are real subscription CLIs only
498
606
  if (await isAvailable(p))
@@ -592,7 +700,7 @@ export class EnsembleProvider {
592
700
  * (the caller then falls back to the normal single-provider path). `samples` sets
593
701
  * the self-consistency depth for the single-CLI case. */
594
702
  export async function selectEnsemble(opts = {}) {
595
- const workers = await selectWorkers();
703
+ const workers = await selectWorkers(opts);
596
704
  // The self-consistency policy default (DEFAULT_SAMPLES) is applied HERE, not in the
597
705
  // provider — so a single CLI under --deep is sampled N times, while direct
598
706
  // construction stays passthrough. `--samples 1` opts back out.
@@ -601,9 +709,9 @@ export async function selectEnsemble(opts = {}) {
601
709
  /** Pick a CLI provider to run the Critic pass (subscription-only, like the workers).
602
710
  * Returns null when no assistant CLI is installed — verification then no-ops and the
603
711
  * un-audited draft stands (graceful degradation; dec_18a81c8291). */
604
- export async function selectVerifier() {
605
- const workers = await selectWorkers();
606
- return workers[0] ?? null;
712
+ export async function selectVerifier(opts = {}) {
713
+ const { provider } = await resolveSynthesisProvider(opts);
714
+ return provider.name === "deterministic" ? null : provider;
607
715
  }
608
716
  // ---- Verification (the Critic pass) ---------------------------------------
609
717
  // Audit a draft against the commit it came from, then PRUNE unsupported
@@ -98,9 +98,9 @@ export async function syncCommit(store, root, sha, opts = {}) {
98
98
  const provider = localOnly
99
99
  ? new DeterministicProvider()
100
100
  : opts.deep
101
- ? (await selectEnsemble({ samples: opts.samples })) ?? await selectProvider()
101
+ ? (await selectEnsemble({ samples: opts.samples })) ?? await selectProvider({ root })
102
102
  : opts.force || opts.verify || isSignificant(meta, analysis, codeFiles)
103
- ? await selectProvider()
103
+ ? await selectProvider({ root })
104
104
  : new DeterministicProvider();
105
105
  const input = { subject: meta.subject, body: meta.body, files: codeFiles, diff, analysis };
106
106
  let draft = await draftDecisionSafe(provider, input);
@@ -108,7 +108,7 @@ export async function syncCommit(store, root, sha, opts = {}) {
108
108
  // (BEFORE they scaffold tripwires below) and consequences, and lower confidence on weak
109
109
  // grounding. No-ops when no assistant CLI is available; never raises trust (dec_9a2f2fe72a).
110
110
  if (wantVerify)
111
- draft = await verifyDecisionSafe(await selectVerifier(), input, draft);
111
+ draft = await verifyDecisionSafe(await selectVerifier({ root }), input, draft);
112
112
  // Advisory synthesis telemetry for `hunch review` — which provider ran, how many drafts
113
113
  // were reconciled, their agreement, and the verifier's grounding. Rides in `evidence`
114
114
  // (no schema change → respects forward-migration invariant con_947c578b2c).
@@ -199,7 +199,7 @@ export async function recordFailure(store, root, failure, opts = {}) {
199
199
  // A private bug may contain a stack trace, customer data, or secrets. Keep the
200
200
  // whole capture local unless the caller deliberately routes it through a shared
201
201
  // (non-private) workflow.
202
- const provider = opts.private ? new DeterministicProvider() : await selectProvider();
202
+ const provider = opts.private ? new DeterministicProvider() : await selectProvider({ root });
203
203
  const input = {
204
204
  test: failure.test,
205
205
  message: failure.message,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
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).",
@@ -18,6 +18,7 @@
18
18
  },
19
19
  "files": [
20
20
  "dist/**/*.js",
21
+ "bench/constitution-exp03-v1.json",
21
22
  "LICENSE",
22
23
  "NOTICE"
23
24
  ],
@@ -48,6 +49,9 @@
48
49
  "hunch": "tsx src/cli/index.ts",
49
50
  "test": "tsx --test test/*.test.ts",
50
51
  "typecheck": "tsc -p tsconfig.json --noEmit",
52
+ "rehearse:constitution": "npm run build && node tooling/constitution-clean-rehearsal.mjs",
53
+ "gate:release": "node tooling/release-gate.mjs",
54
+ "site:proof": "npm run build && node tooling/generate-public-proof.mjs",
51
55
  "prepublishOnly": "npm run build"
52
56
  },
53
57
  "dependencies": {