@guilz-dev/belay 0.2.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 (180) hide show
  1. package/README.md +39 -12
  2. package/dist/adapters/cursor/runtime-entry.js +1 -0
  3. package/dist/adapters/shared/gate-runtime.d.ts +11 -14
  4. package/dist/adapters/shared/gate-runtime.js +289 -23
  5. package/dist/bundle/claude-runtime.mjs +4435 -708
  6. package/dist/bundle/codex-runtime.mjs +4448 -710
  7. package/dist/bundle/cursor-runtime.mjs +4437 -710
  8. package/dist/cli.js +333 -11
  9. package/dist/commands/approve.d.ts +1 -0
  10. package/dist/commands/approve.js +56 -26
  11. package/dist/commands/config.d.ts +70 -0
  12. package/dist/commands/config.js +577 -0
  13. package/dist/commands/doctor.js +13 -2
  14. package/dist/commands/explain.js +6 -1
  15. package/dist/commands/harvest.d.ts +29 -0
  16. package/dist/commands/harvest.js +72 -0
  17. package/dist/commands/health-snapshot.d.ts +6 -0
  18. package/dist/commands/health-snapshot.js +34 -0
  19. package/dist/commands/judge.d.ts +100 -0
  20. package/dist/commands/judge.js +304 -0
  21. package/dist/commands/metrics.js +34 -2
  22. package/dist/commands/quality.d.ts +38 -0
  23. package/dist/commands/quality.js +105 -0
  24. package/dist/commands/simulate.d.ts +1 -0
  25. package/dist/commands/simulate.js +7 -2
  26. package/dist/commands/standing-allow.d.ts +10 -0
  27. package/dist/commands/standing-allow.js +26 -0
  28. package/dist/commands/status.js +2 -2
  29. package/dist/commands/stdin-key.d.ts +1 -0
  30. package/dist/commands/stdin-key.js +8 -0
  31. package/dist/commands/tui.d.ts +48 -0
  32. package/dist/commands/tui.js +150 -0
  33. package/dist/config-io.js +10 -5
  34. package/dist/conformance/guarantee-table.js +12 -0
  35. package/dist/conformance/types.d.ts +2 -0
  36. package/dist/core/approval-replay-cli.d.ts +7 -0
  37. package/dist/core/approval-replay-cli.js +36 -0
  38. package/dist/core/approval-replay.d.ts +45 -0
  39. package/dist/core/approval-replay.js +141 -0
  40. package/dist/core/approval-service.d.ts +15 -0
  41. package/dist/core/approval-service.js +47 -2
  42. package/dist/core/approval.d.ts +24 -3
  43. package/dist/core/approval.js +47 -4
  44. package/dist/core/audit-analysis.d.ts +7 -1
  45. package/dist/core/audit-analysis.js +111 -0
  46. package/dist/core/audit-io.d.ts +2 -0
  47. package/dist/core/audit-io.js +14 -0
  48. package/dist/core/audit-metrics.d.ts +7 -0
  49. package/dist/core/audit-metrics.js +24 -4
  50. package/dist/core/audit-query.d.ts +1 -0
  51. package/dist/core/audit-query.js +3 -0
  52. package/dist/core/audit-replay-context.d.ts +35 -0
  53. package/dist/core/audit-replay-context.js +88 -0
  54. package/dist/core/audit-types.d.ts +24 -1
  55. package/dist/core/audit-types.js +1 -1
  56. package/dist/core/capability/index.d.ts +4 -3
  57. package/dist/core/capability/index.js +3 -2
  58. package/dist/core/capability/paths.d.ts +2 -1
  59. package/dist/core/capability/paths.js +76 -8
  60. package/dist/core/capability/reasons.d.ts +3 -0
  61. package/dist/core/capability/reasons.js +8 -0
  62. package/dist/core/capability/trusted-workspace-roots.d.ts +25 -0
  63. package/dist/core/capability/trusted-workspace-roots.js +149 -0
  64. package/dist/core/capability/types.d.ts +11 -1
  65. package/dist/core/capability-approval.d.ts +2 -1
  66. package/dist/core/capability-approval.js +100 -2
  67. package/dist/core/classify-subagent.js +2 -20
  68. package/dist/core/classify-tool.js +129 -30
  69. package/dist/core/config-layers.js +2 -1
  70. package/dist/core/config.d.ts +40 -2
  71. package/dist/core/config.js +159 -11
  72. package/dist/core/credential-store.d.ts +11 -0
  73. package/dist/core/credential-store.js +60 -0
  74. package/dist/core/gate-engine.js +104 -13
  75. package/dist/core/harvest.d.ts +53 -0
  76. package/dist/core/harvest.js +276 -0
  77. package/dist/core/index.d.ts +9 -3
  78. package/dist/core/index.js +6 -2
  79. package/dist/core/integrity.d.ts +2 -0
  80. package/dist/core/integrity.js +13 -0
  81. package/dist/core/judge-api-key.d.ts +19 -0
  82. package/dist/core/judge-api-key.js +74 -0
  83. package/dist/core/judge-cloud-consent.d.ts +13 -0
  84. package/dist/core/judge-cloud-consent.js +38 -0
  85. package/dist/core/judge-config.d.ts +41 -4
  86. package/dist/core/judge-config.js +263 -57
  87. package/dist/core/judge-doctor.d.ts +9 -1
  88. package/dist/core/judge-doctor.js +199 -94
  89. package/dist/core/judge-model-discovery.d.ts +24 -0
  90. package/dist/core/judge-model-discovery.js +168 -0
  91. package/dist/core/judge-model-policy.d.ts +5 -0
  92. package/dist/core/judge-model-policy.js +21 -0
  93. package/dist/core/judge-runtime-detection.d.ts +9 -0
  94. package/dist/core/judge-runtime-detection.js +68 -0
  95. package/dist/core/path-utils.d.ts +11 -0
  96. package/dist/core/path-utils.js +56 -6
  97. package/dist/core/reclassify.d.ts +3 -0
  98. package/dist/core/reclassify.js +44 -14
  99. package/dist/core/replay-scrub.d.ts +9 -0
  100. package/dist/core/replay-scrub.js +43 -0
  101. package/dist/core/shell-tokenizer.d.ts +2 -0
  102. package/dist/core/shell-tokenizer.js +82 -23
  103. package/dist/core/standing-allow.d.ts +50 -0
  104. package/dist/core/standing-allow.js +175 -0
  105. package/dist/core/transactional/diff-evaluator.js +1 -19
  106. package/dist/core/types.d.ts +17 -0
  107. package/dist/core/verdict/adapter.d.ts +1 -0
  108. package/dist/core/verdict/adapter.js +17 -2
  109. package/dist/core/verdict/containment.d.ts +8 -2
  110. package/dist/core/verdict/containment.js +45 -10
  111. package/dist/core/verdict/judge-audit.d.ts +1 -0
  112. package/dist/core/verdict/judge-audit.js +32 -1
  113. package/dist/core/verdict/judge-baseline.d.ts +20 -0
  114. package/dist/core/verdict/judge-baseline.js +48 -0
  115. package/dist/core/verdict/judge-broker-service.d.ts +42 -0
  116. package/dist/core/verdict/judge-broker-service.js +279 -0
  117. package/dist/core/verdict/judge-catalog.d.ts +40 -0
  118. package/dist/core/verdict/judge-catalog.js +148 -0
  119. package/dist/core/verdict/judge-cli-fingerprint.d.ts +5 -0
  120. package/dist/core/verdict/judge-cli-fingerprint.js +61 -0
  121. package/dist/core/verdict/judge-cli.d.ts +46 -0
  122. package/dist/core/verdict/judge-cli.js +401 -0
  123. package/dist/core/verdict/judge-factory.d.ts +15 -4
  124. package/dist/core/verdict/judge-factory.js +127 -15
  125. package/dist/core/verdict/judge-provider-matrix.d.ts +20 -0
  126. package/dist/core/verdict/judge-provider-matrix.js +66 -0
  127. package/dist/core/verdict/judge-runtime-config.d.ts +42 -0
  128. package/dist/core/verdict/judge-runtime-config.js +92 -0
  129. package/dist/core/verdict/judge-session-broker.d.ts +48 -0
  130. package/dist/core/verdict/judge-session-broker.js +195 -0
  131. package/dist/core/verdict/judge-session-guard.d.ts +27 -0
  132. package/dist/core/verdict/judge-session-guard.js +91 -0
  133. package/dist/core/verdict/judge-session-kill-switch.d.ts +11 -0
  134. package/dist/core/verdict/judge-session-kill-switch.js +43 -0
  135. package/dist/core/verdict/judge-session-mutex.d.ts +9 -0
  136. package/dist/core/verdict/judge-session-mutex.js +23 -0
  137. package/dist/core/verdict/judge-shadow.d.ts +24 -0
  138. package/dist/core/verdict/judge-shadow.js +116 -0
  139. package/dist/core/verdict/judge-transport.d.ts +40 -0
  140. package/dist/core/verdict/judge-transport.js +258 -0
  141. package/dist/core/verdict/judge.d.ts +33 -1
  142. package/dist/core/verdict/judge.js +84 -14
  143. package/dist/core/verdict/parser.js +6 -1
  144. package/dist/core/verdict/persistent-paths.d.ts +8 -0
  145. package/dist/core/verdict/persistent-paths.js +52 -0
  146. package/dist/core/verdict/types.d.ts +19 -2
  147. package/dist/core/verdict/verdict.js +166 -49
  148. package/dist/corpus/adversarial-probe.d.ts +86 -0
  149. package/dist/corpus/adversarial-probe.js +220 -0
  150. package/dist/corpus/evaluate.d.ts +45 -12
  151. package/dist/corpus/evaluate.js +62 -5
  152. package/dist/corpus/gates.d.ts +35 -0
  153. package/dist/corpus/gates.js +81 -0
  154. package/dist/corpus/judge-accuracy.d.ts +8 -0
  155. package/dist/corpus/judge-accuracy.js +39 -0
  156. package/dist/corpus/must-allow-commands.d.ts +5 -0
  157. package/dist/corpus/must-allow-commands.js +13 -0
  158. package/dist/corpus/mutators.d.ts +28 -0
  159. package/dist/corpus/mutators.js +151 -0
  160. package/dist/corpus/ratchet.d.ts +42 -0
  161. package/dist/corpus/ratchet.js +116 -0
  162. package/dist/corpus/runtime-match.d.ts +19 -0
  163. package/dist/corpus/runtime-match.js +45 -0
  164. package/dist/corpus/standing-allow-catalog.generated.d.ts +13 -0
  165. package/dist/corpus/standing-allow-catalog.generated.js +79 -0
  166. package/dist/corpus/types.d.ts +47 -0
  167. package/dist/corpus/types.js +157 -0
  168. package/dist/installer.js +69 -15
  169. package/dist/judge-broker-daemon.d.ts +1 -0
  170. package/dist/judge-broker-daemon.js +123 -0
  171. package/dist/services/sandbox-service.d.ts +1 -0
  172. package/dist/services/sandbox-service.js +2 -0
  173. package/dist/types.d.ts +7 -0
  174. package/dist/version.d.ts +1 -1
  175. package/dist/version.js +1 -1
  176. package/package.json +7 -3
  177. package/skills/belay/SKILL.md +15 -8
  178. package/skills/belay/belay-approve.md +17 -0
  179. package/dist/commands/init-wizard.d.ts +0 -21
  180. package/dist/commands/init-wizard.js +0 -63
@@ -0,0 +1,66 @@
1
+ export const JUDGE_PROVIDER_SESSION_MATRIX = {
2
+ cursor: {
3
+ providerId: 'cursor',
4
+ supportsSession: true,
5
+ resumeFlag: '--resume',
6
+ createChatFlag: null,
7
+ readOnlyArgs: ['--mode', 'ask', '--sandbox', 'enabled', '--trust'],
8
+ rejectedUnsafeOptions: ['--force', '--yolo', '--dangerously-skip-permissions'],
9
+ failureModes: [
10
+ 'parse_error',
11
+ 'non_json_response',
12
+ 'timeout',
13
+ 'cli_nonzero_exit',
14
+ 'io_error',
15
+ 'version_mismatch',
16
+ ],
17
+ notes: 'Pilot provider: ask mode + sandbox enabled; resume via --resume <chatId>.',
18
+ },
19
+ codex: {
20
+ providerId: 'codex',
21
+ supportsSession: false,
22
+ resumeFlag: null,
23
+ createChatFlag: null,
24
+ readOnlyArgs: ['--sandbox', 'read-only'],
25
+ rejectedUnsafeOptions: ['--dangerously-bypass-approvals-and-sandbox'],
26
+ failureModes: ['unsupported_resume', 'parse_error', 'timeout'],
27
+ notes: 'Session transport disabled by default; spawn read-only exec remains canonical.',
28
+ },
29
+ claude: {
30
+ providerId: 'claude',
31
+ supportsSession: false,
32
+ resumeFlag: '--continue',
33
+ createChatFlag: null,
34
+ readOnlyArgs: ['--permission-mode', 'plan', '--tools', '', '--bare'],
35
+ rejectedUnsafeOptions: ['--dangerously-skip-permissions'],
36
+ failureModes: ['unsupported_resume', 'parse_error', 'timeout'],
37
+ notes: 'Session transport gated behind allowlist after capability verification.',
38
+ },
39
+ };
40
+ export function providerSupportsSession(providerId, allowlist) {
41
+ const capability = JUDGE_PROVIDER_SESSION_MATRIX[providerId];
42
+ return capability.supportsSession && allowlist.includes(providerId);
43
+ }
44
+ export function assertReadOnlyInvocationArgs(providerId, args) {
45
+ const capability = JUDGE_PROVIDER_SESSION_MATRIX[providerId];
46
+ for (const unsafe of capability.rejectedUnsafeOptions) {
47
+ if (args.includes(unsafe)) {
48
+ return { ok: false, reason: 'unsafe_option_rejected' };
49
+ }
50
+ }
51
+ for (let index = 0; index < capability.readOnlyArgs.length; index += 2) {
52
+ const flag = capability.readOnlyArgs[index];
53
+ const value = capability.readOnlyArgs[index + 1];
54
+ if (!flag) {
55
+ continue;
56
+ }
57
+ const flagIndex = args.indexOf(flag);
58
+ if (flagIndex === -1) {
59
+ return { ok: false, reason: 'unsafe_option_rejected' };
60
+ }
61
+ if (value !== undefined && args[flagIndex + 1] !== value) {
62
+ return { ok: false, reason: 'unsafe_option_rejected' };
63
+ }
64
+ }
65
+ return { ok: true };
66
+ }
@@ -0,0 +1,42 @@
1
+ import type { JudgeProviderId } from './judge-catalog.js';
2
+ export interface BelayJudgeSessionConfig {
3
+ enabled: boolean;
4
+ maxTurns: number;
5
+ maxAgeMs: number;
6
+ maxIdleMs: number;
7
+ maxPromptBytes: number;
8
+ providerAllowlist: JudgeProviderId[];
9
+ connectTimeoutMs: number;
10
+ evalTimeoutMs: number | null;
11
+ parseTimeoutMs: number;
12
+ maxSessionsPerProvider: number;
13
+ }
14
+ export interface BelayJudgeShadowConfig {
15
+ enabled: boolean;
16
+ sampleRate: number;
17
+ sampleRateMax: number;
18
+ dailyRequestCap: number;
19
+ mismatchRateThreshold: number;
20
+ providerAllowlist: JudgeProviderId[];
21
+ windowSize: number;
22
+ }
23
+ export interface BelayJudgeRuntimeConfig {
24
+ session: BelayJudgeSessionConfig;
25
+ shadow: BelayJudgeShadowConfig;
26
+ }
27
+ export declare const JUDGE_LATENCY_SLO: {
28
+ /** Target: Tier1 p95 at least 40% below spawn baseline. */
29
+ readonly tier1P95ReductionTarget: 0.4;
30
+ /** Reference spawn baseline (ms) from dogfood measurement. */
31
+ readonly spawnBaselineP95Ms: 25000;
32
+ readonly spawnBaselineP50Ms: 1300;
33
+ readonly tier0BaselineP95Ms: 60;
34
+ /** Session path must not exceed spawn p95 when enabled. */
35
+ readonly sessionMaxP95Ms: 15000;
36
+ };
37
+ export declare const DEFAULT_JUDGE_SESSION_CONFIG: BelayJudgeSessionConfig;
38
+ export declare const DEFAULT_JUDGE_SHADOW_CONFIG: BelayJudgeShadowConfig;
39
+ export declare const DEFAULT_JUDGE_RUNTIME_CONFIG: BelayJudgeRuntimeConfig;
40
+ export declare function normalizeJudgeRuntimeConfig(runtime: Partial<BelayJudgeRuntimeConfig> | undefined): BelayJudgeRuntimeConfig;
41
+ export declare function normalizeJudgeSessionConfig(session: Partial<BelayJudgeSessionConfig> | undefined): BelayJudgeSessionConfig;
42
+ export declare function resolveSessionEvalTimeoutMs(sessionConfig: BelayJudgeSessionConfig, judgeTimeoutMs: number): number;
@@ -0,0 +1,92 @@
1
+ export const JUDGE_LATENCY_SLO = {
2
+ /** Target: Tier1 p95 at least 40% below spawn baseline. */
3
+ tier1P95ReductionTarget: 0.4,
4
+ /** Reference spawn baseline (ms) from dogfood measurement. */
5
+ spawnBaselineP95Ms: 25_000,
6
+ spawnBaselineP50Ms: 1_300,
7
+ tier0BaselineP95Ms: 60,
8
+ /** Session path must not exceed spawn p95 when enabled. */
9
+ sessionMaxP95Ms: 15_000,
10
+ };
11
+ export const DEFAULT_JUDGE_SESSION_CONFIG = {
12
+ enabled: false,
13
+ maxTurns: 32,
14
+ maxAgeMs: 30 * 60 * 1000,
15
+ maxIdleMs: 5 * 60 * 1000,
16
+ maxPromptBytes: 64 * 1024,
17
+ providerAllowlist: ['cursor'],
18
+ connectTimeoutMs: 5_000,
19
+ evalTimeoutMs: null,
20
+ parseTimeoutMs: 2_000,
21
+ maxSessionsPerProvider: 1,
22
+ };
23
+ export const DEFAULT_JUDGE_SHADOW_CONFIG = {
24
+ enabled: false,
25
+ sampleRate: 0.01,
26
+ sampleRateMax: 0.05,
27
+ dailyRequestCap: 500,
28
+ mismatchRateThreshold: 0.02,
29
+ providerAllowlist: ['cursor'],
30
+ windowSize: 100,
31
+ };
32
+ export const DEFAULT_JUDGE_RUNTIME_CONFIG = {
33
+ session: { ...DEFAULT_JUDGE_SESSION_CONFIG },
34
+ shadow: { ...DEFAULT_JUDGE_SHADOW_CONFIG },
35
+ };
36
+ const CLI_PROVIDER_IDS = ['codex', 'cursor', 'claude'];
37
+ function normalizeProviderAllowlist(value, fallback) {
38
+ if (!Array.isArray(value)) {
39
+ return [...fallback];
40
+ }
41
+ const allowed = value
42
+ .map((entry) => String(entry).trim())
43
+ .filter((entry) => CLI_PROVIDER_IDS.includes(entry));
44
+ return allowed.length > 0 ? [...new Set(allowed)] : [...fallback];
45
+ }
46
+ function normalizePositiveInt(value, fallback) {
47
+ return typeof value === 'number' && Number.isFinite(value) && value > 0
48
+ ? Math.floor(value)
49
+ : fallback;
50
+ }
51
+ function normalizeRate(value, fallback, max) {
52
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
53
+ return fallback;
54
+ }
55
+ return Math.min(value, max);
56
+ }
57
+ export function normalizeJudgeRuntimeConfig(runtime) {
58
+ const session = runtime?.session ?? {};
59
+ const shadow = runtime?.shadow ?? {};
60
+ return {
61
+ session: normalizeJudgeSessionConfig(session),
62
+ shadow: {
63
+ enabled: shadow.enabled === true,
64
+ sampleRate: normalizeRate(shadow.sampleRate, DEFAULT_JUDGE_SHADOW_CONFIG.sampleRate, 1),
65
+ sampleRateMax: normalizeRate(shadow.sampleRateMax, DEFAULT_JUDGE_SHADOW_CONFIG.sampleRateMax, 1),
66
+ dailyRequestCap: normalizePositiveInt(shadow.dailyRequestCap, DEFAULT_JUDGE_SHADOW_CONFIG.dailyRequestCap),
67
+ mismatchRateThreshold: normalizeRate(shadow.mismatchRateThreshold, DEFAULT_JUDGE_SHADOW_CONFIG.mismatchRateThreshold, 1),
68
+ providerAllowlist: normalizeProviderAllowlist(shadow.providerAllowlist, DEFAULT_JUDGE_SHADOW_CONFIG.providerAllowlist),
69
+ windowSize: normalizePositiveInt(shadow.windowSize, DEFAULT_JUDGE_SHADOW_CONFIG.windowSize),
70
+ },
71
+ };
72
+ }
73
+ export function normalizeJudgeSessionConfig(session) {
74
+ const input = session ?? {};
75
+ return {
76
+ enabled: input.enabled === true,
77
+ maxTurns: normalizePositiveInt(input.maxTurns, DEFAULT_JUDGE_SESSION_CONFIG.maxTurns),
78
+ maxAgeMs: normalizePositiveInt(input.maxAgeMs, DEFAULT_JUDGE_SESSION_CONFIG.maxAgeMs),
79
+ maxIdleMs: normalizePositiveInt(input.maxIdleMs, DEFAULT_JUDGE_SESSION_CONFIG.maxIdleMs),
80
+ maxPromptBytes: normalizePositiveInt(input.maxPromptBytes, DEFAULT_JUDGE_SESSION_CONFIG.maxPromptBytes),
81
+ providerAllowlist: normalizeProviderAllowlist(input.providerAllowlist, DEFAULT_JUDGE_SESSION_CONFIG.providerAllowlist),
82
+ connectTimeoutMs: normalizePositiveInt(input.connectTimeoutMs, DEFAULT_JUDGE_SESSION_CONFIG.connectTimeoutMs),
83
+ evalTimeoutMs: typeof input.evalTimeoutMs === 'number' && input.evalTimeoutMs > 0
84
+ ? Math.floor(input.evalTimeoutMs)
85
+ : null,
86
+ parseTimeoutMs: normalizePositiveInt(input.parseTimeoutMs, DEFAULT_JUDGE_SESSION_CONFIG.parseTimeoutMs),
87
+ maxSessionsPerProvider: normalizePositiveInt(input.maxSessionsPerProvider, DEFAULT_JUDGE_SESSION_CONFIG.maxSessionsPerProvider),
88
+ };
89
+ }
90
+ export function resolveSessionEvalTimeoutMs(sessionConfig, judgeTimeoutMs) {
91
+ return sessionConfig.evalTimeoutMs ?? judgeTimeoutMs;
92
+ }
@@ -0,0 +1,48 @@
1
+ import type { JudgeProviderId } from './judge-catalog.js';
2
+ import type { CliInvocation, CliJudgeRunCommand } from './judge-cli.js';
3
+ import type { BelayJudgeSessionConfig } from './judge-runtime-config.js';
4
+ import { type JudgeSessionBudget, type JudgeSessionKeyParts, type JudgeSessionResetReason } from './judge-session-guard.js';
5
+ export type BrokerSessionState = 'idle' | 'busy';
6
+ export interface BrokerSessionRecord {
7
+ keyParts: JudgeSessionKeyParts;
8
+ budget: JudgeSessionBudget;
9
+ state: BrokerSessionState;
10
+ providerResumeId?: string;
11
+ lastResetReason?: JudgeSessionResetReason;
12
+ }
13
+ export interface BrokerEvaluateRequest {
14
+ keyParts: JudgeSessionKeyParts;
15
+ invocation: CliInvocation;
16
+ promptBytes: number;
17
+ }
18
+ export interface BrokerEvaluateResult {
19
+ raw: string;
20
+ reused: boolean;
21
+ resetReason?: JudgeSessionResetReason;
22
+ providerResumeId?: string;
23
+ }
24
+ type RunCommand = CliJudgeRunCommand;
25
+ export interface JudgeSessionBrokerOptions {
26
+ config: BelayJudgeSessionConfig;
27
+ runCommand: RunCommand;
28
+ extractResumeId?: (providerId: Exclude<JudgeProviderId, 'ollama'>, raw: string) => string | null;
29
+ }
30
+ export declare class JudgeSessionBroker {
31
+ private readonly sessions;
32
+ private readonly mutexes;
33
+ private readonly options;
34
+ constructor(options: JudgeSessionBrokerOptions);
35
+ listSessions(): BrokerSessionRecord[];
36
+ invalidateSession(sessionKey: string, reason: JudgeSessionResetReason): void;
37
+ stopAll(reason?: JudgeSessionResetReason): number;
38
+ evaluate(request: BrokerEvaluateRequest, timeoutMs: number): Promise<BrokerEvaluateResult>;
39
+ private evaluateExclusive;
40
+ private enforceProviderSessionCap;
41
+ private buildResumeInvocation;
42
+ }
43
+ export declare function extractProviderResumeId(providerId: Exclude<JudgeProviderId, 'ollama'>, raw: string): string | null;
44
+ export declare function getRepoJudgeSessionBroker(repoRoot: string, options: JudgeSessionBrokerOptions): JudgeSessionBroker;
45
+ export declare function stopRepoJudgeSessionBroker(repoRoot: string, reason?: JudgeSessionResetReason): number;
46
+ export declare function resetJudgeSessionBrokersForTests(): void;
47
+ export declare function listRepoJudgeSessionBrokers(): string[];
48
+ export {};
@@ -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
+ }