@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/README.md +60 -336
- package/dist/cli/index.js +54 -37
- package/dist/cli/invocation.js +55 -2
- package/dist/constitution/experiment.js +90 -0
- package/dist/constitution/service.js +21 -4
- 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 +9 -2
- package/tooling/competitive-watch.mjs +108 -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();
|
|
@@ -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
|
|
2773
|
+
// ---- provider (per-user synthesis provider choice) -----------------------
|
|
2748
2774
|
program
|
|
2749
2775
|
.command("provider")
|
|
2750
|
-
.description("Show or set the local
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
3848
|
-
// only (same
|
|
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}
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|
|
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;
|
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");
|
|
@@ -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
|
-
|
|
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). */
|
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
|