@davesheffer/hunch 1.18.1 → 1.19.0

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.
@@ -25,14 +25,735 @@
25
25
  import { mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
26
26
  import { tmpdir } from "node:os";
27
27
  import { join } from "node:path";
28
+ export const EXECUTION_OBLIGATION_CATEGORIES = [
29
+ "evidence",
30
+ "behavior",
31
+ "types",
32
+ "serialization",
33
+ "compatibility",
34
+ "other",
35
+ ];
36
+ export const CONTRACT_AXES = ["runtime", "static", "serialization", "compatibility"];
28
37
  export const emptyState = () => ({
29
38
  turn: 0,
30
39
  soulInjected: false,
31
40
  blocks: 0,
41
+ probeBlocks: 0,
32
42
  domains: {},
33
43
  editedFiles: [],
34
44
  verifyAfterEdit: true,
45
+ editGeneration: 0,
46
+ obligations: [],
47
+ proofActivity: 0,
48
+ proofReminderActivity: 0,
49
+ proofReminders: 0,
35
50
  });
51
+ const MAX_OBLIGATIONS = 12;
52
+ const MAX_ALTERNATIVES = 6;
53
+ const MAX_TOKENS = 8;
54
+ const MAX_OUTPUT_MARKERS = 8;
55
+ const MAX_PROBES = 3;
56
+ function boundedText(value, max) {
57
+ if (typeof value !== "string")
58
+ return null;
59
+ const text = value.replace(/\s+/g, " ").trim();
60
+ return text && text.length <= max ? text : null;
61
+ }
62
+ function normalizeAlternatives(value) {
63
+ if (!Array.isArray(value))
64
+ return [];
65
+ return value.slice(0, MAX_ALTERNATIVES)
66
+ .filter(Array.isArray)
67
+ .map((alternative) => alternative.slice(0, MAX_TOKENS).map((token) => boundedText(token, 120)).filter((token) => !!token))
68
+ .filter((alternative) => alternative.length > 0);
69
+ }
70
+ function normalizeExpectation(value) {
71
+ const raw = value && typeof value === "object" && !Array.isArray(value)
72
+ ? value
73
+ : null;
74
+ if (!raw || typeof raw.success !== "boolean")
75
+ return null;
76
+ const normalizeMarkers = (markers) => {
77
+ if (markers === undefined)
78
+ return undefined;
79
+ if (!Array.isArray(markers))
80
+ return [];
81
+ return markers.slice(0, MAX_OUTPUT_MARKERS)
82
+ .map((marker) => boundedText(marker, 160))
83
+ .filter((marker) => !!marker);
84
+ };
85
+ const outputIncludes = normalizeMarkers(raw.output_includes);
86
+ const outputExcludes = normalizeMarkers(raw.output_excludes);
87
+ if (outputIncludes?.length === 0 || outputExcludes?.length === 0)
88
+ return null;
89
+ return {
90
+ success: raw.success,
91
+ ...(outputIncludes ? { output_includes: outputIncludes } : {}),
92
+ ...(outputExcludes ? { output_excludes: outputExcludes } : {}),
93
+ };
94
+ }
95
+ /** Validate untrusted MCP/env episode data. Invalid entries are ignored so the
96
+ * hook's fail-open safety posture remains intact. */
97
+ export function normalizeExecutionObligations(value) {
98
+ if (!Array.isArray(value))
99
+ return [];
100
+ const out = [];
101
+ const seen = new Set();
102
+ for (const raw of value.slice(0, MAX_OBLIGATIONS)) {
103
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
104
+ continue;
105
+ const item = raw;
106
+ const id = boundedText(item.id, 100);
107
+ const description = boundedText(item.description, 320);
108
+ const origin = item.origin;
109
+ const category = item.category;
110
+ const phase = item.phase;
111
+ if (!id || !/^[A-Za-z0-9._:-]+$/.test(id) || seen.has(id) || !description)
112
+ continue;
113
+ if (origin !== "memory" && origin !== "episode" && origin !== "manual")
114
+ continue;
115
+ if (!EXECUTION_OBLIGATION_CATEGORIES.includes(category))
116
+ continue;
117
+ if (phase !== "before-edit" && phase !== "session" && phase !== "after-edit")
118
+ continue;
119
+ const alternatives = normalizeAlternatives(item.command_alternatives);
120
+ if (!alternatives.length)
121
+ continue;
122
+ const expected = normalizeExpectation(item.expected);
123
+ if (!expected)
124
+ continue;
125
+ const rawProbe = item.probe && typeof item.probe === "object" && !Array.isArray(item.probe)
126
+ ? item.probe
127
+ : null;
128
+ const probeId = boundedText(rawProbe?.id, 100);
129
+ const probeStage = rawProbe?.stage;
130
+ const probeClaim = boundedText(rawProbe?.claim, 480);
131
+ const probeFalsifier = boundedText(rawProbe?.falsifier, 480);
132
+ const probeCommand = boundedText(rawProbe?.command, 1_600);
133
+ const probe = probeId && /^[A-Za-z0-9._:-]+$/.test(probeId)
134
+ && (probeStage === "baseline" || probeStage === "validation")
135
+ && probeClaim && probeFalsifier && probeCommand
136
+ ? { id: probeId, stage: probeStage, claim: probeClaim, falsifier: probeFalsifier, command: probeCommand }
137
+ : undefined;
138
+ seen.add(id);
139
+ out.push({
140
+ id,
141
+ origin,
142
+ category: category,
143
+ phase,
144
+ description,
145
+ command_alternatives: alternatives,
146
+ expected,
147
+ ...(probe ? { probe } : {}),
148
+ });
149
+ }
150
+ return out;
151
+ }
152
+ /** Validate untrusted probe specs. Probes are declarative data; only the agent
153
+ * sees the bounded command, and ordinary tool hooks observe its result. */
154
+ export function normalizeExecutableProbes(value) {
155
+ if (!Array.isArray(value))
156
+ return [];
157
+ const probes = [];
158
+ const seen = new Set();
159
+ for (const raw of value.slice(0, MAX_PROBES)) {
160
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
161
+ continue;
162
+ const item = raw;
163
+ const id = boundedText(item.id, 100);
164
+ const claim = boundedText(item.claim, 480);
165
+ const falsifier = boundedText(item.falsifier, 480);
166
+ const command = boundedText(item.command, 1_600);
167
+ const origin = item.origin;
168
+ const category = item.category;
169
+ const alternatives = normalizeAlternatives(item.command_alternatives);
170
+ const expectedBefore = normalizeExpectation(item.expected_before);
171
+ const expectedAfter = normalizeExpectation(item.expected_after);
172
+ const rawArtifact = item.artifact && typeof item.artifact === "object" && !Array.isArray(item.artifact)
173
+ ? item.artifact
174
+ : null;
175
+ const artifactPath = boundedText(rawArtifact?.path, 200);
176
+ const artifactContent = typeof rawArtifact?.content === "string" && rawArtifact.content.length > 0 && rawArtifact.content.length <= 4_000
177
+ ? rawArtifact.content
178
+ : null;
179
+ const artifact = artifactPath && /^\.hunch-probes\/[A-Za-z0-9._/-]+$/.test(artifactPath)
180
+ && !artifactPath.split("/").includes("..") && artifactContent
181
+ ? { path: artifactPath, content: artifactContent }
182
+ : undefined;
183
+ if (rawArtifact && !artifact)
184
+ continue;
185
+ if (!id || !/^[A-Za-z0-9._:-]+$/.test(id) || seen.has(id) || !claim || !falsifier || !command)
186
+ continue;
187
+ if (origin !== "memory" && origin !== "episode" && origin !== "manual")
188
+ continue;
189
+ if (!EXECUTION_OBLIGATION_CATEGORIES.includes(category))
190
+ continue;
191
+ if (!alternatives.length || !expectedBefore || !expectedAfter)
192
+ continue;
193
+ seen.add(id);
194
+ probes.push({
195
+ id,
196
+ origin,
197
+ category: category,
198
+ claim,
199
+ falsifier,
200
+ command,
201
+ ...(artifact ? { artifact } : {}),
202
+ command_alternatives: alternatives,
203
+ expected_before: expectedBefore,
204
+ expected_after: expectedAfter,
205
+ });
206
+ }
207
+ return probes;
208
+ }
209
+ const CONTRACT_AXIS_BY_CATEGORY = {
210
+ behavior: "runtime",
211
+ types: "static",
212
+ serialization: "serialization",
213
+ compatibility: "compatibility",
214
+ };
215
+ function markerAssignments(expectation) {
216
+ const assignments = new Map();
217
+ for (const marker of expectation.output_includes ?? []) {
218
+ const match = marker.match(/^([^=\s]+)=([^\s]+)$/);
219
+ if (match)
220
+ assignments.set(match[1].toLowerCase(), match[2].toLowerCase());
221
+ }
222
+ return assignments;
223
+ }
224
+ /** Infer which contract consumers an immutable executable contrast actually
225
+ * exercises. This is deliberately conservative: static coverage requires both
226
+ * type-shaped source and a typechecking command, while compatibility requires
227
+ * at least one key=value control that stays identical across red and green. */
228
+ export function discoverExecutableProbeContractAxes(value) {
229
+ const probe = normalizeExecutableProbes([value])[0];
230
+ if (!probe)
231
+ return [];
232
+ const source = probe.artifact?.content ?? "";
233
+ const command = [probe.command, ...probe.command_alternatives.flat()].join(" ");
234
+ const axes = new Set();
235
+ if (probe.category === "behavior" || /\.(?:safeParse|parse|parseAsync)\s*\(/.test(source))
236
+ axes.add("runtime");
237
+ if (/\b(?:tsc|typecheck)\b|--typecheck\b/i.test(command)
238
+ && /expectTypeOf|\bz\.(?:input|output)\b|\[\s*["']_zod["']\s*\]|\b(?:Input|Output)\s*</.test(source)) {
239
+ axes.add("static");
240
+ }
241
+ if (/toJSONSchema|jsonSchema|serialize|deserialize|openapi/i.test(source))
242
+ axes.add("serialization");
243
+ const before = markerAssignments(probe.expected_before);
244
+ const after = markerAssignments(probe.expected_after);
245
+ const stableControls = [...before].filter(([key, result]) => key !== "state" && after.get(key) === result);
246
+ if (probe.category === "compatibility" || stableControls.length > 0)
247
+ axes.add("compatibility");
248
+ return CONTRACT_AXES.filter((axis) => axes.has(axis));
249
+ }
250
+ /** Compare a contrast with its broader proof plan. This audit is diagnostic:
251
+ * category-labelled regression tests are not proof that an axis is closed. */
252
+ export function auditExecutableProbeContractAxes(probeValue, obligationValue) {
253
+ const obligations = normalizeExecutionObligations(obligationValue);
254
+ const requiredSet = new Set();
255
+ for (const obligation of obligations) {
256
+ if (obligation.phase !== "after-edit")
257
+ continue;
258
+ const axis = CONTRACT_AXIS_BY_CATEGORY[obligation.category];
259
+ if (axis)
260
+ requiredSet.add(axis);
261
+ }
262
+ const coveredSet = new Set(discoverExecutableProbeContractAxes(probeValue));
263
+ const required = CONTRACT_AXES.filter((axis) => requiredSet.has(axis));
264
+ const covered = CONTRACT_AXES.filter((axis) => coveredSet.has(axis));
265
+ const missing = required.filter((axis) => !coveredSet.has(axis));
266
+ return { required, covered, missing };
267
+ }
268
+ /** Promote only independently falsifiable red→green probes for uncovered axes.
269
+ * A passing neighboring test is intentionally ineligible: the V experiment
270
+ * showed that it can resolve every receipt while leaving the claimed static
271
+ * contract wrong. */
272
+ export function compileContractAxisProbeClosure(contrastValue, obligationValue, candidateValue) {
273
+ const audit = auditExecutableProbeContractAxes(contrastValue, obligationValue);
274
+ const missing = new Set(audit.missing);
275
+ const chosen = new Map();
276
+ for (const probe of normalizeExecutableProbes(candidateValue)) {
277
+ const axis = CONTRACT_AXIS_BY_CATEGORY[probe.category];
278
+ if (!axis || !missing.has(axis) || chosen.has(axis))
279
+ continue;
280
+ const before = new Set((probe.expected_before.output_includes ?? []).map((marker) => marker.toLowerCase()));
281
+ const after = new Set((probe.expected_after.output_includes ?? []).map((marker) => marker.toLowerCase()));
282
+ if (!before.has("state=red") || !after.has("state=green"))
283
+ continue;
284
+ chosen.set(axis, probe);
285
+ }
286
+ return {
287
+ ...audit,
288
+ probes: CONTRACT_AXES.flatMap((axis) => {
289
+ const probe = chosen.get(axis);
290
+ return probe ? [probe] : [];
291
+ }),
292
+ };
293
+ }
294
+ /** Collapse a qualified axis closure into one staged red→green probe. The main
295
+ * contrast runs first on both passes. While it is red, consumer probes are
296
+ * skipped; once it turns green, only then are the independently qualified
297
+ * static/serialization/compatibility commands executed. This keeps the biting
298
+ * closure of W without forcing every baseline into the agent's pre-edit loop. */
299
+ export function compileAdaptiveContractAxisProbeClosure(contrastValue, obligationValue, candidateValue) {
300
+ const closure = compileContractAxisProbeClosure(contrastValue, obligationValue, candidateValue);
301
+ const disclosures = closure.probes.map(({ category, claim, falsifier }) => ({ category, claim, falsifier }));
302
+ const contrast = normalizeExecutableProbes([contrastValue])[0];
303
+ if (!contrast || closure.probes.length === 0)
304
+ return { ...closure, probe: null, disclosures };
305
+ const slug = contrast.id.replace(/[^A-Za-z0-9._-]+/g, "-").slice(0, 80);
306
+ const artifactPath = `.hunch-probes/${slug}-adaptive.mjs`;
307
+ const specs = [contrast, ...closure.probes].map((probe, index) => ({
308
+ label: index === 0 ? "main" : probe.category,
309
+ command: probe.command,
310
+ expected: probe.expected_after,
311
+ }));
312
+ const artifactContent = [
313
+ `import { spawnSync } from "node:child_process";`,
314
+ `const specs=${JSON.stringify(specs)};`,
315
+ `const run=(spec)=>{const p=spawnSync(spec.command,{shell:true,encoding:"utf8",env:process.env});const output=String(p.stdout??"")+String(p.stderr??"");if(output)process.stdout.write(output.endsWith("\\n")?output:output+"\\n");const text=output.toLowerCase();const ok=((p.status===0)===spec.expected.success)&&(spec.expected.output_includes??[]).every(x=>text.includes(x.toLowerCase()))&&!(spec.expected.output_excludes??[]).some(x=>text.includes(x.toLowerCase()));return ok;};`,
316
+ `const main=run(specs[0]);if(!main){console.log("HUNCH_ADAPTIVE state=red stage=main axes=skipped");}else{const failed=[];for(const spec of specs.slice(1)){if(!run(spec))failed.push(spec.label);}console.log("HUNCH_ADAPTIVE state="+(failed.length?"red":"green")+" stage="+(failed.length?"axes":"closed")+" axes="+(specs.length-1)+" failed="+(failed.join(",")||"none"));}`,
317
+ ].join("\n");
318
+ const probe = {
319
+ id: `${contrast.id}:adaptive`,
320
+ origin: contrast.origin,
321
+ category: contrast.category,
322
+ claim: `Make the main contrast green, then close ${closure.probes.length} independently qualified missing consumer axis${closure.probes.length === 1 ? "" : "es"}.`,
323
+ falsifier: "Reject the fix if the main behavior remains red or any qualified consumer contract stays red after the main behavior turns green.",
324
+ command: `node ${artifactPath}`,
325
+ artifact: { path: artifactPath, content: artifactContent },
326
+ command_alternatives: [["node", artifactPath]],
327
+ expected_before: {
328
+ success: true,
329
+ output_includes: ["HUNCH_ADAPTIVE", "state=red", "stage=main", "axes=skipped"],
330
+ },
331
+ expected_after: {
332
+ success: true,
333
+ output_includes: ["HUNCH_ADAPTIVE", "state=green", "stage=closed", `axes=${closure.probes.length}`, "failed=none"],
334
+ output_excludes: ["state=red"],
335
+ },
336
+ };
337
+ return { ...closure, probe: normalizeExecutableProbes([probe])[0] ?? null, disclosures };
338
+ }
339
+ /** Compile one author-ranked consumer risk into a bounded design hint. The
340
+ * selected probe must already belong to the independently qualified closure.
341
+ * Commands, claims, and falsifiers are deliberately excluded: this hint names
342
+ * where to leave design room without turning the deferred consumer into work. */
343
+ export function compileContractAxisRiskHint(closure, value) {
344
+ if (!value || typeof value !== "object" || Array.isArray(value))
345
+ return null;
346
+ const raw = value;
347
+ const probeId = boundedText(raw.probe_id, 100);
348
+ const owner = boundedText(raw.owner, 240);
349
+ if (!probeId || !owner || owner.startsWith("/") || owner.split("::")[0].split("/").includes(".."))
350
+ return null;
351
+ if (!/^[A-Za-z0-9._/-]+(?:::[A-Za-z0-9_$.-]+)?$/.test(owner))
352
+ return null;
353
+ const selected = closure.probes.find((probe) => probe.id === probeId);
354
+ return selected ? { probe_id: selected.id, category: selected.category, owner } : null;
355
+ }
356
+ const OWNER_ANCHOR_STOP_WORDS = new Set([
357
+ "array", "classic", "core", "decode", "encode", "false", "input", "mini", "number", "object", "optional",
358
+ "output", "parse", "required", "safeparse", "schema", "string", "tostring", "true",
359
+ "type", "undefined", "unknown", "value",
360
+ ]);
361
+ /** Infer one bounded risk owner using only qualified probes and pre-edit source
362
+ * text supplied by the caller. Compatibility is ranked ahead of static and
363
+ * serialization because it most often crosses a public-surface owner. Within
364
+ * the chosen probe, public identifiers nominate existing declarations; an
365
+ * explicit package surface in the probe breaks ties without reading future
366
+ * changes. */
367
+ export function rankContractAxisRiskOwners(closure, sourceValue) {
368
+ if (!Array.isArray(sourceValue) || closure.probes.length === 0)
369
+ return null;
370
+ const categoryRisk = {
371
+ compatibility: 30,
372
+ types: 20,
373
+ serialization: 10,
374
+ behavior: 5,
375
+ };
376
+ const probe = closure.probes
377
+ .map((candidate, index) => ({ candidate, index, risk: categoryRisk[candidate.category] ?? 0 }))
378
+ .sort((a, b) => b.risk - a.risk || a.index - b.index)[0]?.candidate;
379
+ if (!probe)
380
+ return null;
381
+ const artifact = probe.artifact?.content ?? "";
382
+ const words = `${probe.id} ${probe.claim} ${probe.falsifier}`.match(/[A-Za-z_$][A-Za-z0-9_$-]{3,}/g) ?? [];
383
+ const publicMembers = [...artifact.matchAll(/(?:\bz|\bvalue|\boriginal|\bcodec)\.([A-Za-z_$][A-Za-z0-9_$]*)/g)].map((match) => match[1]);
384
+ const calledMembers = [...artifact.matchAll(/(?:\bz|\bvalue|\boriginal|\bcodec)\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g)]
385
+ .map((match) => match[1]);
386
+ const calledAnchors = new Set(calledMembers.map((member) => member.toLowerCase().replace(/[^a-z0-9]/g, "")));
387
+ const candidates = new Map();
388
+ for (const [raw, weight] of [
389
+ ...words.map((word) => [word, 1]),
390
+ ...publicMembers.map((member) => [member, 4]),
391
+ ...calledMembers.map((member) => [member, 12]),
392
+ ]) {
393
+ const token = raw.replace(/-/g, "").toLowerCase();
394
+ if (token.length < 4 || OWNER_ANCHOR_STOP_WORDS.has(token))
395
+ continue;
396
+ candidates.set(token, (candidates.get(token) ?? 0) + weight);
397
+ }
398
+ if (candidates.size === 0)
399
+ return null;
400
+ const scope = artifact.match(/packages\/[A-Za-z0-9._-]+\/src\/(?:v\d+\/)?(classic|mini|core)\//)?.[1] ?? null;
401
+ const sources = [];
402
+ const seen = new Set();
403
+ for (const raw of sourceValue.slice(0, 4_000)) {
404
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
405
+ continue;
406
+ const item = raw;
407
+ const path = boundedText(item.path, 240);
408
+ const content = typeof item.content === "string" && item.content.length <= 1_000_000 ? item.content : null;
409
+ if (!path || !content || seen.has(path) || path.startsWith("/") || path.split("/").includes(".."))
410
+ continue;
411
+ if (!/^[A-Za-z0-9._/-]+\.tsx?$/.test(path) || /(?:^|\/)(?:tests?|__tests__)(?:\/|$)|\.test\.tsx?$/.test(path))
412
+ continue;
413
+ seen.add(path);
414
+ sources.push({ path, content });
415
+ }
416
+ const ranked = [];
417
+ const declaration = /(?:^|\n)\s*(?:export\s+)?(?:declare\s+)?(interface|class|function|const|type)\s+([$A-Za-z_][$\w]*)/g;
418
+ for (const source of sources) {
419
+ for (const match of source.content.matchAll(declaration)) {
420
+ const kind = match[1];
421
+ const symbol = match[2];
422
+ const normalized = symbol.toLowerCase().replace(/[^a-z0-9]/g, "");
423
+ for (const [anchor, evidenceWeight] of candidates) {
424
+ if (!normalized.includes(anchor))
425
+ continue;
426
+ let score = normalized === anchor
427
+ ? probe.category === "compatibility" ? 120 : 130
428
+ : 90;
429
+ if (kind === "interface" || kind === "class")
430
+ score += probe.category === "compatibility" ? 55 : 35;
431
+ else
432
+ score += 10;
433
+ if (scope && source.path.includes(`/${scope}/`))
434
+ score += 50;
435
+ if (source.path.endsWith("/schemas.ts") || source.path.endsWith("/schema.ts"))
436
+ score += 20;
437
+ if (probe.category === "compatibility" && scope && source.path.includes(`/${scope}/`))
438
+ score += 20;
439
+ if (probe.category === "types" && /\/(?:classic|mini)\//.test(source.path))
440
+ score += 10;
441
+ if (calledAnchors.has(anchor))
442
+ score += 45;
443
+ score += Math.min(10, Math.floor(normalized.length / 4));
444
+ score += Math.min(40, evidenceWeight * 5);
445
+ ranked.push({ owner: `${source.path}::${symbol}`, anchor, score });
446
+ }
447
+ }
448
+ }
449
+ ranked.sort((a, b) => b.score - a.score || a.owner.localeCompare(b.owner));
450
+ const ownerScores = new Map();
451
+ for (const item of ranked) {
452
+ if (!ownerScores.has(item.owner))
453
+ ownerScores.set(item.owner, item);
454
+ }
455
+ const uniqueOwners = [...ownerScores.values()];
456
+ return {
457
+ probe_id: probe.id,
458
+ category: probe.category,
459
+ candidates: uniqueOwners.slice(0, 20),
460
+ };
461
+ }
462
+ export function inferContractAxisRiskHint(closure, sourceValue) {
463
+ const ranking = rankContractAxisRiskOwners(closure, sourceValue);
464
+ if (!ranking)
465
+ return null;
466
+ const uniqueOwners = ranking.candidates;
467
+ const best = uniqueOwners[0];
468
+ if (!best || best.score < 120)
469
+ return null;
470
+ const runnerUp = uniqueOwners[1];
471
+ if (!runnerUp || best.score - runnerUp.score >= 5) {
472
+ return {
473
+ hint: { probe_id: ranking.probe_id, category: ranking.category, owner: best.owner },
474
+ level: "symbol",
475
+ anchor: best.anchor,
476
+ score: best.score,
477
+ runner_up_score: runnerUp?.score ?? null,
478
+ };
479
+ }
480
+ const fileScores = new Map();
481
+ for (const item of uniqueOwners) {
482
+ const path = item.owner.split("::")[0];
483
+ if (!fileScores.has(path))
484
+ fileScores.set(path, { ...item, owner: path });
485
+ }
486
+ const files = [...fileScores.values()];
487
+ const bestFile = files[0];
488
+ const runnerUpFile = files[1];
489
+ if (!bestFile || (runnerUpFile && bestFile.score - runnerUpFile.score < 5))
490
+ return null;
491
+ return {
492
+ hint: { probe_id: ranking.probe_id, category: ranking.category, owner: bestFile.owner },
493
+ level: "file",
494
+ anchor: bestFile.anchor,
495
+ score: bestFile.score,
496
+ runner_up_score: runnerUpFile?.score ?? null,
497
+ };
498
+ }
499
+ const IMPLEMENTATION_OWNER_STOP_WORDS = new Set([
500
+ "about", "after", "again", "also", "because", "before", "being", "classic", "could", "does", "error", "expected",
501
+ "from", "have", "input", "into", "issue", "json", "mini", "object", "output", "parse", "result", "safe", "schema",
502
+ "should", "string", "that", "their", "there", "these", "this", "type", "typescript", "using", "value", "version", "when",
503
+ "where", "which", "with", "would", "zod",
504
+ ]);
505
+ function implementationOwnerTokens(value) {
506
+ const expanded = value.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z])([A-Z][a-z])/g, "$1 $2");
507
+ return (expanded.toLowerCase().match(/[a-z_$][a-z0-9_$-]{2,}/g) ?? [])
508
+ .map((token) => token.replace(/^[$_-]+|[$_-]+$/g, ""))
509
+ .filter((token) => token.length >= 3 && !IMPLEMENTATION_OWNER_STOP_WORDS.has(token));
510
+ }
511
+ /** Rank likely implementation declarations from issue/reproduction prose. This
512
+ * is deliberately separate from contract-axis owner inference: a public API
513
+ * can own a deferred consumer contract while an internal declaration owns the
514
+ * smallest patch. The ranker is deterministic BM25 over bounded pre-edit
515
+ * declaration text, with literal path/symbol disclosure recorded separately. */
516
+ export function rankIssueImplementationOwners(issueValue, sourceValue, candidateLimit = 20) {
517
+ const issue = boundedText(issueValue, 100_000);
518
+ if (!issue || !Array.isArray(sourceValue))
519
+ return null;
520
+ const issueLower = issue.toLowerCase();
521
+ const queryTokens = implementationOwnerTokens(issue);
522
+ if (queryTokens.length === 0)
523
+ return null;
524
+ const queryCounts = new Map();
525
+ for (const token of queryTokens)
526
+ queryCounts.set(token, (queryCounts.get(token) ?? 0) + 1);
527
+ const sources = [];
528
+ const seen = new Set();
529
+ for (const raw of sourceValue.slice(0, 4_000)) {
530
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
531
+ continue;
532
+ const item = raw;
533
+ const path = boundedText(item.path, 240);
534
+ const content = typeof item.content === "string" && item.content.length <= 1_000_000 ? item.content : null;
535
+ if (!path || !content || seen.has(path) || path.startsWith("/") || path.split("/").includes(".."))
536
+ continue;
537
+ if (!/^[A-Za-z0-9._/-]+\.tsx?$/.test(path) || /(?:^|\/)(?:tests?|__tests__)(?:\/|$)|\.test\.tsx?$/.test(path))
538
+ continue;
539
+ seen.add(path);
540
+ sources.push({ path, content });
541
+ }
542
+ const declarations = [];
543
+ const declaration = /^(?:export\s+)?(?:declare\s+)?(?:interface|class|function|const|type)\s+([$A-Za-z_][$\w]*)/gm;
544
+ for (const source of sources) {
545
+ const matches = [...source.content.matchAll(declaration)];
546
+ for (let index = 0; index < matches.length; index++) {
547
+ const match = matches[index];
548
+ const symbol = match[1];
549
+ const start = match.index ?? 0;
550
+ const end = matches[index + 1]?.index ?? source.content.length;
551
+ const text = `${symbol} ${symbol} ${source.content.slice(start, end)}`.slice(0, 80_000);
552
+ const tokens = implementationOwnerTokens(text);
553
+ const counts = new Map();
554
+ for (const token of tokens)
555
+ counts.set(token, (counts.get(token) ?? 0) + 1);
556
+ declarations.push({ owner: `${source.path}::${symbol}`, path: source.path, symbol, tokens, counts, length: Math.max(1, tokens.length) });
557
+ }
558
+ }
559
+ if (declarations.length === 0)
560
+ return null;
561
+ const documentFrequency = new Map();
562
+ for (const item of declarations) {
563
+ for (const token of new Set(item.tokens))
564
+ documentFrequency.set(token, (documentFrequency.get(token) ?? 0) + 1);
565
+ }
566
+ const averageLength = declarations.reduce((sum, item) => sum + item.length, 0) / declarations.length;
567
+ const genericBasenames = new Set(["api.ts", "index.ts", "schemas.ts", "types.ts", "util.ts"]);
568
+ const candidates = declarations.map((item) => {
569
+ let lexicalScore = 0;
570
+ for (const [token, queryFrequency] of queryCounts) {
571
+ const frequency = item.counts.get(token) ?? 0;
572
+ if (!frequency)
573
+ continue;
574
+ const documentsWithToken = documentFrequency.get(token) ?? 0;
575
+ const inverseDocumentFrequency = Math.log(1 + (declarations.length - documentsWithToken + 0.5) / (documentsWithToken + 0.5));
576
+ const saturation = (frequency * 2.2) / (frequency + 1.2 * (0.25 + 0.75 * (item.length / averageLength)));
577
+ lexicalScore += inverseDocumentFrequency * saturation * Math.min(3, queryFrequency);
578
+ }
579
+ const normalizedSymbol = item.symbol.toLowerCase().replace(/^\$+/, "");
580
+ const symbolPattern = new RegExp(`(^|[^a-z0-9_$])\\$?${normalizedSymbol.replace(/[$]/g, "\\$")}([^a-z0-9_$]|$)`, "i");
581
+ const symbolDisclosed = normalizedSymbol.length >= 3 && symbolPattern.test(issue);
582
+ const path = item.path.toLowerCase();
583
+ const suffix = path.replace(/^packages\/[^/]+\//, "");
584
+ const file = path.split("/").at(-1);
585
+ const pathDisclosed = issueLower.includes(path) || issueLower.includes(suffix)
586
+ || (!genericBasenames.has(file) && issueLower.includes(file));
587
+ let score = lexicalScore;
588
+ if (symbolDisclosed)
589
+ score += 24;
590
+ if (pathDisclosed)
591
+ score += 32;
592
+ if (new RegExp(`(?:\\.|\\b)${normalizedSymbol.replace(/[$]/g, "\\$")}\\s*\\(`, "i").test(issue))
593
+ score += 8;
594
+ return {
595
+ owner: item.owner,
596
+ score: Math.round(score * 100) / 100,
597
+ lexical_score: Math.round(lexicalScore * 100) / 100,
598
+ symbol_disclosed: symbolDisclosed,
599
+ path_disclosed: pathDisclosed,
600
+ };
601
+ }).sort((a, b) => b.score - a.score || a.owner.localeCompare(b.owner));
602
+ const limit = Number.isSafeInteger(candidateLimit) ? Math.max(1, Math.min(4_000, candidateLimit)) : 20;
603
+ return { candidates: candidates.slice(0, limit) };
604
+ }
605
+ /** Conservative delivery gate for implementation-owner retrieval. Thresholds
606
+ * are intentionally exposed in the result and require an absolute score plus
607
+ * a stable lead; benchmark policy may impose stricter external promotion. */
608
+ export function inferIssueImplementationOwner(issueValue, sourceValue) {
609
+ const ranking = rankIssueImplementationOwners(issueValue, sourceValue);
610
+ const best = ranking?.candidates[0];
611
+ const runnerUp = ranking?.candidates[1];
612
+ if (!best || best.score < 18 || (runnerUp && best.score - runnerUp.score < 3))
613
+ return null;
614
+ return {
615
+ owner: best.owner,
616
+ score: best.score,
617
+ runner_up_score: runnerUp?.score ?? null,
618
+ symbol_disclosed: best.symbol_disclosed,
619
+ path_disclosed: best.path_disclosed,
620
+ };
621
+ }
622
+ /** Compile one probe into two independently observed receipts. The baseline is
623
+ * eligible only before a product edit; validation is eligible only afterwards. */
624
+ export function compileExecutableProbes(value) {
625
+ return normalizeExecutableProbes(value).flatMap((probe) => {
626
+ const binding = (stage) => ({
627
+ id: probe.id,
628
+ stage,
629
+ claim: probe.claim,
630
+ falsifier: probe.falsifier,
631
+ command: probe.command,
632
+ });
633
+ return [
634
+ {
635
+ id: `${probe.id}:baseline`,
636
+ origin: probe.origin,
637
+ category: probe.category,
638
+ phase: "before-edit",
639
+ description: `Establish the pre-change result for: ${probe.claim.slice(0, 260)}`,
640
+ command_alternatives: probe.command_alternatives,
641
+ expected: probe.expected_before,
642
+ probe: binding("baseline"),
643
+ },
644
+ {
645
+ id: `${probe.id}:validation`,
646
+ origin: probe.origin,
647
+ category: probe.category,
648
+ phase: "after-edit",
649
+ description: `Re-run the same falsification probe after the latest edit: ${probe.claim.slice(0, 230)}`,
650
+ command_alternatives: probe.command_alternatives,
651
+ expected: probe.expected_after,
652
+ probe: binding("validation"),
653
+ },
654
+ ];
655
+ });
656
+ }
657
+ function sameObligation(left, right) {
658
+ const shape = (item) => ({
659
+ id: item.id,
660
+ origin: item.origin,
661
+ category: item.category,
662
+ phase: item.phase,
663
+ description: item.description,
664
+ command_alternatives: item.command_alternatives,
665
+ expected: item.expected,
666
+ ...(item.probe ? { probe: item.probe } : {}),
667
+ });
668
+ return JSON.stringify(shape(left)) === JSON.stringify(shape(right));
669
+ }
670
+ /** Add/refresh controller obligations. Replacing an origin lets a newer
671
+ * hunch_context task discard stale memory obligations without disturbing a
672
+ * benchmark episode or a manually supplied plan. */
673
+ export function armExecutionObligations(state, input, options = {}) {
674
+ const specs = normalizeExecutionObligations(input);
675
+ const existing = new Map(state.obligations.map((item) => [item.id, item]));
676
+ const retained = options.replaceOrigin
677
+ ? state.obligations.filter((item) => item.origin !== options.replaceOrigin)
678
+ : [...state.obligations];
679
+ const byId = new Map(retained.map((item) => [item.id, item]));
680
+ for (const spec of specs) {
681
+ const previous = existing.get(spec.id);
682
+ const tracked = previous && sameObligation(previous, spec)
683
+ ? previous
684
+ : { ...spec, status: "pending" };
685
+ byId.set(spec.id, tracked);
686
+ }
687
+ return { ...state, obligations: [...byId.values()].slice(0, MAX_OBLIGATIONS) };
688
+ }
689
+ export function pendingExecutionObligations(state) {
690
+ return state.obligations.filter((item) => item.status !== "satisfied");
691
+ }
692
+ /** Before-edit evidence is useful only before implementation. At firm/strict,
693
+ * deny at most two product edits per prompt until all such receipts exist;
694
+ * then fail open so a broken probe or discriminator cannot deadlock the agent. */
695
+ export function beforeEditProbeVerdict(state) {
696
+ const pending = state.obligations.filter((item) => item.phase === "before-edit" && item.status !== "satisfied");
697
+ if (!pending.length || state.probeBlocks >= 2)
698
+ return { block: false, state };
699
+ const instructions = pending.slice(0, 3).map((item) => {
700
+ const command = item.probe?.command ?? firstCommand(item);
701
+ const markers = item.expected.output_includes?.length ? ` with output including ${item.expected.output_includes.join(" + ")}` : "";
702
+ return `[${item.category}] ${item.description}: run exactly ${command}; expect ${item.expected.success ? "success" : "failure"}${markers}`;
703
+ }).join(". ");
704
+ return {
705
+ block: true,
706
+ state: { ...state, probeBlocks: state.probeBlocks + 1 },
707
+ reason: `Hunch evidence gate — complete all pre-edit evidence before editing product code. ${instructions}. ` +
708
+ `A failed command for the wrong reason does not count.`,
709
+ };
710
+ }
711
+ export function executionObligationBrief(state) {
712
+ const pending = pendingExecutionObligations(state);
713
+ if (!pending.length)
714
+ return "";
715
+ return [
716
+ `Hunch Execution Controller — ${pending.length} observable obligation(s) must be evidenced before completion:`,
717
+ ...pending.map((item, index) => {
718
+ const expectation = `expected result: ${item.expected.success ? "success" : "failure"}` +
719
+ `${item.expected.output_includes?.length ? `; output includes ${item.expected.output_includes.join(" + ")}` : ""}` +
720
+ `${item.expected.output_excludes?.length ? `; output excludes ${item.expected.output_excludes.join(" + ")}` : ""}`;
721
+ if (item.probe?.stage === "baseline") {
722
+ return `${index + 1}. [probe/before-edit] BASELINE — ${item.probe.claim} ` +
723
+ `(run exactly before any product edit: ${item.probe.command}; ${expectation}). ` +
724
+ `Disproof condition: ${item.probe.falsifier}`;
725
+ }
726
+ if (item.probe?.stage === "validation") {
727
+ return `${index + 1}. [probe/after-edit] VALIDATION — re-run probe ${item.probe.id} after the final product edit (${expectation}).`;
728
+ }
729
+ return `${index + 1}. [${item.category}/${item.phase}] ${item.description} ` +
730
+ `(accepted command: ${item.command_alternatives.map((alternative) => alternative.join(" + ")).join(" OR ")}; ${expectation})`;
731
+ }),
732
+ "Before-edit probe baselines cannot be credited after implementation starts. After-edit obligations reset whenever product code changes again. Running a command is not enough: its observed result must satisfy the expectation.",
733
+ ].join("\n");
734
+ }
735
+ /** Harness/orchestrator injection. Malformed input returns no obligations. */
736
+ export function environmentExecutionObligations(value = process.env.HUNCH_EXECUTION_OBLIGATIONS) {
737
+ if (!value)
738
+ return [];
739
+ try {
740
+ return normalizeExecutionObligations(JSON.parse(value));
741
+ }
742
+ catch {
743
+ return [];
744
+ }
745
+ }
746
+ /** Harness/orchestrator probe injection. Malformed input returns no probes. */
747
+ export function environmentExecutableProbes(value = process.env.HUNCH_EXECUTABLE_PROBES) {
748
+ if (!value)
749
+ return [];
750
+ try {
751
+ return normalizeExecutableProbes(JSON.parse(value));
752
+ }
753
+ catch {
754
+ return [];
755
+ }
756
+ }
36
757
  export const DEFAULT_PROFILES = {
37
758
  backend: {
38
759
  paths: /(^|\/)(src|lib|server|api|core|store|services?)\/|\.(ts|mts|cts|js|mjs|cjs|py|go|rs|java|rb|php)$/i,
@@ -75,7 +796,7 @@ function verifyPattern(state, profiles = DEFAULT_PROFILES) {
75
796
  // ------------------------------------------------------- state transitions
76
797
  /** New user prompt: fresh block budget. */
77
798
  export function onPrompt(state) {
78
- return { ...state, turn: state.turn + 1, blocks: 0 };
799
+ return { ...state, turn: state.turn + 1, blocks: 0, probeBlocks: 0 };
79
800
  }
80
801
  /** Edit/Write/MultiEdit landed on `path`. */
81
802
  export function onEdit(state, path, profiles = DEFAULT_PROFILES) {
@@ -84,10 +805,15 @@ export function onEdit(state, path, profiles = DEFAULT_PROFILES) {
84
805
  const domains = { ...state.domains };
85
806
  for (const d of classifyDomains(path, profiles))
86
807
  domains[d] = true;
808
+ const editGeneration = state.editGeneration + 1;
87
809
  return {
88
810
  ...state,
89
811
  domains,
90
812
  verifyAfterEdit: false,
813
+ editGeneration,
814
+ obligations: state.obligations.map((item) => item.phase === "after-edit"
815
+ ? { ...item, status: "pending", satisfied_by: undefined, satisfied_at_edit: undefined, last_attempt: undefined }
816
+ : item),
91
817
  editedFiles: state.editedFiles.includes(path) ? state.editedFiles : [...state.editedFiles, path],
92
818
  };
93
819
  }
@@ -97,17 +823,57 @@ export function onEdit(state, path, profiles = DEFAULT_PROFILES) {
97
823
  * thing you changed is verification, and uncredited real checks are how a
98
824
  * gate gets disabled out of annoyance (first live false-negative: an HTML
99
825
  * structure assertion via `node -e` was blocked on 2026-07-08). */
100
- export function onCommand(state, command, profiles = DEFAULT_PROFILES) {
826
+ export function onCommand(state, command, outcome = { status: "unknown", output: "" }, profiles = DEFAULT_PROFILES) {
827
+ const normalizedCommand = command.replace(/\s+/g, " ").trim().toLowerCase();
828
+ const obligations = state.obligations.map((item) => {
829
+ const eligible = item.phase === "session"
830
+ || (item.phase === "before-edit" && state.editGeneration === 0)
831
+ || (item.phase === "after-edit" && state.editedFiles.length > 0);
832
+ const matched = eligible && item.command_alternatives.some((alternative) => alternative.every((token) => normalizedCommand.includes(token.toLowerCase())));
833
+ if (!matched)
834
+ return item;
835
+ const normalizedOutput = outcome.output.toLowerCase();
836
+ const missingOutput = (item.expected.output_includes ?? []).filter((marker) => !normalizedOutput.includes(marker.toLowerCase()));
837
+ const forbiddenOutput = (item.expected.output_excludes ?? []).filter((marker) => normalizedOutput.includes(marker.toLowerCase()));
838
+ const expectedOutcome = outcome.status !== "unknown" && (outcome.status === "success") === item.expected.success;
839
+ const expectationMet = expectedOutcome && missingOutput.length === 0 && forbiddenOutput.length === 0;
840
+ const lastAttempt = {
841
+ command: command.slice(0, 500),
842
+ outcome: outcome.status,
843
+ expectation_met: expectationMet,
844
+ ...(missingOutput.length ? { missing_output: missingOutput } : {}),
845
+ ...(forbiddenOutput.length ? { forbidden_output: forbiddenOutput } : {}),
846
+ };
847
+ // A probe receipt is a phase-bound observation, not the status of the last
848
+ // arbitrary runner experiment. Once red/green is observed, later command
849
+ // wrappers or infrastructure failures cannot erase it; a later product edit
850
+ // still resets validation through onEdit above.
851
+ if (!expectationMet && item.status === "satisfied" && item.probe)
852
+ return item;
853
+ return expectationMet ? {
854
+ ...item,
855
+ status: "satisfied",
856
+ satisfied_by: command.slice(0, 500),
857
+ satisfied_at_edit: state.editGeneration,
858
+ last_attempt: lastAttempt,
859
+ } : {
860
+ ...item,
861
+ status: "pending",
862
+ satisfied_by: undefined,
863
+ satisfied_at_edit: undefined,
864
+ last_attempt: lastAttempt,
865
+ };
866
+ });
101
867
  if (!state.editedFiles.length)
102
- return state;
868
+ return { ...state, obligations };
103
869
  const editedFileNamed = state.editedFiles.some((f) => {
104
870
  const base = f.replace(/\\/g, "/").split("/").pop();
105
871
  return !!base && command.includes(base);
106
872
  });
107
873
  const verifyShaped = verifyPattern(state, profiles).test(command) || /node (--test|-e\b)/.test(command);
108
- if (verifyShaped || editedFileNamed)
109
- return { ...state, verifyAfterEdit: true };
110
- return state;
874
+ if ((verifyShaped || editedFileNamed) && outcome.status !== "failure")
875
+ return { ...state, obligations, verifyAfterEdit: true };
876
+ return { ...state, obligations };
111
877
  }
112
878
  /** A verification-class skill ran (/verify, /code-review) — counts as coverage. */
113
879
  export function onSkill(state, skill) {
@@ -115,6 +881,128 @@ export function onSkill(state, skill) {
115
881
  return { ...state, verifyAfterEdit: true };
116
882
  return state;
117
883
  }
884
+ const PROOF_CHECKPOINT_INTERVAL = 6;
885
+ const MAX_PROOF_REMINDERS = 8;
886
+ function firstCommand(item) {
887
+ return item.probe?.command ?? item.command_alternatives[0]?.join(" ") ?? "the supplied proof command";
888
+ }
889
+ /** Schedule bounded proof reminders while work is still happening. Stop is too
890
+ * late for clients that terminate at a hard turn budget, so the controller also
891
+ * injects checkpoints at the evidence→implementation handoff, after the first
892
+ * product edit, invalidated proof receipts, mismatched proof attempts, and a
893
+ * small activity cadence. */
894
+ export function proofCheckpoint(before, after, activity) {
895
+ if (!after.obligations.length)
896
+ return { state: after };
897
+ const proofActivity = Math.max(0, after.proofActivity) + 1;
898
+ let state = { ...after, proofActivity };
899
+ const pending = pendingExecutionObligations(state);
900
+ if (!pending.length || state.proofReminders >= MAX_PROOF_REMINDERS)
901
+ return { state };
902
+ const attempted = activity.kind === "command"
903
+ ? pending.find((item) => item.last_attempt?.command === activity.command.slice(0, 500) && !item.last_attempt.expectation_met)
904
+ : undefined;
905
+ const attemptedProbe = attempted?.probe;
906
+ const attempt = attempted?.last_attempt;
907
+ const baseline = attemptedProbe?.stage === "validation"
908
+ ? after.obligations.find((item) => item.probe?.id === attemptedProbe.id && item.probe?.stage === "baseline")
909
+ : undefined;
910
+ const baselineMarkers = new Set((baseline?.expected.output_includes ?? []).map((marker) => marker.toLowerCase()));
911
+ const changedMarkers = (attempted?.expected.output_includes ?? [])
912
+ .filter((marker) => !baselineMarkers.has(marker.toLowerCase()));
913
+ const missingMarkers = new Set((attempted?.last_attempt?.missing_output ?? []).map((marker) => marker.toLowerCase()));
914
+ const observedChanges = changedMarkers.filter((marker) => !missingMarkers.has(marker.toLowerCase()));
915
+ const survivingFailures = changedMarkers.filter((marker) => missingMarkers.has(marker.toLowerCase()));
916
+ const markerKey = (marker) => marker.match(/^([^=\s]+)=/)?.[1]?.toLowerCase();
917
+ const forbiddenMarkers = attempt?.forbidden_output ?? [];
918
+ const regressedControls = (attempt?.missing_output ?? []).flatMap((missing) => {
919
+ if (!baselineMarkers.has(missing.toLowerCase()))
920
+ return [];
921
+ const key = markerKey(missing);
922
+ const observed = key ? forbiddenMarkers.find((marker) => markerKey(marker) === key) : undefined;
923
+ return observed ? [`${missing} → ${observed}`] : [];
924
+ });
925
+ const expectedOutcomeObserved = !!attempt && attempt.outcome !== "unknown"
926
+ && (attempt.outcome === "success") === attempted?.expected.success;
927
+ const falsifierPivot = attemptedProbe?.stage === "validation"
928
+ && baseline?.status === "satisfied"
929
+ && expectedOutcomeObserved
930
+ && observedChanges.length > 0
931
+ && survivingFailures.length > 0;
932
+ const invalidated = activity.kind === "edit"
933
+ ? before.obligations.filter((item) => item.phase === "after-edit" && item.status === "satisfied").length
934
+ : 0;
935
+ const firstEdit = activity.kind === "edit" && before.editGeneration === 0 && after.editGeneration > 0;
936
+ const pendingBeforeEdit = after.editGeneration === 0 && pending.some((item) => item.phase === "before-edit");
937
+ const evidenceHandoff = after.editGeneration === 0
938
+ && before.obligations.some((item) => item.phase === "before-edit" && item.status === "pending")
939
+ && !after.obligations.some((item) => item.phase === "before-edit" && item.status === "pending")
940
+ && pending.some((item) => item.phase === "after-edit");
941
+ const cadenceDue = (after.editGeneration > 0 || pendingBeforeEdit)
942
+ && proofActivity - Math.max(0, after.proofReminderActivity) >= PROOF_CHECKPOINT_INTERVAL;
943
+ const reason = falsifierPivot
944
+ ? "falsifier-pivot"
945
+ : attempted
946
+ ? "attempt-mismatch"
947
+ : invalidated > 0
948
+ ? "proof-invalidated"
949
+ : firstEdit
950
+ ? "first-edit"
951
+ : evidenceHandoff
952
+ ? "evidence-handoff"
953
+ : cadenceDue
954
+ ? "cadence"
955
+ : undefined;
956
+ if (!reason)
957
+ return { state };
958
+ const completed = state.obligations.length - pending.length;
959
+ const ordered = [...pending].sort((left, right) => Number(right.phase === "after-edit") - Number(left.phase === "after-edit"));
960
+ const next = ordered.slice(0, 3)
961
+ .map((item) => `[${item.category}] ${item.description} — run: ${firstCommand(item)}`)
962
+ .join("; ");
963
+ let headline;
964
+ if (falsifierPivot && attempted?.probe) {
965
+ const regression = regressedControls.length
966
+ ? ` A baseline control regressed (${regressedControls.join(" + ")}).`
967
+ : "";
968
+ headline = `Hunch falsifier pivot: this edit made part of the contrast newly green (${observedChanges.join(" + ")}), while the result is still missing ${survivingFailures.join(" + ")}.${regression} Preserve the newly green behavior and inspect the first mechanism or ownership boundary unique to the surviving control. Do not rerun unchanged, broaden the same fix, or revert to baseline unless you can name a replacement mechanism and test it in the next edit. If another check conflicts, classify it against the current task and provenance as an invariant or a stale expectation; a pre-fix snapshot is not an automatic veto. Make one targeted edit, then rerun the exact probe. Falsifier: ${attempted.probe.falsifier}`;
969
+ }
970
+ else if (attempted?.last_attempt) {
971
+ const attempt = attempted.last_attempt;
972
+ const mismatch = [
973
+ `observed ${attempt.outcome}`,
974
+ ...(attempt.missing_output?.length ? [`missing output: ${attempt.missing_output.join(" + ")}`] : []),
975
+ ...(attempt.forbidden_output?.length ? [`forbidden output present: ${attempt.forbidden_output.join(" + ")}`] : []),
976
+ ].join("; ");
977
+ headline = `Hunch proof attempt did not satisfy [${attempted.category}] ${attempted.description} (${mismatch}).`;
978
+ }
979
+ else if (invalidated > 0) {
980
+ headline = `Hunch proof checkpoint: the latest product edit invalidated ${invalidated} after-edit receipt(s).`;
981
+ }
982
+ else if (firstEdit) {
983
+ headline = "Hunch proof checkpoint: product implementation has started; reserve a verification block before the turn ends.";
984
+ }
985
+ else if (evidenceHandoff) {
986
+ headline = "Hunch phase handoff: pre-edit evidence is complete. Stop expanding the diagnosis; make the smallest chosen product edit now, then prove the pending contract.";
987
+ }
988
+ else if (pendingBeforeEdit) {
989
+ headline = "Hunch evidence checkpoint: investigation is consuming the pre-edit budget; finish the narrowest prerequisite before changing product code.";
990
+ }
991
+ else {
992
+ headline = "Hunch proof checkpoint: investigation is consuming the work budget; run the narrowest pending proof now.";
993
+ }
994
+ state = {
995
+ ...state,
996
+ proofReminderActivity: proofActivity,
997
+ proofReminders: state.proofReminders + 1,
998
+ };
999
+ return {
1000
+ state,
1001
+ reason,
1002
+ reminder: `${headline} ${completed}/${state.obligations.length} expected result(s) proved. Pending: ${next}. ` +
1003
+ "A matching command alone does not count, and turn-budget exhaustion is unresolved—not completion.",
1004
+ };
1005
+ }
118
1006
  // ------------------------------------------------------------------- gates
119
1007
  export const PIPELINE_LOOP = [
120
1008
  "Hunch pipeline — operating loop (enforced on observable facts, not claims):",
@@ -126,18 +1014,30 @@ export const PIPELINE_LOOP = [
126
1014
  "6. REPORT — what ran, what passed, what stays unverified. Failures verbatim.",
127
1015
  ].join("\n");
128
1016
  export const UNVERIFIED_NAG = "Hunch pipeline: earlier product edits are still UNVERIFIED — run the relevant test/build/typecheck before claiming anything about them.";
1017
+ export function unverifiedNag(state) {
1018
+ const pending = pendingExecutionObligations(state);
1019
+ const generic = !state.verifyAfterEdit ? UNVERIFIED_NAG : "Hunch Execution Controller: completion evidence is still incomplete.";
1020
+ if (!pending.length)
1021
+ return generic;
1022
+ return `${generic} Controller obligations still pending: ${pending.slice(0, 4).map((item) => `[${item.category}] ${item.description}`).join("; ")}.`;
1023
+ }
129
1024
  /** Stop-gate verdict. Blocks only at firm/strict, only with unverified product
130
1025
  * edits, and at most twice per turn. */
131
1026
  export function stopVerdict(state, firmness) {
132
1027
  const gated = firmness === "firm" || firmness === "strict";
133
- if (!gated || state.verifyAfterEdit || state.editedFiles.length === 0 || state.blocks >= 2)
1028
+ const pending = pendingExecutionObligations(state);
1029
+ const genericPending = !state.verifyAfterEdit && state.editedFiles.length > 0;
1030
+ if (!gated || (!genericPending && !pending.length) || state.blocks >= 2)
134
1031
  return { block: false };
135
1032
  const domains = Object.keys(state.domains).join(", ") || "generic";
1033
+ const obligationReason = pending.length
1034
+ ? ` Specific obligations still pending: ${pending.slice(0, 6).map((item) => `[${item.category}/${item.phase}] ${item.description}`).join("; ")}. Run command evidence matching the supplied alternatives after the latest edit where required.`
1035
+ : "";
136
1036
  return {
137
1037
  block: true,
138
1038
  state: { ...state, blocks: state.blocks + 1 },
139
- reason: `Hunch pipeline gate — VERIFY unsatisfied. Product files were edited (${state.editedFiles.slice(-5).join(", ")}) ` +
140
- `but no verifying command ran afterwards (domain: ${domains}). Do now, in order: ` +
1039
+ reason: `Hunch pipeline gate — VERIFY unsatisfied.${genericPending ? ` Product files were edited (${state.editedFiles.slice(-5).join(", ")}) but no verifying command ran afterwards (domain: ${domains}).` : ""}` +
1040
+ obligationReason.replace("Run command evidence matching the supplied alternatives", "Run a supplied command and obtain its expected result") + ` Do now, in order: ` +
141
1041
  `(1) run the relevant test/build/typecheck for those files; ` +
142
1042
  `(2) one honest paragraph attacking your own conclusion — what would make it wrong; ` +
143
1043
  `(3) report what ran, what passed, what stays unverified. ` +
@@ -154,7 +1054,50 @@ export function pipelineEnabled() {
154
1054
  export function loadPipelineState(sessionId) {
155
1055
  try {
156
1056
  const raw = JSON.parse(readFileSync(stateFile(sessionId), "utf8"));
157
- return { ...emptyState(), ...raw };
1057
+ const state = { ...emptyState(), ...raw };
1058
+ state.proofActivity = Number.isSafeInteger(raw.proofActivity) && raw.proofActivity >= 0 ? raw.proofActivity : 0;
1059
+ state.proofReminderActivity = Number.isSafeInteger(raw.proofReminderActivity) && raw.proofReminderActivity >= 0 ? raw.proofReminderActivity : 0;
1060
+ state.proofReminders = Number.isSafeInteger(raw.proofReminders) && raw.proofReminders >= 0 ? raw.proofReminders : 0;
1061
+ state.probeBlocks = Number.isSafeInteger(raw.probeBlocks) && raw.probeBlocks >= 0 ? raw.probeBlocks : 0;
1062
+ const specs = normalizeExecutionObligations(raw.obligations);
1063
+ const tracked = new Map((Array.isArray(raw.obligations) ? raw.obligations : []).map((item) => {
1064
+ const candidate = item;
1065
+ return [candidate.id, candidate];
1066
+ }));
1067
+ state.obligations = specs.map((spec) => {
1068
+ const previous = tracked.get(spec.id);
1069
+ const rawAttempt = previous?.last_attempt && typeof previous.last_attempt === "object"
1070
+ ? previous.last_attempt
1071
+ : null;
1072
+ const attempt = rawAttempt
1073
+ && typeof rawAttempt.command === "string"
1074
+ && (rawAttempt.outcome === "success" || rawAttempt.outcome === "failure" || rawAttempt.outcome === "unknown")
1075
+ && typeof rawAttempt.expectation_met === "boolean"
1076
+ ? {
1077
+ command: rawAttempt.command.slice(0, 500),
1078
+ outcome: rawAttempt.outcome,
1079
+ expectation_met: rawAttempt.expectation_met,
1080
+ ...(Array.isArray(rawAttempt.missing_output)
1081
+ ? { missing_output: rawAttempt.missing_output.filter((item) => typeof item === "string").slice(0, MAX_OUTPUT_MARKERS) }
1082
+ : {}),
1083
+ ...(Array.isArray(rawAttempt.forbidden_output)
1084
+ ? { forbidden_output: rawAttempt.forbidden_output.filter((item) => typeof item === "string").slice(0, MAX_OUTPUT_MARKERS) }
1085
+ : {}),
1086
+ }
1087
+ : null;
1088
+ return {
1089
+ ...spec,
1090
+ status: previous?.status === "satisfied" && attempt?.expectation_met ? "satisfied" : "pending",
1091
+ ...(previous?.status === "satisfied" && attempt?.expectation_met && typeof previous.satisfied_by === "string"
1092
+ ? { satisfied_by: previous.satisfied_by.slice(0, 500) }
1093
+ : {}),
1094
+ ...(previous?.status === "satisfied" && attempt?.expectation_met && Number.isSafeInteger(previous.satisfied_at_edit)
1095
+ ? { satisfied_at_edit: previous.satisfied_at_edit }
1096
+ : {}),
1097
+ ...(attempt ? { last_attempt: attempt } : {}),
1098
+ };
1099
+ });
1100
+ return state;
158
1101
  }
159
1102
  catch {
160
1103
  return emptyState();