@guilz-dev/belay 0.3.0 → 0.4.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.
Files changed (140) hide show
  1. package/dist/adapters/cursor/runtime-entry.js +1 -0
  2. package/dist/adapters/shared/gate-runtime.d.ts +11 -14
  3. package/dist/adapters/shared/gate-runtime.js +277 -19
  4. package/dist/bundle/claude-runtime.mjs +3138 -699
  5. package/dist/bundle/codex-runtime.mjs +3162 -711
  6. package/dist/bundle/cursor-runtime.mjs +3140 -701
  7. package/dist/cli.js +137 -5
  8. package/dist/commands/approve.d.ts +1 -0
  9. package/dist/commands/approve.js +45 -26
  10. package/dist/commands/config.js +135 -52
  11. package/dist/commands/doctor.js +11 -1
  12. package/dist/commands/explain.js +6 -1
  13. package/dist/commands/harvest.d.ts +29 -0
  14. package/dist/commands/harvest.js +72 -0
  15. package/dist/commands/judge.d.ts +11 -1
  16. package/dist/commands/judge.js +21 -2
  17. package/dist/commands/metrics.js +34 -2
  18. package/dist/commands/quality.d.ts +38 -0
  19. package/dist/commands/quality.js +105 -0
  20. package/dist/commands/simulate.d.ts +1 -0
  21. package/dist/commands/simulate.js +7 -2
  22. package/dist/commands/standing-allow.d.ts +10 -0
  23. package/dist/commands/standing-allow.js +26 -0
  24. package/dist/commands/tui.d.ts +48 -0
  25. package/dist/commands/tui.js +150 -0
  26. package/dist/config-io.js +10 -5
  27. package/dist/core/approval-replay-cli.d.ts +7 -0
  28. package/dist/core/approval-replay-cli.js +36 -0
  29. package/dist/core/approval-replay.d.ts +45 -0
  30. package/dist/core/approval-replay.js +141 -0
  31. package/dist/core/approval-service.d.ts +15 -0
  32. package/dist/core/approval-service.js +47 -2
  33. package/dist/core/approval.d.ts +24 -3
  34. package/dist/core/approval.js +47 -4
  35. package/dist/core/audit-analysis.d.ts +7 -1
  36. package/dist/core/audit-analysis.js +111 -0
  37. package/dist/core/audit-metrics.d.ts +7 -0
  38. package/dist/core/audit-metrics.js +24 -4
  39. package/dist/core/audit-query.d.ts +1 -0
  40. package/dist/core/audit-query.js +3 -0
  41. package/dist/core/audit-replay-context.d.ts +35 -0
  42. package/dist/core/audit-replay-context.js +88 -0
  43. package/dist/core/audit-types.d.ts +24 -1
  44. package/dist/core/audit-types.js +1 -1
  45. package/dist/core/capability/index.d.ts +2 -1
  46. package/dist/core/capability/index.js +1 -0
  47. package/dist/core/capability/paths.d.ts +2 -2
  48. package/dist/core/capability/paths.js +9 -9
  49. package/dist/core/capability/trusted-workspace-roots.d.ts +25 -0
  50. package/dist/core/capability/trusted-workspace-roots.js +149 -0
  51. package/dist/core/capability/types.d.ts +11 -1
  52. package/dist/core/capability-approval.d.ts +2 -1
  53. package/dist/core/capability-approval.js +100 -2
  54. package/dist/core/classify-subagent.js +2 -20
  55. package/dist/core/classify-tool.js +32 -3
  56. package/dist/core/config.d.ts +18 -0
  57. package/dist/core/config.js +49 -0
  58. package/dist/core/gate-engine.js +6 -6
  59. package/dist/core/harvest.d.ts +53 -0
  60. package/dist/core/harvest.js +276 -0
  61. package/dist/core/index.d.ts +9 -3
  62. package/dist/core/index.js +6 -2
  63. package/dist/core/judge-doctor.d.ts +3 -0
  64. package/dist/core/judge-doctor.js +54 -0
  65. package/dist/core/path-utils.d.ts +11 -0
  66. package/dist/core/path-utils.js +56 -6
  67. package/dist/core/reclassify.d.ts +3 -0
  68. package/dist/core/reclassify.js +44 -14
  69. package/dist/core/replay-scrub.d.ts +9 -0
  70. package/dist/core/replay-scrub.js +43 -0
  71. package/dist/core/shell-tokenizer.d.ts +2 -0
  72. package/dist/core/shell-tokenizer.js +82 -23
  73. package/dist/core/standing-allow.d.ts +50 -0
  74. package/dist/core/standing-allow.js +175 -0
  75. package/dist/core/types.d.ts +15 -0
  76. package/dist/core/verdict/adapter.js +10 -1
  77. package/dist/core/verdict/containment.d.ts +4 -3
  78. package/dist/core/verdict/containment.js +17 -12
  79. package/dist/core/verdict/judge-audit.d.ts +1 -0
  80. package/dist/core/verdict/judge-audit.js +32 -1
  81. package/dist/core/verdict/judge-baseline.d.ts +20 -0
  82. package/dist/core/verdict/judge-baseline.js +48 -0
  83. package/dist/core/verdict/judge-broker-service.d.ts +42 -0
  84. package/dist/core/verdict/judge-broker-service.js +279 -0
  85. package/dist/core/verdict/judge-cli-fingerprint.d.ts +5 -0
  86. package/dist/core/verdict/judge-cli-fingerprint.js +61 -0
  87. package/dist/core/verdict/judge-cli.d.ts +23 -0
  88. package/dist/core/verdict/judge-cli.js +137 -16
  89. package/dist/core/verdict/judge-factory.js +13 -4
  90. package/dist/core/verdict/judge-provider-matrix.d.ts +20 -0
  91. package/dist/core/verdict/judge-provider-matrix.js +66 -0
  92. package/dist/core/verdict/judge-runtime-config.d.ts +42 -0
  93. package/dist/core/verdict/judge-runtime-config.js +92 -0
  94. package/dist/core/verdict/judge-session-broker.d.ts +48 -0
  95. package/dist/core/verdict/judge-session-broker.js +195 -0
  96. package/dist/core/verdict/judge-session-guard.d.ts +27 -0
  97. package/dist/core/verdict/judge-session-guard.js +91 -0
  98. package/dist/core/verdict/judge-session-kill-switch.d.ts +11 -0
  99. package/dist/core/verdict/judge-session-kill-switch.js +43 -0
  100. package/dist/core/verdict/judge-session-mutex.d.ts +9 -0
  101. package/dist/core/verdict/judge-session-mutex.js +23 -0
  102. package/dist/core/verdict/judge-shadow.d.ts +24 -0
  103. package/dist/core/verdict/judge-shadow.js +116 -0
  104. package/dist/core/verdict/judge-transport.d.ts +40 -0
  105. package/dist/core/verdict/judge-transport.js +258 -0
  106. package/dist/core/verdict/judge.d.ts +14 -1
  107. package/dist/core/verdict/judge.js +6 -5
  108. package/dist/core/verdict/parser.js +6 -1
  109. package/dist/core/verdict/types.d.ts +13 -0
  110. package/dist/core/verdict/verdict.js +7 -4
  111. package/dist/corpus/adversarial-probe.d.ts +86 -0
  112. package/dist/corpus/adversarial-probe.js +220 -0
  113. package/dist/corpus/evaluate.d.ts +45 -12
  114. package/dist/corpus/evaluate.js +62 -5
  115. package/dist/corpus/gates.d.ts +35 -0
  116. package/dist/corpus/gates.js +81 -0
  117. package/dist/corpus/judge-accuracy.d.ts +8 -0
  118. package/dist/corpus/judge-accuracy.js +39 -0
  119. package/dist/corpus/must-allow-commands.d.ts +5 -0
  120. package/dist/corpus/must-allow-commands.js +13 -0
  121. package/dist/corpus/mutators.d.ts +28 -0
  122. package/dist/corpus/mutators.js +151 -0
  123. package/dist/corpus/ratchet.d.ts +42 -0
  124. package/dist/corpus/ratchet.js +116 -0
  125. package/dist/corpus/runtime-match.d.ts +19 -0
  126. package/dist/corpus/runtime-match.js +45 -0
  127. package/dist/corpus/standing-allow-catalog.generated.d.ts +13 -0
  128. package/dist/corpus/standing-allow-catalog.generated.js +79 -0
  129. package/dist/corpus/types.d.ts +47 -0
  130. package/dist/corpus/types.js +157 -0
  131. package/dist/installer.js +4 -1
  132. package/dist/judge-broker-daemon.d.ts +1 -0
  133. package/dist/judge-broker-daemon.js +123 -0
  134. package/dist/services/sandbox-service.d.ts +1 -0
  135. package/dist/services/sandbox-service.js +2 -0
  136. package/dist/version.d.ts +1 -1
  137. package/dist/version.js +1 -1
  138. package/package.json +7 -3
  139. package/skills/belay/SKILL.md +7 -1
  140. package/skills/belay/belay-approve.md +17 -0
@@ -0,0 +1,195 @@
1
+ import { buildJudgeSessionKey, evaluateSessionReuse, } from './judge-session-guard.js';
2
+ import { MutexRegistry } from './judge-session-mutex.js';
3
+ export class JudgeSessionBroker {
4
+ sessions = new Map();
5
+ mutexes = new MutexRegistry();
6
+ options;
7
+ constructor(options) {
8
+ this.options = options;
9
+ }
10
+ listSessions() {
11
+ return [...this.sessions.values()];
12
+ }
13
+ invalidateSession(sessionKey, reason) {
14
+ const session = this.sessions.get(sessionKey);
15
+ if (session) {
16
+ session.lastResetReason = reason;
17
+ session.state = 'idle';
18
+ }
19
+ this.sessions.delete(sessionKey);
20
+ }
21
+ stopAll(reason = 'manual_stop') {
22
+ const count = this.sessions.size;
23
+ for (const session of this.sessions.values()) {
24
+ session.lastResetReason = reason;
25
+ session.state = 'idle';
26
+ }
27
+ this.sessions.clear();
28
+ this.mutexes.clear();
29
+ return count;
30
+ }
31
+ async evaluate(request, timeoutMs) {
32
+ const sessionKey = buildJudgeSessionKey(request.keyParts);
33
+ return this.mutexes.forKey(sessionKey).run(() => this.evaluateExclusive(request, timeoutMs));
34
+ }
35
+ async evaluateExclusive(request, timeoutMs) {
36
+ const sessionKey = buildJudgeSessionKey(request.keyParts);
37
+ const providerId = request.keyParts.providerId;
38
+ const existing = this.sessions.get(sessionKey);
39
+ const reuseDecision = evaluateSessionReuse(existing?.keyParts ?? null, request.keyParts, existing?.budget ?? null, this.options.config, Date.now(), request.promptBytes);
40
+ const resetReason = reuseDecision.canReuse ? undefined : reuseDecision.resetReason;
41
+ let session = existing;
42
+ if (!reuseDecision.canReuse || !session) {
43
+ session = {
44
+ keyParts: request.keyParts,
45
+ budget: {
46
+ turnCount: 0,
47
+ createdAtMs: Date.now(),
48
+ lastUsedAtMs: Date.now(),
49
+ promptBytes: 0,
50
+ },
51
+ state: 'busy',
52
+ lastResetReason: resetReason ?? 'initial',
53
+ };
54
+ this.enforceProviderSessionCap(providerId, sessionKey);
55
+ this.sessions.set(sessionKey, session);
56
+ }
57
+ else {
58
+ session.state = 'busy';
59
+ session.budget.lastUsedAtMs = Date.now();
60
+ }
61
+ const invocation = reuseDecision.canReuse && session.providerResumeId
62
+ ? this.buildResumeInvocation(request.invocation, providerId, session.providerResumeId)
63
+ : request.invocation;
64
+ const resumed = reuseDecision.canReuse && Boolean(session.providerResumeId);
65
+ try {
66
+ const raw = await this.options.runCommand(invocation, timeoutMs);
67
+ session.budget.turnCount += 1;
68
+ session.budget.promptBytes += request.promptBytes;
69
+ session.budget.lastUsedAtMs = Date.now();
70
+ session.state = 'idle';
71
+ const extracted = this.options.extractResumeId?.(providerId, raw) ??
72
+ extractProviderResumeId(providerId, raw) ??
73
+ undefined;
74
+ if (extracted) {
75
+ session.providerResumeId = extracted;
76
+ }
77
+ return {
78
+ raw,
79
+ reused: resumed,
80
+ resetReason: session.lastResetReason,
81
+ providerResumeId: session.providerResumeId,
82
+ };
83
+ }
84
+ catch (error) {
85
+ session.state = 'idle';
86
+ session.lastResetReason = 'cli_error';
87
+ this.sessions.delete(sessionKey);
88
+ throw error;
89
+ }
90
+ }
91
+ enforceProviderSessionCap(providerId, keepKey) {
92
+ const cap = this.options.config.maxSessionsPerProvider;
93
+ const sameProvider = [...this.sessions.entries()].filter(([, record]) => record.keyParts.providerId === providerId);
94
+ if (sameProvider.length < cap) {
95
+ return;
96
+ }
97
+ for (const [key] of sameProvider) {
98
+ if (key === keepKey) {
99
+ continue;
100
+ }
101
+ this.sessions.delete(key);
102
+ if (this.sessions.size < cap) {
103
+ break;
104
+ }
105
+ }
106
+ }
107
+ buildResumeInvocation(base, providerId, resumeId) {
108
+ if (providerId === 'cursor') {
109
+ const args = [...base.args];
110
+ const modelIndex = args.indexOf('--model');
111
+ const model = modelIndex >= 0 ? args[modelIndex + 1] : undefined;
112
+ const prompt = args[args.length - 1];
113
+ return {
114
+ binary: base.binary,
115
+ args: [
116
+ '--print',
117
+ '--output-format',
118
+ 'json',
119
+ '--mode',
120
+ 'ask',
121
+ '--sandbox',
122
+ 'enabled',
123
+ '--trust',
124
+ '--resume',
125
+ resumeId,
126
+ ...(model ? ['--model', model] : []),
127
+ prompt,
128
+ ],
129
+ };
130
+ }
131
+ if (providerId === 'claude') {
132
+ return {
133
+ ...base,
134
+ args: [...base.args, '--continue', resumeId],
135
+ };
136
+ }
137
+ return base;
138
+ }
139
+ }
140
+ export function extractProviderResumeId(providerId, raw) {
141
+ try {
142
+ const parsed = JSON.parse(raw);
143
+ const candidates = [
144
+ parsed.chat_id,
145
+ parsed.chatId,
146
+ parsed.session_id,
147
+ parsed.sessionId,
148
+ parsed.result?.chat_id,
149
+ parsed.result?.session_id,
150
+ ];
151
+ for (const candidate of candidates) {
152
+ if (typeof candidate === 'string' && candidate.trim()) {
153
+ return candidate.trim();
154
+ }
155
+ }
156
+ }
157
+ catch {
158
+ // fall through
159
+ }
160
+ if (providerId === 'cursor') {
161
+ const match = raw.match(/"chat_id"\s*:\s*"([^"]+)"/);
162
+ if (match?.[1]) {
163
+ return match[1];
164
+ }
165
+ }
166
+ return null;
167
+ }
168
+ const repoBrokers = new Map();
169
+ export function getRepoJudgeSessionBroker(repoRoot, options) {
170
+ const existing = repoBrokers.get(repoRoot);
171
+ if (existing) {
172
+ return existing;
173
+ }
174
+ const broker = new JudgeSessionBroker(options);
175
+ repoBrokers.set(repoRoot, broker);
176
+ return broker;
177
+ }
178
+ export function stopRepoJudgeSessionBroker(repoRoot, reason = 'manual_stop') {
179
+ const broker = repoBrokers.get(repoRoot);
180
+ if (!broker) {
181
+ return 0;
182
+ }
183
+ const count = broker.stopAll(reason);
184
+ repoBrokers.delete(repoRoot);
185
+ return count;
186
+ }
187
+ export function resetJudgeSessionBrokersForTests() {
188
+ for (const broker of repoBrokers.values()) {
189
+ broker.stopAll('manual_stop');
190
+ }
191
+ repoBrokers.clear();
192
+ }
193
+ export function listRepoJudgeSessionBrokers() {
194
+ return [...repoBrokers.keys()];
195
+ }
@@ -0,0 +1,27 @@
1
+ import type { JudgeProviderId } from './judge-catalog.js';
2
+ import type { BelayJudgeSessionConfig } from './judge-runtime-config.js';
3
+ export type JudgeSessionResetReason = 'provider_mismatch' | 'model_mismatch' | 'repo_mismatch' | 'mode_mismatch' | 'cli_version_mismatch' | 'max_turns_exceeded' | 'max_age_exceeded' | 'max_idle_exceeded' | 'max_prompt_bytes_exceeded' | 'parse_failure' | 'non_json_response' | 'cli_error' | 'timeout' | 'kill_switch' | 'manual_stop' | 'budget_exhausted' | 'session_disabled' | 'provider_not_allowlisted' | 'initial';
4
+ export type JudgeFallbackReason = 'session_disabled' | 'provider_not_allowlisted' | 'session_unavailable' | 'guard_reset' | 'parse_error' | 'non_json_response' | 'cli_error' | 'timeout' | 'connect_timeout' | 'eval_timeout' | 'parse_timeout' | 'version_mismatch' | 'kill_switch' | 'shadow_forced_spawn' | 'unsafe_option_rejected' | 'broker_busy_reset' | `${Exclude<JudgeProviderId, 'ollama'>}_cli_spawn_error` | `${Exclude<JudgeProviderId, 'ollama'>}_cli_nonzero` | `${Exclude<JudgeProviderId, 'ollama'>}_cli_unavailable` | `${Exclude<JudgeProviderId, 'ollama'>}_cli_parse_error`;
5
+ export interface JudgeSessionKeyParts {
6
+ providerId: JudgeProviderId;
7
+ model: string;
8
+ repoRoot: string;
9
+ judgeMode: string;
10
+ cliVersion: string;
11
+ }
12
+ export interface JudgeSessionBudget {
13
+ turnCount: number;
14
+ createdAtMs: number;
15
+ lastUsedAtMs: number;
16
+ promptBytes: number;
17
+ }
18
+ export interface JudgeSessionGuardDecision {
19
+ canReuse: boolean;
20
+ resetReason?: JudgeSessionResetReason;
21
+ sessionKey: string;
22
+ }
23
+ export declare function buildJudgeSessionKey(parts: JudgeSessionKeyParts): string;
24
+ export declare function evaluateSessionReuse(existing: JudgeSessionKeyParts | null, requested: JudgeSessionKeyParts, budget: JudgeSessionBudget | null, config: BelayJudgeSessionConfig, now?: number, incomingPromptBytes?: number): JudgeSessionGuardDecision;
25
+ export declare function exceedsPromptBudget(promptBytes: number, config: BelayJudgeSessionConfig, existingBudget?: JudgeSessionBudget | null): boolean;
26
+ export declare function guardFailClosedFallbackReason(resetReason: JudgeSessionResetReason): JudgeFallbackReason;
27
+ export declare function transportFallbackToFailClosedReason(providerId: Exclude<JudgeProviderId, 'ollama'>, fallback: JudgeFallbackReason | undefined): string;
@@ -0,0 +1,91 @@
1
+ export function buildJudgeSessionKey(parts) {
2
+ return [parts.providerId, parts.model, parts.repoRoot, parts.judgeMode, parts.cliVersion].join(':');
3
+ }
4
+ export function evaluateSessionReuse(existing, requested, budget, config, now = Date.now(), incomingPromptBytes = 0) {
5
+ const sessionKey = buildJudgeSessionKey(requested);
6
+ if (!config.enabled) {
7
+ return { canReuse: false, resetReason: 'session_disabled', sessionKey };
8
+ }
9
+ if (!config.providerAllowlist.includes(requested.providerId)) {
10
+ return { canReuse: false, resetReason: 'provider_not_allowlisted', sessionKey };
11
+ }
12
+ if (!existing || !budget) {
13
+ return { canReuse: false, resetReason: 'initial', sessionKey };
14
+ }
15
+ const existingKey = buildJudgeSessionKey(existing);
16
+ if (existingKey !== sessionKey) {
17
+ if (existing.providerId !== requested.providerId) {
18
+ return { canReuse: false, resetReason: 'provider_mismatch', sessionKey };
19
+ }
20
+ if (existing.model !== requested.model) {
21
+ return { canReuse: false, resetReason: 'model_mismatch', sessionKey };
22
+ }
23
+ if (existing.repoRoot !== requested.repoRoot) {
24
+ return { canReuse: false, resetReason: 'repo_mismatch', sessionKey };
25
+ }
26
+ if (existing.judgeMode !== requested.judgeMode) {
27
+ return { canReuse: false, resetReason: 'mode_mismatch', sessionKey };
28
+ }
29
+ if (existing.cliVersion !== requested.cliVersion) {
30
+ return { canReuse: false, resetReason: 'cli_version_mismatch', sessionKey };
31
+ }
32
+ return { canReuse: false, resetReason: 'provider_mismatch', sessionKey };
33
+ }
34
+ if (budget.turnCount >= config.maxTurns) {
35
+ return { canReuse: false, resetReason: 'max_turns_exceeded', sessionKey };
36
+ }
37
+ if (now - budget.createdAtMs >= config.maxAgeMs) {
38
+ return { canReuse: false, resetReason: 'max_age_exceeded', sessionKey };
39
+ }
40
+ if (now - budget.lastUsedAtMs >= config.maxIdleMs) {
41
+ return { canReuse: false, resetReason: 'max_idle_exceeded', sessionKey };
42
+ }
43
+ if (budget.promptBytes + incomingPromptBytes > config.maxPromptBytes) {
44
+ return { canReuse: false, resetReason: 'max_prompt_bytes_exceeded', sessionKey };
45
+ }
46
+ return { canReuse: true, sessionKey };
47
+ }
48
+ export function exceedsPromptBudget(promptBytes, config, existingBudget = null) {
49
+ const total = (existingBudget?.promptBytes ?? 0) + promptBytes;
50
+ return total > config.maxPromptBytes;
51
+ }
52
+ export function guardFailClosedFallbackReason(resetReason) {
53
+ switch (resetReason) {
54
+ case 'parse_failure':
55
+ return 'parse_error';
56
+ case 'non_json_response':
57
+ return 'non_json_response';
58
+ case 'cli_error':
59
+ return 'cli_error';
60
+ case 'timeout':
61
+ return 'timeout';
62
+ case 'cli_version_mismatch':
63
+ return 'version_mismatch';
64
+ case 'kill_switch':
65
+ return 'kill_switch';
66
+ case 'session_disabled':
67
+ return 'session_disabled';
68
+ case 'provider_not_allowlisted':
69
+ return 'provider_not_allowlisted';
70
+ case 'max_prompt_bytes_exceeded':
71
+ return 'guard_reset';
72
+ default:
73
+ return 'guard_reset';
74
+ }
75
+ }
76
+ export function transportFallbackToFailClosedReason(providerId, fallback) {
77
+ if (!fallback) {
78
+ return `${providerId}_cli_parse_error`;
79
+ }
80
+ if (fallback.endsWith('_cli_unavailable') || fallback.endsWith('_cli_parse_error')) {
81
+ return fallback;
82
+ }
83
+ switch (fallback) {
84
+ case 'non_json_response':
85
+ case 'parse_error':
86
+ case 'parse_timeout':
87
+ return `${providerId}_cli_parse_error`;
88
+ default:
89
+ return `${providerId}_cli_unavailable`;
90
+ }
91
+ }
@@ -0,0 +1,11 @@
1
+ export declare const JUDGE_SESSION_KILL_FILE = "judge-session-kill.json";
2
+ export interface JudgeSessionKillSwitchRecord {
3
+ triggered: boolean;
4
+ at: string;
5
+ reason?: string;
6
+ }
7
+ export declare function judgeSessionKillSwitchPath(stateDir: string): string;
8
+ export declare function readJudgeSessionKillSwitch(stateDir: string): Promise<JudgeSessionKillSwitchRecord | null>;
9
+ export declare function isJudgeSessionKillSwitchPersisted(stateDir: string): Promise<boolean>;
10
+ export declare function persistJudgeSessionKillSwitch(stateDir: string, reason?: string): Promise<void>;
11
+ export declare function clearJudgeSessionKillSwitch(stateDir: string): Promise<void>;
@@ -0,0 +1,43 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ export const JUDGE_SESSION_KILL_FILE = 'judge-session-kill.json';
5
+ export function judgeSessionKillSwitchPath(stateDir) {
6
+ return path.join(stateDir, JUDGE_SESSION_KILL_FILE);
7
+ }
8
+ export async function readJudgeSessionKillSwitch(stateDir) {
9
+ const filePath = judgeSessionKillSwitchPath(stateDir);
10
+ if (!existsSync(filePath)) {
11
+ return null;
12
+ }
13
+ try {
14
+ const raw = JSON.parse(await readFile(filePath, 'utf8'));
15
+ return raw.triggered === true ? raw : null;
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ export async function isJudgeSessionKillSwitchPersisted(stateDir) {
22
+ const record = await readJudgeSessionKillSwitch(stateDir);
23
+ return record?.triggered === true;
24
+ }
25
+ export async function persistJudgeSessionKillSwitch(stateDir, reason = 'shadow_mismatch') {
26
+ await mkdir(stateDir, { recursive: true, mode: 0o700 });
27
+ const record = {
28
+ triggered: true,
29
+ at: new Date().toISOString(),
30
+ reason,
31
+ };
32
+ await writeFile(judgeSessionKillSwitchPath(stateDir), JSON.stringify(record), {
33
+ encoding: 'utf8',
34
+ mode: 0o600,
35
+ });
36
+ }
37
+ export async function clearJudgeSessionKillSwitch(stateDir) {
38
+ const filePath = judgeSessionKillSwitchPath(stateDir);
39
+ if (!existsSync(filePath)) {
40
+ return;
41
+ }
42
+ await unlink(filePath);
43
+ }
@@ -0,0 +1,9 @@
1
+ export declare class AsyncMutex {
2
+ private chain;
3
+ run<T>(fn: () => Promise<T>): Promise<T>;
4
+ }
5
+ export declare class MutexRegistry {
6
+ private readonly mutexes;
7
+ forKey(key: string): AsyncMutex;
8
+ clear(): void;
9
+ }
@@ -0,0 +1,23 @@
1
+ export class AsyncMutex {
2
+ chain = Promise.resolve();
3
+ run(fn) {
4
+ const next = this.chain.then(fn);
5
+ this.chain = next.then(() => undefined, () => undefined);
6
+ return next;
7
+ }
8
+ }
9
+ export class MutexRegistry {
10
+ mutexes = new Map();
11
+ forKey(key) {
12
+ const existing = this.mutexes.get(key);
13
+ if (existing) {
14
+ return existing;
15
+ }
16
+ const created = new AsyncMutex();
17
+ this.mutexes.set(key, created);
18
+ return created;
19
+ }
20
+ clear() {
21
+ this.mutexes.clear();
22
+ }
23
+ }
@@ -0,0 +1,24 @@
1
+ import type { BelayJudgeShadowConfig } from './judge-runtime-config.js';
2
+ import type { Tier1Verdict } from './types.js';
3
+ export interface JudgeShadowState {
4
+ compared: number;
5
+ mismatches: number;
6
+ dailyCount: number;
7
+ dailyKey: string;
8
+ killSwitchTriggered: boolean;
9
+ mismatchWindow: boolean[];
10
+ }
11
+ export declare function setJudgeShadowClockForTests(next: () => Date): void;
12
+ export declare function setJudgeShadowRandomForTests(next: () => number): void;
13
+ export declare function resetJudgeShadowState(repoRoot?: string): void;
14
+ export declare function isJudgeSessionKillSwitchActive(repoRoot: string, stateDir?: string): Promise<boolean>;
15
+ export declare function triggerJudgeSessionKillSwitch(repoRoot: string, stateDir?: string, reason?: string): Promise<void>;
16
+ export declare function shouldRunShadowComparison(repoRoot: string, providerId: string, config: BelayJudgeShadowConfig): boolean;
17
+ export declare function verdictsEquivalent(a: Tier1Verdict, b: Tier1Verdict): boolean;
18
+ export declare function recordShadowComparison(repoRoot: string, config: BelayJudgeShadowConfig, mismatch: boolean): {
19
+ compared: boolean;
20
+ mismatch: boolean;
21
+ mismatchRateWindow: number;
22
+ killSwitchTriggered: boolean;
23
+ };
24
+ export declare function judgeShadowAuditFields(repoRoot: string): Record<string, unknown>;
@@ -0,0 +1,116 @@
1
+ import { isJudgeSessionKillSwitchPersisted, persistJudgeSessionKillSwitch, } from './judge-session-kill-switch.js';
2
+ const shadowStates = new Map();
3
+ let clock = () => new Date();
4
+ let random = Math.random;
5
+ export function setJudgeShadowClockForTests(next) {
6
+ clock = next;
7
+ }
8
+ export function setJudgeShadowRandomForTests(next) {
9
+ random = next;
10
+ }
11
+ export function resetJudgeShadowState(repoRoot) {
12
+ if (repoRoot) {
13
+ shadowStates.delete(repoRoot);
14
+ return;
15
+ }
16
+ shadowStates.clear();
17
+ }
18
+ function dailyKeyFor(date) {
19
+ return date.toISOString().slice(0, 10);
20
+ }
21
+ function getShadowState(repoRoot) {
22
+ const existing = shadowStates.get(repoRoot);
23
+ if (existing) {
24
+ const key = dailyKeyFor(clock());
25
+ if (existing.dailyKey !== key) {
26
+ existing.dailyCount = 0;
27
+ existing.dailyKey = key;
28
+ }
29
+ return existing;
30
+ }
31
+ const created = {
32
+ compared: 0,
33
+ mismatches: 0,
34
+ dailyCount: 0,
35
+ dailyKey: dailyKeyFor(clock()),
36
+ killSwitchTriggered: false,
37
+ mismatchWindow: [],
38
+ };
39
+ shadowStates.set(repoRoot, created);
40
+ return created;
41
+ }
42
+ export async function isJudgeSessionKillSwitchActive(repoRoot, stateDir) {
43
+ if (getShadowState(repoRoot).killSwitchTriggered) {
44
+ return true;
45
+ }
46
+ if (stateDir) {
47
+ return isJudgeSessionKillSwitchPersisted(stateDir);
48
+ }
49
+ return false;
50
+ }
51
+ export async function triggerJudgeSessionKillSwitch(repoRoot, stateDir, reason = 'shadow_mismatch') {
52
+ getShadowState(repoRoot).killSwitchTriggered = true;
53
+ if (stateDir) {
54
+ await persistJudgeSessionKillSwitch(stateDir, reason);
55
+ }
56
+ }
57
+ export function shouldRunShadowComparison(repoRoot, providerId, config) {
58
+ if (!config.enabled) {
59
+ return false;
60
+ }
61
+ if (!config.providerAllowlist.includes(providerId)) {
62
+ return false;
63
+ }
64
+ const state = getShadowState(repoRoot);
65
+ if (state.killSwitchTriggered) {
66
+ return false;
67
+ }
68
+ if (state.dailyCount >= config.dailyRequestCap) {
69
+ return false;
70
+ }
71
+ const rate = Math.min(config.sampleRate, config.sampleRateMax);
72
+ return random() < rate;
73
+ }
74
+ export function verdictsEquivalent(a, b) {
75
+ return (a.local_recoverable === b.local_recoverable &&
76
+ a.destroys_history_or_secrets === b.destroys_history_or_secrets);
77
+ }
78
+ export function recordShadowComparison(repoRoot, config, mismatch) {
79
+ const state = getShadowState(repoRoot);
80
+ state.compared += 1;
81
+ state.dailyCount += 1;
82
+ if (mismatch) {
83
+ state.mismatches += 1;
84
+ }
85
+ state.mismatchWindow.push(mismatch);
86
+ if (state.mismatchWindow.length > config.windowSize) {
87
+ state.mismatchWindow.shift();
88
+ }
89
+ const windowComparisons = state.mismatchWindow.length;
90
+ const windowMismatches = state.mismatchWindow.filter(Boolean).length;
91
+ const mismatchRateWindow = windowComparisons === 0 ? 0 : windowMismatches / windowComparisons;
92
+ if (windowComparisons >= Math.min(10, config.windowSize) &&
93
+ mismatchRateWindow > config.mismatchRateThreshold) {
94
+ state.killSwitchTriggered = true;
95
+ }
96
+ return {
97
+ compared: true,
98
+ mismatch,
99
+ mismatchRateWindow,
100
+ killSwitchTriggered: state.killSwitchTriggered,
101
+ };
102
+ }
103
+ export function judgeShadowAuditFields(repoRoot) {
104
+ const state = shadowStates.get(repoRoot);
105
+ if (!state) {
106
+ return {};
107
+ }
108
+ const windowComparisons = state.mismatchWindow.length;
109
+ const windowMismatches = state.mismatchWindow.filter(Boolean).length;
110
+ return {
111
+ judgeShadowCompared: state.compared > 0,
112
+ judgeShadowMismatch: state.mismatches > 0,
113
+ judgeShadowMismatchRateWindow: windowComparisons === 0 ? 0 : windowMismatches / windowComparisons,
114
+ judgeKillSwitchTriggered: state.killSwitchTriggered,
115
+ };
116
+ }
@@ -0,0 +1,40 @@
1
+ import type { JudgeProviderId } from './judge-catalog.js';
2
+ import { type CliInvocation, type CliJudgeRunCommand } from './judge-cli.js';
3
+ import type { BelayJudgeRuntimeConfig } from './judge-runtime-config.js';
4
+ import { type JudgeFallbackReason, type JudgeSessionResetReason } from './judge-session-guard.js';
5
+ import type { Tier1Verdict } from './types.js';
6
+ export interface JudgeTransportContext {
7
+ providerId: Exclude<JudgeProviderId, 'ollama'>;
8
+ model: string;
9
+ repoRoot: string;
10
+ stateDir: string;
11
+ judgeMode: string;
12
+ runtime: BelayJudgeRuntimeConfig;
13
+ judgeTimeoutMs: number;
14
+ }
15
+ export interface JudgeTransportEvaluateRequest {
16
+ prompt: string;
17
+ context: JudgeTransportContext;
18
+ }
19
+ export interface JudgeTransportEvaluateResult {
20
+ raw: string;
21
+ verdict: Tier1Verdict | null;
22
+ transport: 'spawn' | 'session';
23
+ sessionUsed: boolean;
24
+ sessionReused: boolean;
25
+ fallbackReason?: JudgeFallbackReason;
26
+ sessionResetReason?: JudgeSessionResetReason;
27
+ shadowCompared?: boolean;
28
+ shadowMismatch?: boolean;
29
+ shadowMismatchRateWindow?: number;
30
+ killSwitchTriggered?: boolean;
31
+ sessionRefHash?: string;
32
+ connectMs: number;
33
+ evalMs: number;
34
+ parseMs: number;
35
+ }
36
+ export interface JudgeTransportBridgeOptions {
37
+ runCommand?: CliJudgeRunCommand;
38
+ }
39
+ export declare function evaluateWithJudgeTransport(request: JudgeTransportEvaluateRequest, options?: JudgeTransportBridgeOptions): Promise<JudgeTransportEvaluateResult>;
40
+ export declare function buildTransportInvocation(providerId: Exclude<JudgeProviderId, 'ollama'>, prompt: string, model: string): CliInvocation;