@guilz-dev/belay 0.3.0 → 0.6.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 (242) hide show
  1. package/README.md +7 -1
  2. package/dist/adapters/cursor/runtime-entry.js +1 -0
  3. package/dist/adapters/layouts/scope.js +3 -2
  4. package/dist/adapters/shared/gate-runtime.d.ts +12 -16
  5. package/dist/adapters/shared/gate-runtime.js +499 -43
  6. package/dist/bundle/claude-runtime.mjs +8004 -2676
  7. package/dist/bundle/codex-runtime.mjs +8021 -2683
  8. package/dist/bundle/cursor-runtime.mjs +7993 -2667
  9. package/dist/cli.js +180 -6
  10. package/dist/commands/approve.d.ts +1 -0
  11. package/dist/commands/approve.js +45 -26
  12. package/dist/commands/classify-for-report.js +1 -1
  13. package/dist/commands/config.d.ts +8 -0
  14. package/dist/commands/config.js +264 -58
  15. package/dist/commands/doctor.js +47 -2
  16. package/dist/commands/explain.js +17 -2
  17. package/dist/commands/harvest.d.ts +29 -0
  18. package/dist/commands/harvest.js +72 -0
  19. package/dist/commands/judge.d.ts +11 -1
  20. package/dist/commands/judge.js +24 -4
  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/session.d.ts +20 -0
  25. package/dist/commands/session.js +51 -0
  26. package/dist/commands/simulate.d.ts +1 -0
  27. package/dist/commands/simulate.js +7 -2
  28. package/dist/commands/standing-allow.d.ts +10 -0
  29. package/dist/commands/standing-allow.js +26 -0
  30. package/dist/commands/tui.d.ts +48 -0
  31. package/dist/commands/tui.js +150 -0
  32. package/dist/config-io.js +59 -17
  33. package/dist/conformance/guarantee-posture.d.ts +21 -0
  34. package/dist/conformance/guarantee-posture.js +46 -0
  35. package/dist/conformance/guarantee-table.d.ts +1 -0
  36. package/dist/conformance/guarantee-table.js +14 -9
  37. package/dist/core/approval-replay-cli.d.ts +7 -0
  38. package/dist/core/approval-replay-cli.js +36 -0
  39. package/dist/core/approval-replay.d.ts +45 -0
  40. package/dist/core/approval-replay.js +141 -0
  41. package/dist/core/approval-service.d.ts +15 -0
  42. package/dist/core/approval-service.js +53 -4
  43. package/dist/core/approval.d.ts +26 -3
  44. package/dist/core/approval.js +61 -7
  45. package/dist/core/audit-analysis.d.ts +7 -1
  46. package/dist/core/audit-analysis.js +111 -0
  47. package/dist/core/audit-metrics.d.ts +7 -0
  48. package/dist/core/audit-metrics.js +24 -4
  49. package/dist/core/audit-query.d.ts +1 -0
  50. package/dist/core/audit-query.js +3 -0
  51. package/dist/core/audit-replay-context.d.ts +35 -0
  52. package/dist/core/audit-replay-context.js +88 -0
  53. package/dist/core/audit-types.d.ts +24 -1
  54. package/dist/core/audit-types.js +1 -1
  55. package/dist/core/capability/approval-state-mutation.d.ts +13 -0
  56. package/dist/core/capability/approval-state-mutation.js +92 -0
  57. package/dist/core/capability/approval-v3.d.ts +15 -0
  58. package/dist/core/capability/approval-v3.js +74 -0
  59. package/dist/core/capability/attestation.d.ts +13 -0
  60. package/dist/core/capability/attestation.js +55 -0
  61. package/dist/core/capability/boundary-attestation-sign.d.ts +21 -0
  62. package/dist/core/capability/boundary-attestation-sign.js +84 -0
  63. package/dist/core/capability/boundary-driver-container.d.ts +7 -0
  64. package/dist/core/capability/boundary-driver-container.js +122 -0
  65. package/dist/core/capability/boundary-driver.d.ts +29 -0
  66. package/dist/core/capability/boundary-driver.js +43 -0
  67. package/dist/core/capability/boundary-egress.d.ts +21 -0
  68. package/dist/core/capability/boundary-egress.js +109 -0
  69. package/dist/core/capability/boundary-grant-materialize.d.ts +13 -0
  70. package/dist/core/capability/boundary-grant-materialize.js +78 -0
  71. package/dist/core/capability/boundary-profile.d.ts +15 -0
  72. package/dist/core/capability/boundary-profile.js +25 -0
  73. package/dist/core/capability/boundary-run.d.ts +23 -0
  74. package/dist/core/capability/boundary-run.js +23 -0
  75. package/dist/core/capability/boundary-session.d.ts +57 -0
  76. package/dist/core/capability/boundary-session.js +129 -0
  77. package/dist/core/capability/capability-request-hash.d.ts +2 -0
  78. package/dist/core/capability/capability-request-hash.js +8 -0
  79. package/dist/core/capability/gate-latency-ratchet.d.ts +7 -0
  80. package/dist/core/capability/gate-latency-ratchet.js +18 -0
  81. package/dist/core/capability/gate-policy-shadow.d.ts +25 -0
  82. package/dist/core/capability/gate-policy-shadow.js +108 -0
  83. package/dist/core/capability/gate-shadow-audit.d.ts +1 -0
  84. package/dist/core/capability/gate-shadow-audit.js +1 -0
  85. package/dist/core/capability/gate-shadow-ratchet.d.ts +14 -0
  86. package/dist/core/capability/gate-shadow-ratchet.js +81 -0
  87. package/dist/core/capability/grant-lease.d.ts +9 -0
  88. package/dist/core/capability/grant-lease.js +46 -0
  89. package/dist/core/capability/grant-loader.d.ts +12 -0
  90. package/dist/core/capability/grant-loader.js +23 -0
  91. package/dist/core/capability/grant-match.d.ts +6 -0
  92. package/dist/core/capability/grant-match.js +93 -0
  93. package/dist/core/capability/grant.d.ts +16 -0
  94. package/dist/core/capability/grant.js +16 -0
  95. package/dist/core/capability/index.d.ts +10 -1
  96. package/dist/core/capability/index.js +8 -0
  97. package/dist/core/capability/limits.d.ts +7 -0
  98. package/dist/core/capability/limits.js +55 -0
  99. package/dist/core/capability/paths.d.ts +2 -2
  100. package/dist/core/capability/paths.js +9 -9
  101. package/dist/core/capability/policy-bridge.d.ts +18 -0
  102. package/dist/core/capability/policy-bridge.js +51 -0
  103. package/dist/core/capability/policy-engine.d.ts +70 -0
  104. package/dist/core/capability/policy-engine.js +476 -0
  105. package/dist/core/capability/policy-types.d.ts +20 -0
  106. package/dist/core/capability/policy-types.js +1 -0
  107. package/dist/core/capability/request.d.ts +45 -0
  108. package/dist/core/capability/request.js +1 -0
  109. package/dist/core/capability/resolver.d.ts +4 -0
  110. package/dist/core/capability/resolver.js +4 -0
  111. package/dist/core/capability/trusted-workspace-roots.d.ts +25 -0
  112. package/dist/core/capability/trusted-workspace-roots.js +149 -0
  113. package/dist/core/capability/types.d.ts +11 -1
  114. package/dist/core/capability-approval.d.ts +2 -1
  115. package/dist/core/capability-approval.js +100 -2
  116. package/dist/core/classify-subagent.d.ts +2 -1
  117. package/dist/core/classify-subagent.js +40 -22
  118. package/dist/core/classify-tool.js +157 -64
  119. package/dist/core/config.d.ts +32 -3
  120. package/dist/core/config.js +105 -3
  121. package/dist/core/gate-contract.d.ts +3 -0
  122. package/dist/core/gate-contract.js +3 -0
  123. package/dist/core/gate-engine.js +12 -7
  124. package/dist/core/harvest.d.ts +53 -0
  125. package/dist/core/harvest.js +276 -0
  126. package/dist/core/index.d.ts +9 -3
  127. package/dist/core/index.js +6 -2
  128. package/dist/core/judge-doctor.d.ts +3 -0
  129. package/dist/core/judge-doctor.js +90 -11
  130. package/dist/core/judge-fallback-hints.d.ts +13 -0
  131. package/dist/core/judge-fallback-hints.js +108 -0
  132. package/dist/core/path-utils.d.ts +12 -0
  133. package/dist/core/path-utils.js +60 -7
  134. package/dist/core/reclassify.d.ts +3 -0
  135. package/dist/core/reclassify.js +44 -14
  136. package/dist/core/recovery/capability.d.ts +3 -0
  137. package/dist/core/recovery/capability.js +22 -0
  138. package/dist/core/recovery/fail-closed.d.ts +6 -0
  139. package/dist/core/recovery/fail-closed.js +25 -0
  140. package/dist/core/recovery/index.d.ts +3 -0
  141. package/dist/core/recovery/index.js +2 -0
  142. package/dist/core/recovery/types.d.ts +20 -0
  143. package/dist/core/recovery/types.js +1 -0
  144. package/dist/core/replay-scrub.d.ts +9 -0
  145. package/dist/core/replay-scrub.js +43 -0
  146. package/dist/core/shell-tokenizer.d.ts +2 -0
  147. package/dist/core/shell-tokenizer.js +82 -23
  148. package/dist/core/standing-allow.d.ts +50 -0
  149. package/dist/core/standing-allow.js +178 -0
  150. package/dist/core/transactional/diff-evaluator.js +4 -1
  151. package/dist/core/transactional/eligibility.js +4 -0
  152. package/dist/core/transactional/git-worktree.d.ts +10 -3
  153. package/dist/core/transactional/git-worktree.js +81 -11
  154. package/dist/core/transactional/reasons.d.ts +1 -0
  155. package/dist/core/transactional/reasons.js +4 -0
  156. package/dist/core/transactional/runner.js +22 -6
  157. package/dist/core/transactional/types.d.ts +4 -0
  158. package/dist/core/types.d.ts +36 -3
  159. package/dist/core/verdict/adapter.d.ts +3 -4
  160. package/dist/core/verdict/adapter.js +19 -6
  161. package/dist/core/verdict/containment.d.ts +4 -3
  162. package/dist/core/verdict/containment.js +17 -12
  163. package/dist/core/verdict/cursor-acp-client.d.ts +67 -0
  164. package/dist/core/verdict/cursor-acp-client.js +345 -0
  165. package/dist/core/verdict/egress-classify.d.ts +2 -2
  166. package/dist/core/verdict/egress-classify.js +2 -2
  167. package/dist/core/verdict/git-classifier.d.ts +26 -0
  168. package/dist/core/verdict/git-classifier.js +533 -0
  169. package/dist/core/verdict/judge-audit.d.ts +1 -0
  170. package/dist/core/verdict/judge-audit.js +33 -1
  171. package/dist/core/verdict/judge-baseline.d.ts +20 -0
  172. package/dist/core/verdict/judge-baseline.js +49 -0
  173. package/dist/core/verdict/judge-broker-service.d.ts +42 -0
  174. package/dist/core/verdict/judge-broker-service.js +291 -0
  175. package/dist/core/verdict/judge-catalog.js +1 -0
  176. package/dist/core/verdict/judge-cli-fingerprint.d.ts +6 -0
  177. package/dist/core/verdict/judge-cli-fingerprint.js +67 -0
  178. package/dist/core/verdict/judge-cli.d.ts +23 -0
  179. package/dist/core/verdict/judge-cli.js +139 -16
  180. package/dist/core/verdict/judge-factory.js +13 -4
  181. package/dist/core/verdict/judge-provider-matrix.d.ts +20 -0
  182. package/dist/core/verdict/judge-provider-matrix.js +66 -0
  183. package/dist/core/verdict/judge-runtime-config.d.ts +44 -0
  184. package/dist/core/verdict/judge-runtime-config.js +96 -0
  185. package/dist/core/verdict/judge-session-broker.d.ts +48 -0
  186. package/dist/core/verdict/judge-session-broker.js +200 -0
  187. package/dist/core/verdict/judge-session-guard.d.ts +27 -0
  188. package/dist/core/verdict/judge-session-guard.js +91 -0
  189. package/dist/core/verdict/judge-session-kill-switch.d.ts +11 -0
  190. package/dist/core/verdict/judge-session-kill-switch.js +43 -0
  191. package/dist/core/verdict/judge-session-mutex.d.ts +9 -0
  192. package/dist/core/verdict/judge-session-mutex.js +23 -0
  193. package/dist/core/verdict/judge-shadow.d.ts +24 -0
  194. package/dist/core/verdict/judge-shadow.js +116 -0
  195. package/dist/core/verdict/judge-transport.d.ts +44 -0
  196. package/dist/core/verdict/judge-transport.js +268 -0
  197. package/dist/core/verdict/judge.d.ts +14 -14
  198. package/dist/core/verdict/judge.js +5 -67
  199. package/dist/core/verdict/launcher-resolve.d.ts +2 -0
  200. package/dist/core/verdict/launcher-resolve.js +16 -0
  201. package/dist/core/verdict/parser.js +6 -1
  202. package/dist/core/verdict/prescan.d.ts +15 -0
  203. package/dist/core/verdict/prescan.js +68 -0
  204. package/dist/core/verdict/shell-policy.d.ts +25 -0
  205. package/dist/core/verdict/shell-policy.js +40 -0
  206. package/dist/core/verdict/shell-semantics.d.ts +24 -0
  207. package/dist/core/verdict/shell-semantics.js +225 -0
  208. package/dist/core/verdict/types.d.ts +26 -1
  209. package/dist/core/verdict/verdict.js +300 -179
  210. package/dist/corpus/adversarial-probe.d.ts +86 -0
  211. package/dist/corpus/adversarial-probe.js +220 -0
  212. package/dist/corpus/evaluate.d.ts +45 -12
  213. package/dist/corpus/evaluate.js +61 -8
  214. package/dist/corpus/gate-latency-budget.d.ts +39 -0
  215. package/dist/corpus/gate-latency-budget.js +33 -0
  216. package/dist/corpus/gates.d.ts +35 -0
  217. package/dist/corpus/gates.js +81 -0
  218. package/dist/corpus/judge-accuracy.d.ts +8 -0
  219. package/dist/corpus/judge-accuracy.js +39 -0
  220. package/dist/corpus/must-allow-commands.d.ts +5 -0
  221. package/dist/corpus/must-allow-commands.js +13 -0
  222. package/dist/corpus/mutators.d.ts +28 -0
  223. package/dist/corpus/mutators.js +162 -0
  224. package/dist/corpus/ratchet.d.ts +42 -0
  225. package/dist/corpus/ratchet.js +116 -0
  226. package/dist/corpus/runtime-match.d.ts +19 -0
  227. package/dist/corpus/runtime-match.js +43 -0
  228. package/dist/corpus/standing-allow-catalog.generated.d.ts +13 -0
  229. package/dist/corpus/standing-allow-catalog.generated.js +99 -0
  230. package/dist/corpus/types.d.ts +47 -0
  231. package/dist/corpus/types.js +157 -0
  232. package/dist/installer.js +4 -1
  233. package/dist/judge-broker-daemon.d.ts +1 -0
  234. package/dist/judge-broker-daemon.js +137 -0
  235. package/dist/services/sandbox-service.d.ts +5 -0
  236. package/dist/services/sandbox-service.js +30 -2
  237. package/dist/types.d.ts +2 -2
  238. package/dist/version.d.ts +1 -1
  239. package/dist/version.js +1 -1
  240. package/package.json +7 -3
  241. package/skills/belay/SKILL.md +7 -1
  242. package/skills/belay/belay-approve.md +17 -0
@@ -8,11 +8,14 @@ import { clearJudgeCredentialStore, writeJudgeCredentialStore } from '../core/cr
8
8
  import { refreshIntegrityIfPinned } from '../core/integrity.js';
9
9
  import { defaultJudgeProviderForAdapter, hasValidCloudConsent, isCloudJudgeConfig, resolveJudgeUsePatch, } from '../core/judge-config.js';
10
10
  import { rejectDeprecatedJudgeModelAuto } from '../core/judge-model-policy.js';
11
- import { isJudgeProviderId, JUDGE_PROVIDER_IDS, normalizeLegacyProviderId, } from '../core/verdict/judge-catalog.js';
11
+ import { detectJudgeRuntimeCapabilities, resolveJudgeTransport, } from '../core/judge-runtime-detection.js';
12
+ import { getJudgeProviderSpec, isJudgeProviderId, JUDGE_PROVIDER_IDS, normalizeLegacyProviderId, } from '../core/verdict/judge-catalog.js';
13
+ import { normalizeJudgeRuntimeConfig } from '../core/verdict/judge-runtime-config.js';
12
14
  import { initProject } from '../installer.js';
13
15
  import { isBelayFloorInstalled } from './health-snapshot.js';
14
16
  import { judgeStatus } from './judge.js';
15
17
  import { readKeyFromStdin } from './stdin-key.js';
18
+ import { confirmPrompt, intro, isInteractiveTTY, selectPrompt } from './tui.js';
16
19
  export const BELAY_CONFIG_SUBCOMMANDS = [
17
20
  'list',
18
21
  'get',
@@ -29,6 +32,8 @@ const JUDGE_CONFIG_PATHS = [
29
32
  'judge.timeoutMs',
30
33
  'judge.credential.mode',
31
34
  'judge.credential.ref',
35
+ 'judge.runtime.session.enabled',
36
+ 'judge.runtime.shadow.enabled',
32
37
  ];
33
38
  export function parseAdapter(value) {
34
39
  const normalized = (value?.trim() || 'cursor').toLowerCase();
@@ -96,6 +101,12 @@ function getJudgeField(judge, pathKey) {
96
101
  return judge.credential?.mode ?? null;
97
102
  if (pathKey === 'judge.credential.ref')
98
103
  return judge.credential?.ref ?? null;
104
+ if (pathKey === 'judge.runtime.session.enabled') {
105
+ return normalizeJudgeRuntimeConfig(judge.runtime).session.enabled;
106
+ }
107
+ if (pathKey === 'judge.runtime.shadow.enabled') {
108
+ return normalizeJudgeRuntimeConfig(judge.runtime).shadow.enabled;
109
+ }
99
110
  throw new Error(`Unknown judge config path: ${pathKey}`);
100
111
  }
101
112
  function listJudgeFields(judge) {
@@ -106,7 +117,9 @@ function listJudgeFields(judge) {
106
117
  return entries;
107
118
  }
108
119
  function warnCloudConsentIfNeeded(judge) {
109
- if (isCloudJudgeConfig(judge) && !hasValidCloudConsent(judge)) {
120
+ if (isCloudJudgeConfig(judge) &&
121
+ resolveJudgeTransport(judge) === 'http' &&
122
+ !hasValidCloudConsent(judge)) {
110
123
  process.stderr.write('Warning: Cloud judge saved without recorded consent. Tier1 cloud judge will fail closed until consent is granted (belay judge consent + belay approve, or TTY --accept-cloud-judge).\n');
111
124
  }
112
125
  }
@@ -177,6 +190,36 @@ async function applyJudgeSet(repoRoot, config, pathKey, rawValue) {
177
190
  await persistJudge(repoRoot, config, judge, adapter);
178
191
  return judge;
179
192
  }
193
+ if (pathKey === 'judge.runtime.session.enabled') {
194
+ if (value !== 'true' && value !== 'false') {
195
+ throw new Error('judge.runtime.session.enabled must be true or false.');
196
+ }
197
+ const runtime = normalizeJudgeRuntimeConfig(config.judge.runtime);
198
+ const judge = normalizeJudgeConfig({
199
+ ...config.judge,
200
+ runtime: {
201
+ ...runtime,
202
+ session: { ...runtime.session, enabled: value === 'true' },
203
+ },
204
+ });
205
+ await persistJudge(repoRoot, config, judge, adapter);
206
+ return judge;
207
+ }
208
+ if (pathKey === 'judge.runtime.shadow.enabled') {
209
+ if (value !== 'true' && value !== 'false') {
210
+ throw new Error('judge.runtime.shadow.enabled must be true or false.');
211
+ }
212
+ const runtime = normalizeJudgeRuntimeConfig(config.judge.runtime);
213
+ const judge = normalizeJudgeConfig({
214
+ ...config.judge,
215
+ runtime: {
216
+ ...runtime,
217
+ shadow: { ...runtime.shadow, enabled: value === 'true' },
218
+ },
219
+ });
220
+ await persistJudge(repoRoot, config, judge, adapter);
221
+ return judge;
222
+ }
180
223
  throw new Error(`Unknown judge config path: ${pathKey}`);
181
224
  }
182
225
  async function applyJudgeUnset(repoRoot, config, pathKey) {
@@ -285,46 +328,196 @@ export async function runBelayConfigCredential(options) {
285
328
  }
286
329
  throw new Error('credential requires action: mode, set, or clear.');
287
330
  }
288
- async function collectCloudJudgeWizardAnswers(ask, judgeProviderId) {
289
- if (judgeProviderId === 'ollama') {
290
- return { acceptCloud: false };
331
+ export const JUDGE_CREDENTIAL_MODE_PROMPT = 'Judge API key source';
332
+ export const JUDGE_CREDENTIAL_STORE_KEY_PROMPT = 'Paste API key to store locally (visible input; prefer belay config credential set --key-stdin): ';
333
+ export const JUDGE_TRANSPORT_MODE_PROMPT = 'How should Belay reach the judge?';
334
+ export const JUDGE_HTTP_ENDPOINT_PROMPT = 'Judge HTTP API URL:';
335
+ const CLOUD_CLI_LABELS = {
336
+ codex: 'Codex CLI',
337
+ claude: 'Claude CLI',
338
+ cursor: 'Cursor CLI',
339
+ };
340
+ export function buildJudgeTransportSelectOptions(judgeProviderId) {
341
+ const cliLabel = CLOUD_CLI_LABELS[judgeProviderId];
342
+ const runtime = detectJudgeRuntimeCapabilities(judgeProviderId);
343
+ const cliHint = runtime.cliTransport
344
+ ? 'Uses your logged-in host session; no URL needed.'
345
+ : 'Host CLI not found on PATH — install it or choose custom HTTP API.';
346
+ return {
347
+ message: JUDGE_TRANSPORT_MODE_PROMPT,
348
+ defaultValue: 'cli',
349
+ choices: [
350
+ {
351
+ value: 'cli',
352
+ label: `Use ${cliLabel} (recommended)`,
353
+ hint: cliHint,
354
+ },
355
+ {
356
+ value: 'http',
357
+ label: 'Custom HTTP API endpoint',
358
+ hint: 'Requires a provider API URL and cloud egress consent.',
359
+ },
360
+ ],
361
+ };
362
+ }
363
+ export function buildJudgeCredentialModeSelectOptions(judgeProviderId) {
364
+ const spec = getJudgeProviderSpec(judgeProviderId);
365
+ const providerEnvVars = spec?.apiKeyEnvVars.filter((name) => name !== 'BELAY_JUDGE_API_KEY') ?? [];
366
+ const envNames = providerEnvVars.length > 0
367
+ ? ['BELAY_JUDGE_API_KEY', ...providerEnvVars].join(', ')
368
+ : 'BELAY_JUDGE_API_KEY';
369
+ const projectHint = providerEnvVars.length > 0
370
+ ? `Uses ${envNames} from the shell; does not read .env files. Or host CLI login when available.`
371
+ : 'Uses BELAY_JUDGE_API_KEY from the shell; does not read .env files.';
372
+ return {
373
+ message: JUDGE_CREDENTIAL_MODE_PROMPT,
374
+ defaultValue: 'project',
375
+ choices: [
376
+ {
377
+ value: 'project',
378
+ label: 'Environment variables or host CLI',
379
+ hint: projectHint,
380
+ },
381
+ {
382
+ value: 'apiKey',
383
+ label: 'Store in Belay credential store',
384
+ hint: 'Saved to credentials.json (mode 0600) on the next step.',
385
+ },
386
+ ],
387
+ };
388
+ }
389
+ function formatSelectPrompt(options) {
390
+ const hasDetails = options.choices.some((choice) => choice.hint || choice.label);
391
+ if (hasDetails) {
392
+ const lines = options.choices.map((choice) => {
393
+ const label = choice.label ?? choice.value;
394
+ const hint = choice.hint ? ` — ${choice.hint}` : '';
395
+ return ` ${choice.value}: ${label}${hint}`;
396
+ });
397
+ const values = options.choices.map((choice) => choice.value).join(' | ');
398
+ return `${options.message}\n${lines.join('\n')}\n[${values}] (${options.defaultValue}): `;
291
399
  }
292
- const judgeCredentialMode = parseYesNo(await ask('Use project env for credentials? [y=project | n=apiKey] (y): '), true)
293
- ? 'project'
294
- : 'apiKey';
295
- const optionalEndpoint = (await ask('Judge endpoint URL (optional): ')).trim();
296
- const judgeEndpoint = optionalEndpoint || undefined;
297
- if (judgeCredentialMode === 'apiKey') {
298
- const key = await ask('Paste API key (hidden input not available in all shells): ');
299
- if (key.trim()) {
300
- process.env.BELAY_CONFIG_WIZARD_JUDGE_KEY = key.trim();
400
+ const values = options.choices.map((choice) => choice.value).join(' | ');
401
+ return `${options.message} [${values}] (${options.defaultValue}): `;
402
+ }
403
+ function formatConfirmPrompt(message, defaultValue) {
404
+ return `${message} [y | n] (${defaultValue ? 'y' : 'n'}): `;
405
+ }
406
+ function parseSelectAnswer(raw, options) {
407
+ const normalized = raw.trim().toLowerCase();
408
+ if (!normalized) {
409
+ return options.defaultValue;
410
+ }
411
+ const found = options.choices.find((choice) => {
412
+ if (choice.value.toLowerCase() === normalized) {
413
+ return true;
301
414
  }
415
+ return choice.label?.trim().toLowerCase() === normalized;
416
+ });
417
+ if (!found) {
418
+ throw new Error(`Invalid choice for ${options.message}: ${raw}`);
302
419
  }
303
- let acceptCloud = false;
304
- if (judgeEndpoint) {
305
- acceptCloud = parseYesNo(await ask('Accept cloud judge egress (redacted commands leave the repo)? [y | n] (n): '), false);
306
- }
307
- return { judgeCredentialMode, judgeEndpoint, acceptCloud };
420
+ return found.value;
308
421
  }
309
- async function withConfigPrompter(fn, prompts) {
310
- if (prompts) {
311
- let index = 0;
312
- const ask = async (message) => {
313
- if (index >= prompts.length) {
314
- throw new Error(`unexpected config prompt: ${message}`);
315
- }
316
- return prompts[index++];
317
- };
318
- return fn(ask);
319
- }
422
+ async function askTextLazy(message) {
320
423
  const rl = readline.createInterface({ input, output });
321
424
  try {
322
- return await fn((message) => rl.question(message));
425
+ return (await rl.question(message)).trimEnd();
323
426
  }
324
427
  finally {
325
428
  rl.close();
326
429
  }
327
430
  }
431
+ function createTestPrompter(prompts) {
432
+ let index = 0;
433
+ const nextPrompt = (message) => {
434
+ if (index >= prompts.length) {
435
+ throw new Error(`unexpected config prompt: ${message}`);
436
+ }
437
+ return prompts[index++];
438
+ };
439
+ return {
440
+ askText: async (message) => nextPrompt(message),
441
+ askSelect: async (options, parseRaw) => {
442
+ const raw = nextPrompt(formatSelectPrompt(options));
443
+ return parseRaw ? parseRaw(raw, options) : parseSelectAnswer(raw, options);
444
+ },
445
+ askConfirm: async (message, defaultValue) => parseYesNo(nextPrompt(formatConfirmPrompt(message, defaultValue)), defaultValue),
446
+ };
447
+ }
448
+ function createReadlinePrompter() {
449
+ return {
450
+ askText: askTextLazy,
451
+ askSelect: async (options, parseRaw) => {
452
+ const raw = await askTextLazy(formatSelectPrompt(options));
453
+ return parseRaw ? parseRaw(raw, options) : parseSelectAnswer(raw, options);
454
+ },
455
+ askConfirm: async (message, defaultValue) => parseYesNo(await askTextLazy(formatConfirmPrompt(message, defaultValue)), defaultValue),
456
+ };
457
+ }
458
+ function createTuiPrompter() {
459
+ return {
460
+ askText: askTextLazy,
461
+ askSelect: selectPrompt,
462
+ askConfirm: confirmPrompt,
463
+ };
464
+ }
465
+ async function withConfigWizardPrompter(options, fn) {
466
+ if (options.prompts) {
467
+ return fn(createTestPrompter(options.prompts));
468
+ }
469
+ if (isInteractiveTTY()) {
470
+ return fn(createTuiPrompter());
471
+ }
472
+ return fn(createReadlinePrompter());
473
+ }
474
+ function writeConfigWizardBanner(options, title) {
475
+ if (options.skipBanner) {
476
+ return;
477
+ }
478
+ if (isInteractiveTTY()) {
479
+ intro(title);
480
+ return;
481
+ }
482
+ output.write(`${title}\n`);
483
+ }
484
+ async function askJudgeHttpEndpoint(prompter) {
485
+ while (true) {
486
+ const endpoint = (await prompter.askText(JUDGE_HTTP_ENDPOINT_PROMPT)).trim();
487
+ if (endpoint) {
488
+ return endpoint;
489
+ }
490
+ process.stderr.write('Warning: HTTP endpoint URL is required when using custom HTTP API. Paste a URL or re-run belay config and choose host CLI.\n');
491
+ }
492
+ }
493
+ async function askJudgeCredentialStoreKey(prompter) {
494
+ while (true) {
495
+ const key = (await prompter.askText(JUDGE_CREDENTIAL_STORE_KEY_PROMPT)).trim();
496
+ if (key) {
497
+ return key;
498
+ }
499
+ process.stderr.write('Warning: API key cannot be empty. Paste a key or re-run belay config and choose environment variables.\n');
500
+ }
501
+ }
502
+ async function collectCloudJudgeWizardAnswers(prompter, judgeProviderId) {
503
+ if (judgeProviderId === 'ollama') {
504
+ return { acceptCloud: false };
505
+ }
506
+ const judgeCredentialMode = await prompter.askSelect(buildJudgeCredentialModeSelectOptions(judgeProviderId));
507
+ const transportMode = await prompter.askSelect(buildJudgeTransportSelectOptions(judgeProviderId));
508
+ let judgeEndpoint;
509
+ if (transportMode === 'http') {
510
+ judgeEndpoint = await askJudgeHttpEndpoint(prompter);
511
+ }
512
+ if (judgeCredentialMode === 'apiKey') {
513
+ process.env.BELAY_CONFIG_WIZARD_JUDGE_KEY = await askJudgeCredentialStoreKey(prompter);
514
+ }
515
+ let acceptCloud = false;
516
+ if (judgeEndpoint) {
517
+ acceptCloud = await prompter.askConfirm('Accept cloud judge egress (redacted commands leave the repo)?', false);
518
+ }
519
+ return { judgeCredentialMode, judgeEndpoint, acceptCloud };
520
+ }
328
521
  export async function resolveBelayConfigInteractiveMode(repoRoot) {
329
522
  try {
330
523
  return (await isBelayFloorInstalled({ targetDir: repoRoot })) ? 'judge-only' : 'full';
@@ -333,16 +526,26 @@ export async function resolveBelayConfigInteractiveMode(repoRoot) {
333
526
  return 'full';
334
527
  }
335
528
  }
336
- async function runBelayConfigFullWithPrompter(ask, options) {
337
- if (!options.skipBanner) {
338
- output.write('belay config\n');
339
- }
340
- const adapter = parseAdapter(await ask('Adapter [cursor | claude | codex] (cursor): '));
341
- const scope = parseScope(await ask('Install scope [project | global] (project): '));
342
- const withSkill = parseYesNo(await ask('Install SKILL.md and slash commands? [y | n] (y): '), true);
529
+ async function runBelayConfigFullWithWizard(prompter, options) {
530
+ writeConfigWizardBanner(options, 'belay config');
531
+ const adapter = await prompter.askSelect({
532
+ message: 'Adapter',
533
+ defaultValue: 'cursor',
534
+ choices: [{ value: 'cursor' }, { value: 'claude' }, { value: 'codex' }],
535
+ });
536
+ const scope = await prompter.askSelect({
537
+ message: 'Install scope',
538
+ defaultValue: 'project',
539
+ choices: [{ value: 'project' }, { value: 'global' }],
540
+ });
541
+ const withSkill = await prompter.askConfirm('Install SKILL.md and slash commands?', true);
343
542
  const defaultJudgeProviderId = defaultJudgeProviderForAdapter(adapter);
344
- const judgeProviderId = parseJudgeProviderId(await ask(`Judge provider [${JUDGE_PROVIDER_IDS.join(' | ')}] (${defaultJudgeProviderId}): `), defaultJudgeProviderId);
345
- const { judgeCredentialMode, judgeEndpoint, acceptCloud } = await collectCloudJudgeWizardAnswers(ask, judgeProviderId);
543
+ const judgeProviderId = await prompter.askSelect({
544
+ message: 'Judge provider',
545
+ defaultValue: defaultJudgeProviderId,
546
+ choices: JUDGE_PROVIDER_IDS.map((providerId) => ({ value: providerId })),
547
+ }, (raw, selectOptions) => parseJudgeProviderId(raw, selectOptions.defaultValue));
548
+ const { judgeCredentialMode, judgeEndpoint, acceptCloud } = await collectCloudJudgeWizardAnswers(prompter, judgeProviderId);
346
549
  const initOptions = buildInitOptionsFromConfigAnswers({
347
550
  adapter,
348
551
  scope,
@@ -362,19 +565,22 @@ async function runBelayConfigFullWithPrompter(ask, options) {
362
565
  }
363
566
  return result;
364
567
  }
365
- async function runBelayConfigJudgeOnlyWithPrompter(ask, options, repoRoot, config, adapter) {
366
- if (!options.skipBanner) {
367
- output.write('belay config (judge only)\n');
368
- }
568
+ async function runBelayConfigJudgeOnlyWithWizard(prompter, options, repoRoot, config, adapter) {
569
+ writeConfigWizardBanner(options, 'belay config (judge only)');
369
570
  const defaultJudgeProviderId = defaultJudgeProviderForAdapter(adapter);
370
- const judgeProviderId = parseJudgeProviderId(await ask(`Judge provider [${JUDGE_PROVIDER_IDS.join(' | ')}] (${defaultJudgeProviderId}): `), defaultJudgeProviderId);
371
- const { judgeCredentialMode, judgeEndpoint, acceptCloud } = await collectCloudJudgeWizardAnswers(ask, judgeProviderId);
571
+ const judgeProviderId = await prompter.askSelect({
572
+ message: 'Judge provider',
573
+ defaultValue: defaultJudgeProviderId,
574
+ choices: JUDGE_PROVIDER_IDS.map((providerId) => ({ value: providerId })),
575
+ }, (raw, selectOptions) => parseJudgeProviderId(raw, selectOptions.defaultValue));
576
+ const { judgeCredentialMode, judgeEndpoint, acceptCloud } = await collectCloudJudgeWizardAnswers(prompter, judgeProviderId);
372
577
  const patch = resolveJudgeUsePatch(config.judge, {
373
578
  providerId: judgeProviderId,
374
579
  endpoint: judgeEndpoint,
375
580
  credentialMode: judgeCredentialMode,
376
581
  acceptCloud: acceptCloud && Boolean(judgeEndpoint),
377
- interactiveTTY: true,
582
+ // `prompts` is a scripted stand-in for interactive responses in tests.
583
+ interactiveTTY: isInteractiveTTY() || Boolean(options.prompts),
378
584
  interactiveConsentApproved: acceptCloud && Boolean(judgeEndpoint),
379
585
  });
380
586
  if (patch.errors.length > 0) {
@@ -399,13 +605,13 @@ async function runBelayConfigJudgeOnlyWithPrompter(ask, options, repoRoot, confi
399
605
  return { repoRoot, adapter };
400
606
  }
401
607
  export async function runBelayConfigFullInteractive(options = {}) {
402
- return withConfigPrompter((ask) => runBelayConfigFullWithPrompter(ask, options), options.prompts);
608
+ return withConfigWizardPrompter(options, (prompter) => runBelayConfigFullWithWizard(prompter, options));
403
609
  }
404
610
  export async function runBelayConfigJudgeOnlyInteractive(options = {}) {
405
611
  const repoRoot = path.resolve(options.targetDir ?? process.cwd());
406
612
  const config = await loadConfigFile(repoRoot);
407
613
  const adapter = resolveAdapterName(config);
408
- return withConfigPrompter((ask) => runBelayConfigJudgeOnlyWithPrompter(ask, options, repoRoot, config, adapter), options.prompts);
614
+ return withConfigWizardPrompter(options, (prompter) => runBelayConfigJudgeOnlyWithWizard(prompter, options, repoRoot, config, adapter));
409
615
  }
410
616
  export async function runBelayConfigInteractive(options = {}) {
411
617
  const repoRoot = path.resolve(options.targetDir ?? process.cwd());
@@ -427,16 +633,16 @@ export async function runBelayConfigInteractive(options = {}) {
427
633
  skipBanner: true,
428
634
  });
429
635
  }
430
- return withConfigPrompter(async (ask) => {
431
- output.write('belay config\n');
432
- const judgeOnly = parseYesNo(await ask('Configure judge only? [Y/n]: '), true);
433
- if (judgeOnly) {
434
- const config = await loadConfigFile(repoRoot);
435
- const adapter = resolveAdapterName(config);
436
- return runBelayConfigJudgeOnlyWithPrompter(ask, { ...options, skipBanner: true }, repoRoot, config, adapter);
636
+ return withConfigWizardPrompter(options, async (prompter) => {
637
+ writeConfigWizardBanner(options, 'belay config');
638
+ const judgeOnly = await prompter.askConfirm('Configure judge only?', true);
639
+ if (!judgeOnly) {
640
+ return runBelayConfigFullWithWizard(prompter, { ...options, skipBanner: true });
437
641
  }
438
- return runBelayConfigFullWithPrompter(ask, { ...options, skipBanner: true });
439
- }, options.prompts);
642
+ const config = await loadConfigFile(repoRoot);
643
+ const adapter = resolveAdapterName(config);
644
+ return runBelayConfigJudgeOnlyWithWizard(prompter, { ...options, skipBanner: true }, repoRoot, config, adapter);
645
+ });
440
646
  }
441
647
  return runBelayConfigFullInteractive(options);
442
648
  }
@@ -8,9 +8,12 @@ import { resolveScopedPaths } from '../adapters/layouts/scope.js';
8
8
  import { cleanupOrphanApprovalState } from '../cleanup-orphans.js';
9
9
  import { approvedApprovalsPath, belayStateDir, detectAdapterName, loadLayeredConfig, pendingApprovalsPath, repoLocalStateDirFor, } from '../config-io.js';
10
10
  import { detectFenceDrift, summarizeAuditVisibility } from '../core/audit-summary.js';
11
+ import { inspectBoundaryAttestationFile } from '../core/capability/boundary-attestation-sign.js';
12
+ import { boundaryAttestationPath, boundarySessionStatus, } from '../core/capability/boundary-session.js';
11
13
  import { defaultControlPlaneDir } from '../core/config.js';
12
14
  import { verifyIntegrityManifest } from '../core/integrity.js';
13
- import { diagnoseJudge } from '../core/judge-doctor.js';
15
+ import { diagnoseJudge, stopJudgeSessionBrokers } from '../core/judge-doctor.js';
16
+ import { resolveJudgeTransport } from '../core/judge-runtime-detection.js';
14
17
  import { getManagedHookEntries } from '../defaults.js';
15
18
  import { resolveNodeBinary } from '../node-resolution.js';
16
19
  import { egressStatus } from '../services/egress-service.js';
@@ -78,7 +81,9 @@ export async function doctorProject(options = {}) {
78
81
  if (loadedConfig.version !== 4) {
79
82
  warnings.push(`Config version is ${loadedConfig.version}; expected 4. Run belay upgrade to migrate.`);
80
83
  }
81
- const judgeDoctor = await diagnoseJudge(loadedConfig);
84
+ const transport = resolveJudgeTransport(loadedConfig.judge);
85
+ const shouldProbe = transport.endsWith('-cli');
86
+ const judgeDoctor = await diagnoseJudge(loadedConfig, repoRoot, { liveProbe: shouldProbe });
82
87
  issues.push(...judgeDoctor.issues);
83
88
  warnings.push(...judgeDoctor.warnings);
84
89
  notes.push(...judgeDoctor.notes);
@@ -239,6 +244,16 @@ export async function doctorProject(options = {}) {
239
244
  else {
240
245
  notes.push('No orphan approval cleanup actions were needed.');
241
246
  }
247
+ const judgeStateDir = belayStateDir(loadedConfig, repoLocalDir);
248
+ if (options.dryRun !== true) {
249
+ const stoppedBrokers = await stopJudgeSessionBrokers(repoRoot, judgeStateDir);
250
+ if (stoppedBrokers > 0) {
251
+ notes.push(`Stopped judge session broker artifacts for ${repoRoot}.`);
252
+ }
253
+ }
254
+ else {
255
+ notes.push('Dry run: would stop judge session broker and clear kill switch if present.');
256
+ }
242
257
  }
243
258
  let dogfood = null;
244
259
  if (loadedConfig) {
@@ -284,6 +299,36 @@ export async function doctorProject(options = {}) {
284
299
  for (const issue of sandbox.issues) {
285
300
  warnings.push(issue);
286
301
  }
302
+ for (const advisory of sandbox.advisories) {
303
+ warnings.push(advisory);
304
+ }
305
+ }
306
+ const boundaryDriver = loadedConfig.capability?.boundaryDriver ??
307
+ (loadedConfig.sandbox.runtime === 'container' ? 'container' : null);
308
+ if (loadedConfig.capability || boundaryDriver === 'container') {
309
+ const attestationPath = boundaryAttestationPath(repoRoot, loadedConfig);
310
+ const attestationFormat = await inspectBoundaryAttestationFile(attestationPath);
311
+ if (attestationFormat === 'legacy') {
312
+ warnings.push('Boundary attestation uses an unsigned legacy format. Run belay session start to re-sign it.');
313
+ }
314
+ else if (attestationFormat === 'invalid') {
315
+ warnings.push('Boundary attestation file is present but invalid. Run belay session start to regenerate it.');
316
+ }
317
+ }
318
+ if (boundaryDriver === 'container') {
319
+ const session = await boundarySessionStatus({ repoRoot, config: loadedConfig });
320
+ if (!session.attestation) {
321
+ warnings.push('Container boundary driver is configured but no attestation was found. Run belay session start.');
322
+ }
323
+ else if (!session.fresh) {
324
+ warnings.push('Boundary attestation is stale or expired. Run belay session start to refresh.');
325
+ }
326
+ else if (session.attestation.probeSignals.includes('repo-mount-ro-default')) {
327
+ notes.push('Container boundary defaults to read-only directory mounts; predicted writes use read-write mounts for the working directory only.');
328
+ }
329
+ else if (session.attestation.probeSignals.includes('repo-mount-rw')) {
330
+ notes.push('Container boundary mounts the working directory read-write (repo-mount-rw). Policy grants scope widening; paths outside the mount are not container-enforced.');
331
+ }
287
332
  }
288
333
  if (loadedConfig.egress.enabled) {
289
334
  const egress = await egressStatus({ targetDir: repoRoot });
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { loadApprovalState, loadConfigFile } from '../config-io.js';
3
3
  import { compactApprovals } from '../core/approval.js';
4
+ import { extractJudgeFallbackReason, formatJudgeRecoveryHint, inferProviderIdFromFallbackReason, isJudgeInfrastructureFailure, } from '../core/judge-fallback-hints.js';
4
5
  import { classifyForReport } from './classify-for-report.js';
5
6
  export async function explainCommand(options) {
6
7
  const repoRoot = path.resolve(options.targetDir ?? process.cwd());
@@ -11,11 +12,16 @@ export async function explainCommand(options) {
11
12
  const latest = [...pending.approvals].sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))[0];
12
13
  const inputKind = latest.inputKind ??
13
14
  (latest.kind === 'egress' || latest.kind === 'capability' ? 'shell' : latest.kind);
15
+ const payload = latest.payloadJson !== undefined
16
+ ? JSON.parse(latest.payloadJson)
17
+ : undefined;
14
18
  const classified = await classifyForReport({
15
19
  targetDir: repoRoot,
16
- cwd: options.cwd,
20
+ cwd: latest.cwd ?? options.cwd,
17
21
  kind: inputKind,
18
22
  command: latest.input ?? latest.summary,
23
+ toolName: latest.toolName,
24
+ payload,
19
25
  });
20
26
  return {
21
27
  repoRoot: classified.repoRoot,
@@ -129,5 +135,14 @@ export function formatExplainReport(report) {
129
135
  ? 'Observed assessment: measured in an isolated git worktree at gate time. Observed-safe commands are applied once and the hook denies re-execution (transactional_already_applied).'
130
136
  : 'Observed assessment: not applicable (transactional path not eligible).',
131
137
  ];
132
- return `${lines.join('\n')}\n`;
138
+ const recoveryLines = [];
139
+ if (isJudgeInfrastructureFailure(result)) {
140
+ const fallbackReason = extractJudgeFallbackReason(result);
141
+ const providerId = inferProviderIdFromFallbackReason(fallbackReason);
142
+ const recoveryHint = formatJudgeRecoveryHint(providerId, fallbackReason);
143
+ if (recoveryHint) {
144
+ recoveryLines.push('', 'Recovery:', ` ${recoveryHint}`, ' Then verify: belay judge test --live-probe');
145
+ }
146
+ }
147
+ return `${[...lines, ...recoveryLines].join('\n')}\n`;
133
148
  }
@@ -0,0 +1,29 @@
1
+ import type { AuditRecord } from '../core/audit-types.js';
2
+ import { type HarvestReport, type HarvestReviewOutcome } from '../core/harvest.js';
3
+ export interface HarvestListOptions {
4
+ targetDir?: string;
5
+ since?: string;
6
+ until?: string;
7
+ json?: boolean;
8
+ }
9
+ export interface HarvestApplyOptions {
10
+ targetDir?: string;
11
+ command: string;
12
+ outcome: HarvestReviewOutcome;
13
+ reason?: string;
14
+ corpusPath?: string;
15
+ }
16
+ export declare function harvestListProject(options?: HarvestListOptions): Promise<HarvestReport>;
17
+ export declare function harvestReportFromRecords(records: AuditRecord[], options?: {
18
+ since?: string;
19
+ until?: string;
20
+ allowPatterns?: string[];
21
+ }): HarvestReport;
22
+ export declare function formatHarvestReport(report: HarvestReport): string;
23
+ export declare function harvestApplyProject(options: HarvestApplyOptions): Promise<{
24
+ ok: boolean;
25
+ message: string;
26
+ corpusPath: string;
27
+ }>;
28
+ /** Parse audit ndjson for tests without full project layout. */
29
+ export declare function harvestReportFromNdjson(raw: string): HarvestReport;
@@ -0,0 +1,72 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { loadConfigFile } from '../config-io.js';
4
+ import { parseAuditNdjson } from '../core/audit-metrics.js';
5
+ import { toAuditRecord } from '../core/audit-query.js';
6
+ import { applyHarvestReview, buildHarvestReport, filterRecordsForHarvest, } from '../core/harvest.js';
7
+ import { parseCorpusCases } from '../corpus/types.js';
8
+ import { loadAuditRecords } from './audit.js';
9
+ export async function harvestListProject(options = {}) {
10
+ const repoRoot = path.resolve(options.targetDir ?? process.cwd());
11
+ const config = await loadConfigFile(repoRoot);
12
+ const records = await loadAuditRecords(repoRoot);
13
+ return harvestReportFromRecords(records, {
14
+ since: options.since,
15
+ until: options.until,
16
+ allowPatterns: config.overrides.allow,
17
+ });
18
+ }
19
+ export function harvestReportFromRecords(records, options = {}) {
20
+ return buildHarvestReport(filterRecordsForHarvest(records, { since: options.since, until: options.until }), { allowPatterns: options.allowPatterns });
21
+ }
22
+ export function formatHarvestReport(report) {
23
+ const lines = [
24
+ `belay harvest (scope: ${report.scope} audit traces only)`,
25
+ `Schema: v${report.schemaVersion}`,
26
+ '',
27
+ `Benign candidates (${report.candidates.length}):`,
28
+ ];
29
+ if (report.candidates.length === 0) {
30
+ lines.push('- (none)');
31
+ }
32
+ else {
33
+ for (const candidate of report.candidates) {
34
+ lines.push(`- ${JSON.stringify(candidate.command)} [${candidate.sources.join(', ')}] asks=${candidate.askCount} approved=${candidate.approvedAfterDeny ? 'yes' : 'no'} fp=${candidate.fingerprint.slice(0, 12)}…`);
35
+ }
36
+ }
37
+ lines.push('', `Availability queue (${report.availabilityQueue.length}):`);
38
+ if (report.availabilityQueue.length === 0) {
39
+ lines.push('- (none)');
40
+ }
41
+ else {
42
+ for (const entry of report.availabilityQueue) {
43
+ lines.push(`- ${JSON.stringify(entry.command)} signal=${entry.availabilitySignal} asks=${entry.askCount} (${entry.reason})`);
44
+ }
45
+ }
46
+ lines.push('', 'Candidates are review-only signals — approve in audit does not auto-promote to corpus.', 'Time filters (--since/--until) keep paired deny/approval rows for round-trip detection.', 'Use: belay harvest apply --command "<text>" --outcome provably-benign|accepted-benign|reject');
47
+ return lines.join('\n');
48
+ }
49
+ export async function harvestApplyProject(options) {
50
+ const repoRoot = path.resolve(options.targetDir ?? process.cwd());
51
+ const corpusPath = path.resolve(repoRoot, options.corpusPath ?? path.join('corpus', 'shell-commands.json'));
52
+ const raw = await readFile(corpusPath, 'utf8');
53
+ const cases = parseCorpusCases(JSON.parse(raw));
54
+ const result = applyHarvestReview(cases, {
55
+ command: options.command,
56
+ outcome: options.outcome,
57
+ reason: options.reason,
58
+ });
59
+ if (result.applied) {
60
+ await writeFile(corpusPath, `${JSON.stringify(result.cases, null, 2)}\n`, 'utf8');
61
+ }
62
+ return {
63
+ ok: result.ok,
64
+ message: result.message,
65
+ corpusPath: path.relative(repoRoot, corpusPath) || corpusPath,
66
+ };
67
+ }
68
+ /** Parse audit ndjson for tests without full project layout. */
69
+ export function harvestReportFromNdjson(raw) {
70
+ const records = parseAuditNdjson(raw).map((entry) => toAuditRecord(entry));
71
+ return harvestReportFromRecords(records);
72
+ }