@inixiative/foundry-core 0.5.1 → 0.5.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.
@@ -0,0 +1,580 @@
1
+ // Warden probes: the offline measurement of how well a Warden charter keeps the chorus quiet, in
2
+ // scope and right. Data and pure functions only, so Oracle can score runs on a core-only dependency;
3
+ // callers bring the probes, the Warden calls and the judge call.
4
+ //
5
+ // A probe is one moment of a real session. A positive probe is a moment that drew the user's sharp
6
+ // correction; a negative probe is one the user let pass. Each trigger point asks about its own part
7
+ // of the moment: `user-message` about the user's ask (advise), `agent-action` about the agent's turn
8
+ // (guard). A Warden anticipated a correction when it said it first, at either trigger.
9
+
10
+ import type { TokenCounts } from '../token-counts';
11
+
12
+ /** Where the Prospector found a probe. */
13
+ export type ProbeSource = 'session' | 'github' | 'linear';
14
+
15
+ /**
16
+ * Whether the person wrote a correction or an agent wrote it under their account. `strong` rests on
17
+ * a marker (an agent footer, a posting command in a session, text typed in the web UI); `weak` on style.
18
+ */
19
+ export interface Authorship {
20
+ readonly by: 'human' | 'agent';
21
+ readonly confidence: 'strong' | 'weak';
22
+ readonly signals: readonly string[];
23
+ }
24
+
25
+ /** The trigger points a probe is asked at (live: `user-message` advises, `agent-action` guards). */
26
+ export type WardenProbeTrigger = 'user-message' | 'agent-action';
27
+
28
+ /** One real moment a chorus is asked about. Never commit probes: they hold session text. */
29
+ export interface WardenProbe {
30
+ readonly id: string;
31
+ readonly kind: 'positive' | 'negative';
32
+ /** Which context's Wardens may see it (UserEvidence probes only ever see the UserEvidence ladder). */
33
+ readonly ctx: string;
34
+ readonly project?: string;
35
+ /** Where the Prospector found it: a session, a GitHub PR review, a Linear issue. Absent means session. */
36
+ readonly source?: ProbeSource;
37
+ /** Who wrote the correction, for sources where the person's account also posts agent-written text. */
38
+ readonly authorship?: Authorship;
39
+ /** How much the probe counts in weighted scores (default 1): generic code-correctness findings count less. */
40
+ readonly weight?: number;
41
+ /** What the agent just did or said, as the Warden sees it ('' when only the user's ask was recorded). */
42
+ readonly moment: string;
43
+ /** The user's message the agent was answering: what the `user-message` trigger advises on. */
44
+ readonly ask?: string;
45
+ /** When the user answered (ISO), and the period it falls in, so a backtest trains on earlier probes only. */
46
+ readonly at?: string;
47
+ readonly period?: 'train' | 'test';
48
+ /** Positive probes: the Warden domains that own the rule the user enforced next (when known), and its one-line statements. */
49
+ readonly gold?: {
50
+ readonly domains: readonly string[];
51
+ readonly statements: readonly string[];
52
+ /** What the user pushed the agent toward: the existing rule, primitive, system or pattern. */
53
+ readonly target?: CorrectionTarget;
54
+ };
55
+ /** The user's actual next message. The judge sees it; Wardens never do. */
56
+ readonly hidden?: string;
57
+ /** The Warden's own understanding of the thread at this moment, as a live call supplies it. */
58
+ readonly threadKnowledge?: string;
59
+ }
60
+
61
+ /** The trigger points that can ask about a probe: those whose input it recorded. */
62
+ export function probeTriggers(probe: WardenProbe): WardenProbeTrigger[] {
63
+ return [
64
+ ...(probe.ask?.trim() ? (['user-message'] as const) : []),
65
+ ...(probe.moment.trim() ? (['agent-action'] as const) : []),
66
+ ];
67
+ }
68
+
69
+ /** One Warden's answer on one probe, and the judge's verdict when it spoke. */
70
+ export interface WardenProbeAnswer {
71
+ readonly probeId: string;
72
+ readonly domain: string;
73
+ /** The trigger point that asked; `agent-action` when unrecorded. */
74
+ readonly trigger?: WardenProbeTrigger;
75
+ readonly noComment: boolean;
76
+ /** The protocol was broken; counted apart, never as silence or speech. */
77
+ readonly error?: string;
78
+ readonly text?: string;
79
+ readonly elapsedMs?: number;
80
+ /** What the call used, as the provider reported it (cache reads show a warm, primed prefix). */
81
+ readonly tokens?: Omit<TokenCounts, 'providerUsage'>;
82
+ /** Transport failures of earlier attempts at this call, re-asked before this answer. */
83
+ readonly retried?: readonly string[];
84
+ readonly judged?: WardenJudgement;
85
+ }
86
+
87
+ /** One probe run as a turn: every Warden asked at once, as the live chorus is. */
88
+ export interface WardenProbeTurn {
89
+ readonly probeId: string;
90
+ readonly trigger?: WardenProbeTrigger;
91
+ /** Warden calls made for this probe. */
92
+ readonly calls: number;
93
+ /** Wall clock from the first ask to the last answer. */
94
+ readonly wallMs: number;
95
+ /** Calls the decision queue never admitted. */
96
+ readonly notAdmitted: number;
97
+ }
98
+
99
+ export interface WardenJudgement {
100
+ /** The remark says what the user's next message enforced (the same rule or the same fix). */
101
+ readonly matches: boolean;
102
+ /** The remark stays inside the speaking Warden's domain. */
103
+ readonly inDomain: boolean;
104
+ readonly reason?: string;
105
+ }
106
+
107
+ export interface CharterScore {
108
+ readonly probes: number;
109
+ readonly calls: number;
110
+ readonly errors: number;
111
+ /**
112
+ * Of the Warden answers where nothing of theirs applied (a negative probe, or a positive one owned
113
+ * by another domain, without a remark matched to the real correction), the share that were
114
+ * NO_COMMENT. A protocol error there counts as a failed silence.
115
+ */
116
+ readonly silencePrecision: number | null;
117
+ /** Of the remarks made, the share that matched the user's later real correction. */
118
+ readonly speakPrecision: number | null;
119
+ /** Remarks outside the speaking Warden's domain. */
120
+ readonly scopeViolations: number;
121
+ /** Of positive probes with gold domains, those where an owning Warden named the correction before the user did. */
122
+ readonly recall: number | null;
123
+ /** The primary measure: of all positive probes, those where any Warden, at any trigger, said the correction first. */
124
+ readonly anticipated: number | null;
125
+ /** `anticipated` with each positive counted at its `weight`. */
126
+ readonly weightedAnticipated: number | null;
127
+ /** Remarks the judge did not match to a real correction (on negative probes, or saying something else). */
128
+ readonly wrongRemarks: number;
129
+ /** Wrong remarks per probe asked: how often the chorus speaks when the user would not have. */
130
+ readonly wrongPerProbe: number | null;
131
+ /** The same measures per trigger point, over the probes that trigger could ask about. */
132
+ readonly byTrigger: Partial<
133
+ Record<
134
+ WardenProbeTrigger,
135
+ {
136
+ readonly probes: number;
137
+ readonly positives: number;
138
+ readonly anticipated: number | null;
139
+ readonly remarks: number;
140
+ readonly speakPrecision: number | null;
141
+ readonly wrongRemarks: number;
142
+ }
143
+ >
144
+ >;
145
+ readonly remarks: number;
146
+ readonly silences: number;
147
+ /** Remarks the judge could not grade: counted as unmatched, reported apart. */
148
+ readonly unjudged: number;
149
+ /** Per-turn load at roster scale: calls and wall clock, when the run recorded turns. */
150
+ readonly turns?: {
151
+ readonly count: number;
152
+ readonly callsPerTurn: number;
153
+ readonly p50Ms: number;
154
+ readonly p95Ms: number;
155
+ readonly maxMs: number;
156
+ readonly notAdmitted: number;
157
+ };
158
+ }
159
+
160
+ function percentile(sorted: readonly number[], p: number): number {
161
+ return sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))] ?? 0;
162
+ }
163
+
164
+ const ratio = (n: number, d: number) => (d ? Math.round((n / d) * 1000) / 1000 : null);
165
+
166
+ /** The 95% Wilson interval of `n` successes in `d` trials, to three places; null when nothing was tried. */
167
+ export function wilsonInterval(n: number, d: number): [number, number] | null {
168
+ if (!d) return null;
169
+ const z = 1.96;
170
+ const p = n / d;
171
+ const centre = (p + (z * z) / (2 * d)) / (1 + (z * z) / d);
172
+ const half = (z * Math.sqrt((p * (1 - p)) / d + (z * z) / (4 * d * d))) / (1 + (z * z) / d);
173
+ return [Math.round((centre - half) * 1000) / 1000, Math.round((centre + half) * 1000) / 1000];
174
+ }
175
+
176
+ /**
177
+ * Score one charter run. "Nothing applied" means a negative probe, or a positive probe whose gold
178
+ * domains do not include this Warden; a remark there that the judge matched to the real correction
179
+ * is still a correct remark, not a broken silence.
180
+ */
181
+ export function scoreCharterRun(
182
+ probes: readonly WardenProbe[],
183
+ answers: readonly WardenProbeAnswer[],
184
+ turns: readonly WardenProbeTurn[] = [],
185
+ ): CharterScore {
186
+ const byId = new Map(probes.map((p) => [p.id, p]));
187
+ let calls = 0,
188
+ errors = 0,
189
+ quietCases = 0,
190
+ quietRight = 0,
191
+ remarks = 0,
192
+ remarksRight = 0,
193
+ scope = 0,
194
+ silences = 0,
195
+ unjudged = 0;
196
+ const recalled = new Set<string>();
197
+ const anticipated = new Set<string>();
198
+ const perTrigger = new Map<
199
+ WardenProbeTrigger,
200
+ { probes: Set<string>; anticipated: Set<string>; remarks: number; right: number }
201
+ >();
202
+ for (const a of answers) {
203
+ calls++;
204
+ const probe = byId.get(a.probeId);
205
+ if (!probe) continue;
206
+ const owned = probe.kind === 'positive' && (probe.gold?.domains ?? []).includes(a.domain);
207
+ if (a.error) {
208
+ // A broken answer is neither silence nor a remark; where nothing applied, it is a failed silence.
209
+ errors++;
210
+ perTrigger.get(a.trigger ?? 'agent-action')?.probes.add(probe.id);
211
+ if (!owned) quietCases++;
212
+ continue;
213
+ }
214
+ const trigger = a.trigger ?? 'agent-action';
215
+ const tally = perTrigger.get(trigger) ?? {
216
+ probes: new Set<string>(),
217
+ anticipated: new Set<string>(),
218
+ remarks: 0,
219
+ right: 0,
220
+ };
221
+ perTrigger.set(trigger, tally);
222
+ tally.probes.add(probe.id);
223
+ if (a.noComment) silences++;
224
+ else {
225
+ remarks++;
226
+ tally.remarks++;
227
+ if (!a.judged) unjudged++;
228
+ if (a.judged?.matches) {
229
+ remarksRight++;
230
+ tally.right++;
231
+ if (owned) recalled.add(probe.id);
232
+ if (probe.kind === 'positive') {
233
+ anticipated.add(probe.id);
234
+ tally.anticipated.add(probe.id);
235
+ }
236
+ }
237
+ if (a.judged && !a.judged.inDomain) scope++;
238
+ }
239
+ if (!owned && !(a.judged?.matches ?? false)) {
240
+ quietCases++;
241
+ if (a.noComment) quietRight++;
242
+ }
243
+ }
244
+ const positives = probes.filter((p) => p.kind === 'positive');
245
+ const owners = positives.filter((p) => p.gold?.domains.length).length;
246
+ const asked = new Set(answers.map((a) => a.probeId));
247
+ const byTrigger: CharterScore['byTrigger'] = Object.fromEntries(
248
+ [...perTrigger].map(([trigger, t]) => {
249
+ const reached = probes.filter((p) => t.probes.has(p.id));
250
+ return [
251
+ trigger,
252
+ {
253
+ probes: reached.length,
254
+ positives: reached.filter((p) => p.kind === 'positive').length,
255
+ anticipated: ratio(
256
+ t.anticipated.size,
257
+ reached.filter((p) => p.kind === 'positive').length,
258
+ ),
259
+ remarks: t.remarks,
260
+ speakPrecision: ratio(t.right, t.remarks),
261
+ wrongRemarks: t.remarks - t.right,
262
+ },
263
+ ];
264
+ }),
265
+ );
266
+ return {
267
+ probes: probes.length,
268
+ calls,
269
+ errors,
270
+ silencePrecision: ratio(quietRight, quietCases),
271
+ speakPrecision: ratio(remarksRight, remarks),
272
+ scopeViolations: scope,
273
+ recall: ratio(recalled.size, owners),
274
+ anticipated: ratio(anticipated.size, positives.length),
275
+ weightedAnticipated: (() => {
276
+ const total = positives.reduce((n, p) => n + (p.weight ?? 1), 0);
277
+ const said = positives
278
+ .filter((p) => anticipated.has(p.id))
279
+ .reduce((n, p) => n + (p.weight ?? 1), 0);
280
+ return total ? Math.round((said / total) * 1000) / 1000 : null;
281
+ })(),
282
+ wrongRemarks: remarks - remarksRight,
283
+ wrongPerProbe: ratio(remarks - remarksRight, probes.filter((p) => asked.has(p.id)).length),
284
+ byTrigger,
285
+ remarks,
286
+ silences,
287
+ unjudged,
288
+ ...(turns.length
289
+ ? {
290
+ turns: (() => {
291
+ const walls = turns.map((t) => t.wallMs).sort((a, b) => a - b);
292
+ return {
293
+ count: turns.length,
294
+ callsPerTurn:
295
+ Math.round((turns.reduce((n, t) => n + t.calls, 0) / turns.length) * 10) / 10,
296
+ p50Ms: percentile(walls, 50),
297
+ p95Ms: percentile(walls, 95),
298
+ maxMs: walls.at(-1) ?? 0,
299
+ notAdmitted: turns.reduce((n, t) => n + t.notAdmitted, 0),
300
+ };
301
+ })(),
302
+ }
303
+ : {}),
304
+ };
305
+ }
306
+
307
+ /** What a correction pushed the agent toward. Absent when it pushed toward nothing that already existed. */
308
+ export interface CorrectionTarget {
309
+ /** The thing itself, named as the codebase names it (e.g. "Inquiry handler autoApprove"). */
310
+ readonly name: string;
311
+ readonly kind: 'primitive' | 'system' | 'pattern' | 'rule';
312
+ /** The concept cluster it belongs to (kebab-case, e.g. "concurrency-locking"): the routing label. */
313
+ readonly concept: string;
314
+ /** Where it lives, when the moment says (file, package, doc). */
315
+ readonly where?: string;
316
+ /** The canonical name shared by every correction that pushed toward the same thing. */
317
+ readonly canonical?: string;
318
+ /** What was built instead of using it, when the source says (PR reviews do). */
319
+ readonly builtInstead?: string;
320
+ /**
321
+ * Why it was missed, from the thread: `unaware` (didn't know it existed), `misfit` (knew it and
322
+ * worked around a gap instead of extending it), `wrapper` (wrapped it under a new name), `unclear`.
323
+ */
324
+ readonly miss?: MissKind;
325
+ }
326
+
327
+ export const MISS_KINDS = ['unaware', 'misfit', 'wrapper', 'unclear'] as const;
328
+ export type MissKind = (typeof MISS_KINDS)[number];
329
+
330
+ /** Concept clusters the labeler starts from; it may add one when none fits. */
331
+ export const CONCEPT_CLUSTERS = [
332
+ 'concurrency-locking',
333
+ 'requests-inquiry',
334
+ 'access-signets-permissions',
335
+ 'audit-log',
336
+ 'errors-responses',
337
+ 'data-schema-db',
338
+ 'queries-db-client',
339
+ 'jobs-queues-cron',
340
+ 'caching',
341
+ 'http-routes-api',
342
+ 'validation-types',
343
+ 'testing-fixtures-vcr',
344
+ 'frontend-ui',
345
+ 'email-communications',
346
+ 'config-env-setup',
347
+ 'tooling-ci-lint',
348
+ 'naming',
349
+ 'docs-conventions',
350
+ 'shared-helpers-utils',
351
+ 'process-merge-ship',
352
+ 'process-communication',
353
+ ] as const;
354
+
355
+ export const CORRECTION_TARGET_PROMPT = `You label corrections a developer gave an AI coding agent. Each item shows the developer's request, the agent's turn when recorded, and the developer's correction.
356
+
357
+ Most corrections push the agent toward something that ALREADY EXISTS: a primitive (a helper, wrapper, class, module, package export), a system (an existing subsystem or flow, e.g. the inquiry system, the audit log), a pattern (an established way the codebase does a thing), or a rule (a standing convention or process rule, e.g. "merge yourself once green", "no backwards compatibility"). Name that target.
358
+
359
+ For each item:
360
+ - target: null when the correction pushes toward nothing that already existed (a new requirement, a bug report, a fact about the world, a scope change). Otherwise an object:
361
+ - name: the thing itself, as the codebase would name it, at most 80 characters (e.g. "Inquiry handler autoApprove", "shared VCR utilities", "SELECT FOR UPDATE lock helper", "audit log records the actor"). Name the thing, not the advice.
362
+ - kind: "primitive" | "system" | "pattern" | "rule"
363
+ - concept: the concept cluster it belongs to; use one of the given clusters when one fits, else a new short kebab-case cluster.
364
+ - where: the file, package or doc it lives in, only when the item says so; else omit.
365
+
366
+ Respond with JSON only: { "results": { "<id>": { "target": null | { "name": string, "kind": string, "concept": string, "where"?: string } } } }`;
367
+
368
+ /** The labeler's input for a batch of positive probes. */
369
+ export function correctionTargetMessages(
370
+ probes: readonly Pick<WardenProbe, 'id' | 'ask' | 'moment' | 'hidden'>[],
371
+ ): { role: 'system' | 'user'; content: string }[] {
372
+ const body = probes
373
+ .map(
374
+ (p) =>
375
+ `## ${p.id}\n### Request\n${p.ask?.slice(0, 1500) || '(not recorded)'}\n\n### Agent turn\n${p.moment.slice(-1500) || '(not recorded)'}\n\n### Correction\n${(p.hidden ?? '').slice(0, 1500)}`,
376
+ )
377
+ .join('\n\n');
378
+ return [
379
+ { role: 'system', content: CORRECTION_TARGET_PROMPT },
380
+ {
381
+ role: 'user',
382
+ content: `Concept clusters: ${CONCEPT_CLUSTERS.join(', ')}\n\nLabel each item below.\n\n${body}`,
383
+ },
384
+ ];
385
+ }
386
+
387
+ const TARGET_KINDS = new Set(['primitive', 'system', 'pattern', 'rule']);
388
+
389
+ /** One answer per id: a target, null for no existing target, undefined when missing or malformed. */
390
+ export function parseCorrectionTargets(
391
+ content: string,
392
+ ids: readonly string[],
393
+ ): (CorrectionTarget | null | undefined)[] {
394
+ const start = content.indexOf('{');
395
+ const end = content.lastIndexOf('}');
396
+ let results: Record<string, { target?: unknown }> = {};
397
+ try {
398
+ results =
399
+ (JSON.parse(content.slice(start, end + 1)) as { results?: typeof results }).results ?? {};
400
+ } catch {
401
+ return ids.map(() => undefined);
402
+ }
403
+ return ids.map((id) => {
404
+ const item = results[id];
405
+ if (!item || !('target' in item)) return undefined;
406
+ if (item.target === null) return null;
407
+ const t = item.target as Record<string, unknown>;
408
+ if (typeof t.name !== 'string' || !t.name.trim() || typeof t.concept !== 'string')
409
+ return undefined;
410
+ return {
411
+ name: t.name.trim().slice(0, 120),
412
+ kind: (TARGET_KINDS.has(String(t.kind)) ? t.kind : 'pattern') as CorrectionTarget['kind'],
413
+ concept: t.concept
414
+ .trim()
415
+ .toLowerCase()
416
+ .replace(/[^a-z0-9]+/g, '-')
417
+ .replace(/^-|-$/g, ''),
418
+ ...(typeof t.where === 'string' && t.where.trim()
419
+ ? { where: t.where.trim().slice(0, 160) }
420
+ : {}),
421
+ };
422
+ });
423
+ }
424
+
425
+ export const TARGET_CANONICAL_PROMPT = `You merge names of existing codebase things (primitives, systems, patterns, rules) that developers' corrections pointed to. All names given share one concept cluster. Names that refer to the same thing get the same canonical name; different things stay apart (when unsure, keep them apart). A canonical name is the clearest of the merged names, at most 80 characters.
426
+ Respond with JSON only: { "canonical": { "<N1>": "<canonical name>", ... } } with every given key.`;
427
+
428
+ /** The canonicalization input: one concept cluster's distinct target names, keyed N1..Nn. */
429
+ export function targetCanonicalMessages(
430
+ concept: string,
431
+ names: readonly string[],
432
+ ): { role: 'system' | 'user'; content: string }[] {
433
+ return [
434
+ { role: 'system', content: TARGET_CANONICAL_PROMPT },
435
+ {
436
+ role: 'user',
437
+ content: `Cluster: ${concept}\n\n${names.map((n, i) => `N${i + 1}: ${n}`).join('\n')}`,
438
+ },
439
+ ];
440
+ }
441
+
442
+ /** Canonical names in order; a missing key keeps the name as given. */
443
+ export function parseTargetCanonical(content: string, names: readonly string[]): string[] {
444
+ try {
445
+ const parsed = JSON.parse(
446
+ content.slice(content.indexOf('{'), content.lastIndexOf('}') + 1),
447
+ ) as {
448
+ canonical?: Record<string, unknown>;
449
+ };
450
+ return names.map((name, i) => {
451
+ const value = parsed.canonical?.[`N${i + 1}`];
452
+ return typeof value === 'string' && value.trim() ? value.trim().slice(0, 120) : name;
453
+ });
454
+ } catch {
455
+ return [...names];
456
+ }
457
+ }
458
+
459
+ /** The judge's instructions: one remark against the user's real next message. */
460
+ export const WARDEN_JUDGE_PROMPT = `You grade one remark a narrow expert reviewer (a "Warden") made during an AI coding session.
461
+ The Warden spoke either on the user's message, before the agent worked, or on the agent's turn, after it.
462
+ You see the Warden's domain, what the Warden saw, the remark, and what the user actually said next.
463
+ When a TARGET is given (the existing rule, primitive, system or pattern the user pushed the agent toward), the test is the target, not wording: the remark matches when it names that same target (or points the agent to it unmistakably), however differently it is phrased.
464
+ Answer two questions:
465
+ - matches: with a target, does the remark name the same target? Without one, does the remark say what the user's next message enforced: the same rule, or the same fix? A remark that is merely related, or generic, does not match.
466
+ - inDomain: does the remark stay inside the Warden's stated domain?
467
+ Respond with JSON only: { "matches": boolean, "inDomain": boolean, "reason": string } where reason is one short sentence.`;
468
+
469
+ /** The judge's input for one remark: what the Warden saw at its trigger point, and what came next. */
470
+ export function wardenJudgeMessage(input: {
471
+ domain: string;
472
+ domainDescription: string;
473
+ /** The agent's turn; '' when the Warden spoke on the user's message only. */
474
+ moment: string;
475
+ /** The user's message the agent was answering, when recorded. */
476
+ ask?: string;
477
+ trigger?: WardenProbeTrigger;
478
+ /** What the user pushed toward, when labelled. */
479
+ target?: CorrectionTarget;
480
+ remark: string;
481
+ hidden: string;
482
+ }): string {
483
+ const before = input.trigger === 'user-message';
484
+ return [
485
+ `## Warden domain: ${input.domain}`,
486
+ input.domainDescription,
487
+ ...(input.ask ? ["## The user's message", input.ask] : []),
488
+ ...(input.moment
489
+ ? [
490
+ before ? '## Agent turn (after the remark; the Warden did not see it)' : '## Agent turn',
491
+ input.moment,
492
+ ]
493
+ : []),
494
+ before
495
+ ? "## Remark (made on the user's message, before the agent worked)"
496
+ : '## Remark (made on the agent turn)',
497
+ input.remark,
498
+ "## The user's actual next message",
499
+ input.hidden || '(the user let the turn pass without correcting it)',
500
+ ...(input.target
501
+ ? [
502
+ '## TARGET (what the user pushed the agent toward)',
503
+ `${input.target.canonical ?? input.target.name} (${input.target.kind}, ${input.target.concept}${input.target.where ? `, ${input.target.where}` : ''})`,
504
+ ]
505
+ : []),
506
+ ].join('\n\n');
507
+ }
508
+
509
+ /** Parse a judge answer strictly; undefined when it is not the shape. */
510
+ export function parseWardenJudgement(text: string): WardenJudgement | undefined {
511
+ const trimmed = text.trim();
512
+ const fenced = /^```(?:json)?\s*\n([\s\S]*?)\n?\s*```$/.exec(trimmed);
513
+ try {
514
+ const value = JSON.parse(fenced ? fenced[1]! : trimmed) as Record<string, unknown>;
515
+ if (typeof value.matches !== 'boolean' || typeof value.inDomain !== 'boolean') return undefined;
516
+ return {
517
+ matches: value.matches,
518
+ inDomain: value.inDomain,
519
+ ...(typeof value.reason === 'string' ? { reason: value.reason.slice(0, 300) } : {}),
520
+ };
521
+ } catch {
522
+ return undefined;
523
+ }
524
+ }
525
+
526
+ /**
527
+ * The pointer judge: a Warden that classifies (relevant, and if so "look over here") is graded on
528
+ * where its pointer leads, not on advice quality. A pointer is as specific as the Warden can make
529
+ * it, and a general one is better than none (Aron, 2026-10-15): the exact thing, the target among a
530
+ * few candidates, or the right class.
531
+ */
532
+ export const WARDEN_POINTER_JUDGE_PROMPT = `You grade one pointer a narrow classifier (a "Warden") gave a coding agent. The Warden only says whether something it knows applies to the agent's moment and, if so, points at it ("look over here: ..."); the agent then follows the pointer itself.
533
+ You see the agent's moment, the TARGET (the existing thing that actually applies, with where it is written) and the pointer.
534
+ Place the pointer on this ladder, leniently on wording (a doc section that holds the TARGET counts as naming it):
535
+ - "exact": it points at the TARGET itself, alone or clearly first.
536
+ - "candidates": it names at most three specific things to choose between, and the TARGET is one of them.
537
+ - "class": it names no specific TARGET but points at the right class or area (the family, doc or section the TARGET belongs to), near enough to find it.
538
+ - "wrong": it steers away from the TARGET (a different specific thing, an unrelated area, more than three candidates without the TARGET singled out).
539
+ Respond with JSON only: { "level": "exact" | "candidates" | "class" | "wrong", "reason": string } where reason is one short sentence.`;
540
+
541
+ export const POINTER_LEVELS = ['exact', 'candidates', 'class', 'wrong'] as const;
542
+ export type PointerLevel = (typeof POINTER_LEVELS)[number];
543
+
544
+ export interface WardenPointerJudgement {
545
+ readonly level: PointerLevel;
546
+ readonly reason?: string;
547
+ }
548
+
549
+ /** The pointer judge's input for one pointer. */
550
+ export function wardenPointerJudgeMessage(input: {
551
+ moment: string;
552
+ target: CorrectionTarget;
553
+ pointer: string;
554
+ }): string {
555
+ return [
556
+ '## Agent moment',
557
+ input.moment,
558
+ '## TARGET',
559
+ `${input.target.canonical ?? input.target.name} (${input.target.kind}${input.target.where ? `, ${input.target.where}` : ''})`,
560
+ '## Pointer',
561
+ input.pointer,
562
+ ].join('\n\n');
563
+ }
564
+
565
+ /** Parse a pointer judgement strictly; undefined when it is not the shape. */
566
+ export function parseWardenPointerJudgement(text: string): WardenPointerJudgement | undefined {
567
+ const trimmed = text.trim();
568
+ const fenced = /^```(?:json)?\s*\n([\s\S]*?)\n?\s*```$/.exec(trimmed);
569
+ try {
570
+ const value = JSON.parse(fenced ? fenced[1]! : trimmed) as Record<string, unknown>;
571
+ const level = POINTER_LEVELS.find((l) => l === value.level);
572
+ if (!level) return undefined;
573
+ return {
574
+ level,
575
+ ...(typeof value.reason === 'string' ? { reason: value.reason.slice(0, 300) } : {}),
576
+ };
577
+ } catch {
578
+ return undefined;
579
+ }
580
+ }