@davesheffer/hunch 1.8.1 → 1.8.3

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/dist/cli/index.js CHANGED
@@ -29,7 +29,7 @@ import { selectEmbedder } from "../store/embedder.js";
29
29
  import { indexRepo } from "../extractors/indexer.js";
30
30
  import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
31
31
  import { parseTestReport } from "../extractors/testreport.js";
32
- import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, } from "../synthesis/provider.js";
32
+ import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, normalizeProviderName, } from "../synthesis/provider.js";
33
33
  import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, workingFiles, commitFiles, asOfDate, stagedDiff, workingDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, revParse, commitAndPushHunch, pullHunch, gitUntrackCached, gitCommonDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges } from "../extractors/git.js";
34
34
  import { parseMemoryLog } from "../core/memorylog.js";
35
35
  import { renamesOf, planRepair, repairDecision, repairConstraint } from "../core/repair.js";
@@ -85,7 +85,7 @@ import { movePublicMemoryToPrivate } from "../store/privateMigrate.js";
85
85
  import { ENTITY_KINDS } from "../core/types.js";
86
86
  import { planCompaction } from "../store/compact.js";
87
87
  import { repairDecisionReference } from "../core/refrepair.js";
88
- import { resolveInvocation } from "./invocation.js";
88
+ import { resolveInvocation, dim, synthesisStatusLines, maybeWarnOllamaContext } from "./invocation.js";
89
89
  const program = new Command();
90
90
  program.name("hunch").description("Hunch — an Engineering Memory OS: a git-native reasoning graph for your codebase.").version(HUNCH_VERSION);
91
91
  let openStore = null;
@@ -272,8 +272,8 @@ program
272
272
  .option("--since <spec>", "how far back, e.g. 90d", "90d")
273
273
  .option("--max <n>", "max commits to process", "40")
274
274
  .option("--concurrency <n>", "commits to synthesize in parallel (the LLM call is the bottleneck)", "4")
275
- .option("--deep", "Deep Synthesis: ensemble every available subscription CLI per commit and reconcile their drafts (slower, higher-quality; advisory)")
276
- .option("--verify", "Critic pass: audit each draft against its commit, prune unsupported alternatives/consequences, down-weight weak grounding (extra subscription call; advisory)")
275
+ .option("--deep", "Deep Synthesis: ensemble every available LLM provider per commit and reconcile their drafts (slower, higher-quality; advisory)")
276
+ .option("--verify", "Critic pass: audit each draft against its commit, prune unsupported alternatives/consequences, down-weight weak grounding (extra provider call; advisory)")
277
277
  .option("--samples <n>", "self-consistency depth when only one CLI is installed: sample it n times per commit and reconcile (default 2 under --deep)")
278
278
  .action(async (opts) => {
279
279
  const { store, root } = storeFor();
@@ -283,6 +283,15 @@ program
283
283
  const commits = logSince(opts.since, root, Number(opts.max));
284
284
  const conc = Math.max(1, Math.min(16, Number(opts.concurrency) || 4));
285
285
  console.log(`Backfilling from ${commits.length} commit(s) since ${opts.since} (concurrency ${conc})…`);
286
+ // Best-effort context-window advisory (issue #11): printed ONCE, before any
287
+ // commit is drafted — not per-commit, and not under --deep (an ensemble may
288
+ // fan out to several distinct workers, each with its own configuration).
289
+ if (!opts.deep && commits.length > 0) {
290
+ const ctxProvider = await selectProvider();
291
+ const ctxWarning = await maybeWarnOllamaContext(ctxProvider.name, process.env);
292
+ if (ctxWarning)
293
+ console.log(ctxWarning);
294
+ }
286
295
  let written = 0, skipped = 0, llm = 0, heuristic = 0;
287
296
  // The per-commit cost is the Claude synthesis spawn; run several at once. Safe:
288
297
  // each commit drafts independently and writes its OWN decision file atomically,
@@ -322,8 +331,8 @@ program
322
331
  .option("--overlay", "alias of --private")
323
332
  .option("--commit", "after a capture, also git add+commit the repo the decision landed in (default: follows auto-commit, ON unless opted out) — the overlay is also pushed; the public .hunch/ rides your next push")
324
333
  .option("--no-commit", "skip the auto-commit for this capture even when auto-commit is on")
325
- .option("--deep", "Deep Synthesis: ensemble every available subscription CLI and reconcile their drafts (agreement-weighted, advisory). Slower; subscription-only")
326
- .option("--verify", "Critic pass: audit the draft against its commit, prune unsupported alternatives/consequences, down-weight weak grounding (extra subscription call; advisory)")
334
+ .option("--deep", "Deep Synthesis: ensemble every available LLM provider and reconcile their drafts (agreement-weighted, advisory). Slower; uses configured subscriptions/local endpoint")
335
+ .option("--verify", "Critic pass: audit the draft against its commit, prune unsupported alternatives/consequences, down-weight weak grounding (extra provider call; advisory)")
327
336
  .option("--samples <n>", "self-consistency depth when only one CLI is installed: sample it n times and reconcile (default 2 under --deep)")
328
337
  .action(async (sha, opts) => {
329
338
  const { store, root } = storeFor();
@@ -1948,6 +1957,23 @@ experimentCmd
1948
1957
  store.close();
1949
1958
  }
1950
1959
  });
1960
+ experimentCmd
1961
+ .command("qualify")
1962
+ .description("Record a passing excluded comprehension check before an EXP-03 revision-2 timed review.")
1963
+ .argument("<file>", "reviewer qualification JSON")
1964
+ .action((file) => {
1965
+ const { store, root } = storeFor();
1966
+ try {
1967
+ const input = JSON.parse(readFileSync(resolve(file), "utf8"));
1968
+ console.log(JSON.stringify(new ConstitutionService(store, root).qualifyExperimentReviewer(input), null, 2));
1969
+ }
1970
+ catch (e) {
1971
+ fail(e.message);
1972
+ }
1973
+ finally {
1974
+ store.close();
1975
+ }
1976
+ });
1951
1977
  experimentCmd
1952
1978
  .command("next")
1953
1979
  .description("Start or resume the next randomized EXP-03 human review and return only its assigned treatment.")
@@ -2744,15 +2770,15 @@ program
2744
2770
  const next = writeConfig(paths, { firmness: level }).firmness;
2745
2771
  console.log(`✓ firmness set to ${next} (takes effect on the next agent edit — no restart needed).`);
2746
2772
  });
2747
- // ---- provider (per-user synthesis subscription choice) -------------------
2773
+ // ---- provider (per-user synthesis provider choice) -----------------------
2748
2774
  program
2749
2775
  .command("provider")
2750
- .description("Show or set the local coding-assistant subscription Hunch may use for synthesis. Never changes team config.")
2776
+ .description("Show or set the local LLM provider Hunch may use for synthesis. Never changes team config.")
2751
2777
  .argument("[name]", `auto | ${SYNTH_PREFERENCES.filter((p) => p !== "auto").join(" | ")} (omit to inspect)`)
2752
2778
  .action(async (value) => {
2753
2779
  const root = findRoot();
2754
2780
  if (value != null) {
2755
- const preference = value.trim();
2781
+ const preference = normalizeProviderName(value.trim()) ?? value.trim();
2756
2782
  if (!SYNTH_PREFERENCES.includes(preference)) {
2757
2783
  return fail(`provider must be one of: ${SYNTH_PREFERENCES.join(", ")}`);
2758
2784
  }
@@ -2765,7 +2791,7 @@ program
2765
2791
  console.log(`✓ local synthesis preference set to ${preference} (gitignored; it never changes a teammate's billing choice).`);
2766
2792
  }
2767
2793
  const resolution = await resolveSynthesisProvider({ root });
2768
- const envValue = process.env.HUNCH_SYNTH_PROVIDER?.trim();
2794
+ const envValue = normalizeProviderName(process.env.HUNCH_SYNTH_PROVIDER?.trim());
2769
2795
  const local = readSynthesisPreference(root);
2770
2796
  const hasValidEnv = !!envValue && SYNTH_PREFERENCES.includes(envValue);
2771
2797
  console.log(`selected: ${resolution.provider.name} (${resolution.source})`);
@@ -2779,7 +2805,7 @@ program
2779
2805
  }
2780
2806
  if (resolution.source === "ambiguous") {
2781
2807
  const choices = resolution.statuses.filter((s) => s.name !== "deterministic" && s.available).map((s) => `hunch provider ${s.name}`);
2782
- console.log(dim("Multiple subscription CLIs are available, so Hunch uses the free deterministic fallback rather than guessing which plan to spend."));
2808
+ 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
2809
  console.log(`choose one: ${choices.join(" or ")}`);
2784
2810
  }
2785
2811
  else if (resolution.source === "unavailable-preference") {
@@ -3313,7 +3339,7 @@ program
3313
3339
  console.log("✓ No drafts to auto-review.");
3314
3340
  return;
3315
3341
  }
3316
- // Delegate relevance to the harness (subscription CLI) — feature-detected.
3342
+ // Delegate relevance to the configured LLM provider — feature-detected.
3317
3343
  // A dry-run may remain partial (missing verdicts are kept), but --apply is
3318
3344
  // all-or-nothing when judgment was requested: a provider outage must never
3319
3345
  // turn an incomplete batch into an apparently safe mutation plan.
@@ -3342,7 +3368,7 @@ program
3342
3368
  }
3343
3369
  }
3344
3370
  else {
3345
- console.log(dim("No subscription CLI available — relevance judgment skipped (dedup + grounding only)."));
3371
+ console.log(dim("No LLM synthesis provider available — relevance judgment skipped (dedup + grounding only)."));
3346
3372
  judgmentFailures.push(...drafts.map((d) => ({ id: d.id, error: "no subscription relevance judge available" })));
3347
3373
  }
3348
3374
  }
@@ -3779,7 +3805,7 @@ program
3779
3805
  // ---- wiki (generated component wiki — a derived VIEW of the graph) ----------
3780
3806
  program
3781
3807
  .command("wiki")
3782
- .description("Generate a component wiki from the graph — pages are a derived VIEW (the graph stays the source of truth), pinned with hunch:topic anchors and freshness-hashed into a wiki-manifest. Stale pages surface as wiki-stale in `hunch drift`; --heal regenerates ONLY those. Prose via a subscription CLI when available; deterministic template otherwise. Default: PUBLIC-store records only, written to <repo>/wiki/. With --private: the FULL graph (overlay included), written into the private overlay repo — never committed here.")
3808
+ .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
3809
  .option("--dir <dir>", "output directory (default: wiki/, or the manifest's dir once adopted)")
3784
3810
  .option("--heal", "regenerate only new/stale pages (manifest hash mismatch) and remove orphans")
3785
3811
  .option("--check", "report stale pages and exit non-zero (CI gate); writes nothing")
@@ -3844,8 +3870,8 @@ program
3844
3870
  // otherwise: drift says "remove with --heal", --heal refuses to run).
3845
3871
  if (!status.entries.length && !opts.heal)
3846
3872
  return fail("no active components in the graph — run `hunch index` first.");
3847
- // Prose is optional garnish on the deterministic skeleton: subscription CLI
3848
- // only (same rule as synthesis), feature-detected, and any failure degrades
3873
+ // Prose is optional garnish on the deterministic skeleton: configured LLM
3874
+ // provider only (same guards as synthesis), feature-detected, and any failure degrades
3849
3875
  // to a template page — generation never depends on a model being present.
3850
3876
  if (opts.proseHeal && opts.llm === false)
3851
3877
  return fail("--prose-heal needs the LLM — drop --no-llm.");
@@ -3854,13 +3880,13 @@ program
3854
3880
  if (opts.llm !== false) {
3855
3881
  const provider = await selectProvider({ root });
3856
3882
  if (provider.draftProse) {
3857
- console.log(`Prose via ${provider.name} (subscription); the drift-bearing skeleton stays deterministic.`);
3883
+ console.log(`Prose via ${provider.name}; the drift-bearing skeleton stays deterministic.`);
3858
3884
  prose = (pack, excerpts) => provider.draftProse(wikiPrompt(pack, excerpts));
3859
3885
  if (opts.proseHeal)
3860
3886
  adoptionProse = (doc, content) => provider.draftProse(adoptProsePrompt(doc, content, status.decisions));
3861
3887
  }
3862
3888
  else {
3863
- console.log(`No subscription CLI available — deterministic template pages${opts.proseHeal ? " (prose-heal skipped)" : ""}.`);
3889
+ console.log(`No LLM synthesis provider available — deterministic template pages${opts.proseHeal ? " (prose-heal skipped)" : ""}.`);
3864
3890
  }
3865
3891
  }
3866
3892
  const res = await generateWiki(store, root, home, {
@@ -4087,22 +4113,16 @@ program
4087
4113
  const resolution = await resolveSynthesisProvider({ root });
4088
4114
  const provider = resolution.provider;
4089
4115
  console.log(`synthesis: ${provider.name} (${resolution.source})`);
4090
- const selected = resolution.statuses.find((s) => s.name === provider.name);
4091
- if (selected?.subscription) {
4092
- console.log(` ↳ LLM synthesis uses your ${selected.subscription}; provider API credentials are not used.`);
4093
- }
4094
- else if (resolution.source === "ambiguous") {
4095
- const names = resolution.statuses.filter((s) => s.name !== "deterministic" && s.available).map((s) => s.name);
4096
- console.log(dim(` ↳ ${names.join(", ")} are available; Hunch will not guess which subscription to spend.`));
4097
- console.log(dim(` choose one locally: ${names.map((name) => `hunch provider ${name}`).join(" or ")}`));
4098
- }
4099
- else if (resolution.source === "unavailable-preference") {
4100
- console.log(dim(` ↳ ${resolution.preference} was selected but is unavailable; using the offline heuristic.`));
4101
- }
4102
- else {
4103
- console.log(dim(` ↳ no assistant CLI found — synthesis uses the offline heuristic (advisory, low-confidence).`));
4104
- console.log(dim(` install or log into Claude Code, Codex, or Cursor; then select one with \`hunch provider <name>\`.`));
4105
- }
4116
+ // Synthesis uses the user's SUBSCRIPTION via a coding-assistant CLI or a
4117
+ // configured local/self-hosted endpoint. Public remotes require the named
4118
+ // metered opt-in. Surface which one or what's missing (issue #9:
4119
+ // openai-compat has no `subscription` and must not fall through to the
4120
+ // "no assistant CLI found" branch).
4121
+ for (const line of synthesisStatusLines(resolution, process.env))
4122
+ console.log(line);
4123
+ const ctxWarning = await maybeWarnOllamaContext(provider.name, process.env);
4124
+ if (ctxWarning)
4125
+ console.log(ctxWarning);
4106
4126
  const c = store.reindex().counts;
4107
4127
  console.log(`hunch: ${c.symbols} symbols, ${c.edges} edges, ${c.components} components, ${c.decisions} decisions, ${c.bugs} bugs, ${c.constraints} constraints`);
4108
4128
  try {
@@ -4199,9 +4219,6 @@ function reportClaudeConfigHeal() {
4199
4219
  }
4200
4220
  console.log(dim(` ↳ backup: ${res.backup}`));
4201
4221
  }
4202
- function dim(s) {
4203
- return `\x1b[2m${s}\x1b[0m`;
4204
- }
4205
4222
  function fail(msg) {
4206
4223
  console.error(`error: ${msg}`);
4207
4224
  process.exitCode = 1;
@@ -1,8 +1,61 @@
1
- /** Figures out how to re-invoke this CLI from a git hook / .mcp.json, working
2
- * both when running the built dist (plain node) and in dev via tsx. */
1
+ /** Side-effect-free shared CLI logic safe for any module (including tests)
2
+ * to import, unlike src/cli/index.ts, which runs the whole program at
3
+ * import time. Holds: how to re-invoke this CLI from a git hook / .mcp.json
4
+ * (working both when running the built dist and in dev via tsx), plus small
5
+ * formatting helpers (dim(), doctor's synthesisStatusLines()) that need the
6
+ * same import-safety to be unit-testable. */
3
7
  import { fileURLToPath } from "node:url";
8
+ import { probeOllamaNumCtx } from "../synthesis/provider.js";
4
9
  /** Published package name — used for OS-agnostic invocations (see below). */
5
10
  const PKG = "@davesheffer/hunch";
11
+ export function dim(s) {
12
+ return `\x1b[2m${s}\x1b[0m`;
13
+ }
14
+ /** The doctor command's synthesis-status line(s) for a resolved provider.
15
+ * Exported for testing — the previous version (a bare provider-name switch,
16
+ * before the resolveSynthesisProvider preference system existed) had zero
17
+ * test coverage, which is how issue #8 (openai-compat misreported as "no
18
+ * assistant CLI found") shipped unnoticed through three review passes. That
19
+ * bug resurfaces here for the same reason: resolution.statuses carries a
20
+ * `subscription` field for the CLI providers but openai-compat's is null (it
21
+ * isn't a subscription), so it must be special-cased explicitly rather than
22
+ * falling through to the "no assistant CLI" branch. */
23
+ export function synthesisStatusLines(resolution, env) {
24
+ const provider = resolution.provider;
25
+ const selected = resolution.statuses.find((s) => s.name === provider.name);
26
+ if (selected?.subscription) {
27
+ return [` ↳ LLM synthesis uses your ${selected.subscription}; provider API credentials are not used.`];
28
+ }
29
+ if (provider.name === "openai-compat") {
30
+ const base = env.HUNCH_SYNTH_BASE_URL ?? "(unset)";
31
+ const model = env.HUNCH_SYNTH_MODEL ?? "(unset)";
32
+ const keyNote = env.HUNCH_SYNTH_API_KEY ? " (HUNCH_SYNTH_API_KEY set)" : " (no API key)";
33
+ return [` ↳ LLM synthesis via local/self-hosted endpoint ${base} (model: ${model})${keyNote}`];
34
+ }
35
+ if (resolution.source === "ambiguous") {
36
+ const names = resolution.statuses.filter((s) => s.name !== "deterministic" && s.available).map((s) => s.name);
37
+ return [
38
+ dim(` ↳ ${names.join(", ")} are available; Hunch will not guess which provider to use.`),
39
+ dim(` choose one locally: ${names.map((name) => `hunch provider ${name}`).join(" or ")}`),
40
+ ];
41
+ }
42
+ if (resolution.source === "unavailable-preference") {
43
+ return [dim(` ↳ ${resolution.preference} was selected but is unavailable; using the offline heuristic.`)];
44
+ }
45
+ return [
46
+ dim(` ↳ no assistant CLI found — synthesis uses the offline heuristic (advisory, low-confidence).`),
47
+ dim(` install or log into Claude Code, Codex, or Cursor; then select one with \`hunch provider <name>\`.`),
48
+ ];
49
+ }
50
+ /** Gate + fetch the Ollama context-window advisory (issue #11): only relevant
51
+ * for the openai-compat provider, so every other provider is a no-op. Kept
52
+ * separate from synthesisStatusLines (sync, already fully covered) because
53
+ * this one makes a best-effort network call. */
54
+ export async function maybeWarnOllamaContext(providerName, env) {
55
+ if (providerName !== "openai-compat")
56
+ return null;
57
+ return probeOllamaNumCtx(env.HUNCH_SYNTH_BASE_URL ?? "", env.HUNCH_SYNTH_MODEL ?? "");
58
+ }
6
59
  export function resolveInvocation() {
7
60
  const entry = fileURLToPath(import.meta.url).replace(/invocation\.(js|ts)$/, "index.$1");
8
61
  const isDev = entry.endsWith(".ts");
@@ -245,6 +245,46 @@ export function assignmentTreatment(bank, run, assignment) {
245
245
  throw new Error(`assignment ${assignment.id} treatment hash mismatch`);
246
246
  return treatment;
247
247
  }
248
+ /** Human-facing help is deliberately outside the hash-bound treatment. It may
249
+ * explain the review task, but must never interpret the assigned evidence. */
250
+ export function experimentReviewGuide(arm) {
251
+ const notEnough = {
252
+ value: "uncompilable",
253
+ label: "Not enough information",
254
+ use_when: "The requirement does not clearly say which code relationship must always hold.",
255
+ };
256
+ if (arm === "A") {
257
+ return {
258
+ title: "Write one code rule from the requirement",
259
+ question: "Can one exact code rule be written from the requirement without guessing?",
260
+ action: "If yes, write one sentence naming the code elements, required or forbidden relationship, direct or transitive meaning, and file scope. If no, choose Not enough information.",
261
+ answer_template: "<subject> must <directly or transitively> <required or forbidden relationship> <target>; scope: <file or component>",
262
+ warning: "Use only the stated requirement. Current code may confirm names, but cannot add intent.",
263
+ choices: [
264
+ { value: "accepted_precise", label: "Rule written", use_when: "Your sentence expresses one exact rule fully supported by the requirement." },
265
+ notEnough,
266
+ ],
267
+ };
268
+ }
269
+ const choices = [
270
+ { value: "accepted_precise", label: "Yes — exact match", use_when: "The proposed rule says exactly what the requirement says." },
271
+ { value: "accepted_edited", label: "Needs editing", use_when: "The requirement supports one rule, but the proposal needs a specific correction." },
272
+ { value: "rejected", label: "Unsupported", use_when: "The proposal adds or changes meaning that the requirement does not support." },
273
+ notEnough,
274
+ ];
275
+ return {
276
+ title: arm === "C" ? "Check the proposed rule and its proof card" : "Check the proposed rule",
277
+ question: "Does the proposed rule say exactly what the requirement says?",
278
+ action: arm === "C"
279
+ ? "Compare the requirement with the proposed rule, then use the proof card only to check that the named code targets are bound correctly."
280
+ : "Compare the requirement with the proposed rule. Check the relationship, direction, direct or transitive meaning, and scope.",
281
+ answer_template: "Choose one plain-language option; include corrected rule text only for Needs editing.",
282
+ warning: arm === "C"
283
+ ? "The proof card can verify code bindings, but it cannot add intent missing from the requirement."
284
+ : "Do not infer intent from the current implementation or from nearby code.",
285
+ choices,
286
+ };
287
+ }
248
288
  export function experimentRunContentHash(run) {
249
289
  const { id: _id, content_hash: _hash, ...body } = run;
250
290
  return canonicalHash(body);
@@ -537,6 +577,38 @@ export function compileExperimentReviewStart(run, assignment, reviewer, opts = {
537
577
  const contentHash = canonicalHash(body);
538
578
  return ExperimentReviewStartSchema.parse({ id: `expreview_${shortHash(contentHash)}`, content_hash: contentHash, ...body });
539
579
  }
580
+ export const ExperimentReviewerQualificationSchema = z.object({
581
+ id: z.string().regex(/^expreviewqual_[a-f0-9]{10}$/),
582
+ content_hash: z.string().regex(HASH),
583
+ preregistration_id: z.string().regex(/^expreg_[a-f0-9]{10}$/),
584
+ preregistration_hash: z.string().regex(HASH),
585
+ reviewer: z.string().regex(/^human:[^\s]+$/i),
586
+ protocol: z.literal("exp03-plain-language-comprehension-v2"),
587
+ cases_hash: z.string().regex(HASH),
588
+ passed: z.literal(true),
589
+ reason: z.string().trim().min(1).max(4000),
590
+ data_class: z.literal("private"),
591
+ authority: z.literal("none"),
592
+ recorded_at: z.string().datetime({ offset: true }),
593
+ }).strict();
594
+ export function experimentReviewerQualificationContentHash(record) {
595
+ const { id: _id, content_hash: _hash, ...body } = record;
596
+ return canonicalHash(body);
597
+ }
598
+ export function compileExperimentReviewerQualification(input, preregistration, opts = {}) {
599
+ if (preregistration.experiment !== "EXP-03" || preregistration.revision < 2)
600
+ throw new Error("plain-language reviewer qualification requires EXP-03 revision 2 or later");
601
+ if (input.preregistration_id !== preregistration.id || input.preregistration_hash !== preregistration.content_hash)
602
+ throw new Error("reviewer qualification must bind the exact current preregistration");
603
+ const body = {
604
+ ...input,
605
+ data_class: "private",
606
+ authority: "none",
607
+ recorded_at: opts.now ?? new Date().toISOString(),
608
+ };
609
+ const contentHash = canonicalHash(body);
610
+ return ExperimentReviewerQualificationSchema.parse({ id: `expreviewqual_${shortHash(contentHash)}`, content_hash: contentHash, ...body });
611
+ }
540
612
  export const ExperimentFollowupSchema = z.object({
541
613
  id: z.string().regex(/^expfollow_[a-f0-9]{10}$/),
542
614
  content_hash: z.string().regex(HASH),
@@ -943,6 +1015,24 @@ export class ExperimentRepository {
943
1015
  this.put("experiment-review-starts", parsed.id, parsed);
944
1016
  return parsed;
945
1017
  }
1018
+ listReviewerQualifications() {
1019
+ return this.load("experiment-review-qualifications", "expreviewqual_", (raw) => {
1020
+ const parsed = ExperimentReviewerQualificationSchema.parse(raw);
1021
+ if (experimentReviewerQualificationContentHash(parsed) !== parsed.content_hash || parsed.id !== `expreviewqual_${shortHash(parsed.content_hash)}`)
1022
+ throw new Error(`experiment reviewer qualification ${parsed.id} content hash mismatch`);
1023
+ return parsed;
1024
+ }).sort((a, b) => a.id.localeCompare(b.id));
1025
+ }
1026
+ putReviewerQualification(record) {
1027
+ const parsed = ExperimentReviewerQualificationSchema.parse(record);
1028
+ if (experimentReviewerQualificationContentHash(parsed) !== parsed.content_hash || parsed.id !== `expreviewqual_${shortHash(parsed.content_hash)}`)
1029
+ throw new Error(`experiment reviewer qualification ${parsed.id} content hash mismatch`);
1030
+ const incumbent = this.listReviewerQualifications().find((item) => item.preregistration_id === parsed.preregistration_id && item.reviewer === parsed.reviewer);
1031
+ if (incumbent)
1032
+ return incumbent;
1033
+ this.put("experiment-review-qualifications", parsed.id, parsed);
1034
+ return parsed;
1035
+ }
946
1036
  listFollowups() {
947
1037
  const records = this.load("experiment-followups", "expfollow_", (raw) => {
948
1038
  const parsed = ExperimentFollowupSchema.parse(raw);
@@ -31,7 +31,7 @@ import { evaluateExecutableBehaviorPolicy } from "./behaviorEvaluator.js";
31
31
  import { executeG2OperationalDrill } from "./g2Drills.js";
32
32
  import { G3_REQUIRED_EXPERIMENTS, G3EvidenceRepository, compileExperimentPreregistration, compileG3Plan, compileProofReviewMeasurement, scoreG3Readiness, } from "./g3.js";
33
33
  import { executeG3AdapterConformance, g3ConformanceSourceHash } from "./g3Conformance.js";
34
- import { ExperimentRepository, assignmentTreatment, buildExperimentReport, compileExperimentCaseBank, compileExperimentFollowup, compileExperimentOutcome, compileExperimentReviewStart, compileExperimentRun, compileExperimentStop, compileExp03ReviewResponse, currentExperimentOutcomes, normalizedEditDistance, } from "./experiment.js";
34
+ import { ExperimentRepository, assignmentTreatment, buildExperimentReport, compileExperimentCaseBank, compileExperimentFollowup, compileExperimentOutcome, compileExperimentReviewStart, compileExperimentReviewerQualification, compileExperimentRun, compileExperimentStop, compileExp03ReviewResponse, currentExperimentOutcomes, experimentReviewGuide, normalizedEditDistance, } from "./experiment.js";
35
35
  import { executeExp01Assignment } from "./experimentRunner.js";
36
36
  function relationSummary(policy) {
37
37
  return {
@@ -492,11 +492,22 @@ export class ConstitutionService {
492
492
  const bank = this.experimentRepository.listCaseBanks().find((item) => item.id === run.case_bank_id);
493
493
  if (!bank)
494
494
  throw new Error(`run ${run.id} is missing exact case bank ${run.case_bank_id}`);
495
+ const preregistration = this.g3Repository.listExperiments().find((item) => item.id === run.preregistration_id && item.content_hash === run.preregistration_hash);
496
+ const usesPlainLanguageReview = bank.cases.some((item) => "required_relationship" in item);
497
+ if (usesPlainLanguageReview && !preregistration)
498
+ throw new Error(`run ${run.id} is missing exact preregistration ${run.preregistration_id}`);
499
+ if (preregistration && preregistration.revision >= 2) {
500
+ const qualification = this.experimentRepository.listReviewerQualifications().find((item) => item.preregistration_id === preregistration.id && item.preregistration_hash === preregistration.content_hash && item.reviewer === reviewer);
501
+ if (!qualification)
502
+ throw new Error(`${reviewer} must pass the excluded plain-language comprehension check before a revision-${preregistration.revision} timed review`);
503
+ const targetReviewers = new Set(bank.cases.map((item) => item.strata.target_reviewer).filter(Boolean));
504
+ if (targetReviewers.has(reviewer) || targetReviewers.has(reviewer.replace(/^human:/i, "")))
505
+ throw new Error(`${reviewer} cannot perform timed reviews because the same actor labeled revision-${preregistration.revision} targets`);
506
+ }
495
507
  // Single-operator mitigation (expreg_9c9617cd13, revision >= 3): at least 48 hours
496
508
  // must separate the case-bank lock from the FIRST review start — enforced, not
497
509
  // merely auditable, so a violation is impossible rather than post-hoc visible.
498
- const prereg = this.g3Repository.listExperiments().find((item) => item.id === run.preregistration_id);
499
- if (prereg && prereg.revision >= 3) {
510
+ if (preregistration && preregistration.revision >= 3) {
500
511
  const elapsed = Date.parse(opts.now ?? new Date().toISOString()) - Date.parse(bank.locked_at);
501
512
  const hasStart = this.experimentRepository.listReviewStarts().some((item) => item.run_id === run.id);
502
513
  if (!hasStart && elapsed < 48 * 3_600_000) {
@@ -518,7 +529,13 @@ export class ConstitutionService {
518
529
  if (!assignment)
519
530
  throw new Error(`no unreviewed EXP-03 assignment is available for ${reviewer}`);
520
531
  const start = existing ?? this.experimentRepository.putReviewStart(compileExperimentReviewStart(run, assignment, reviewer, opts));
521
- return { start, assignment, treatment: assignmentTreatment(bank, run, assignment) };
532
+ return { start, assignment, treatment: assignmentTreatment(bank, run, assignment), review_guide: experimentReviewGuide(assignment.arm) };
533
+ }
534
+ qualifyExperimentReviewer(input, opts = {}) {
535
+ const preregistration = this.g3Repository.currentExperiments().find((item) => item.experiment === "EXP-03");
536
+ if (!preregistration)
537
+ throw new Error("no current EXP-03 preregistration");
538
+ return this.experimentRepository.putReviewerQualification(compileExperimentReviewerQualification(input, preregistration, opts));
522
539
  }
523
540
  /** Resolve an EXP-03 run/assignment/case triple (the shared lookup for both
524
541
  * review-submission dialects). */
@@ -3,7 +3,7 @@
3
3
  * "add embeddings once keyword search proves insufficient" upgrade).
4
4
  *
5
5
  * Embeddings are LOCAL and FREE. Anthropic has no embeddings endpoint and the
6
- * project is subscription-only (see synthesis/provider.ts), so we run a small
6
+ * project avoids implicit metered inference (see synthesis/provider.ts), so we run a small
7
7
  * sentence-transformer locally via transformers.js. That library is an OPTIONAL
8
8
  * dependency, dynamically imported — if it isn't installed, `selectEmbedder()`
9
9
  * returns null and the whole feature degrades to pure FTS (the lean-install