@guilz-dev/belay 0.2.0 → 0.3.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 (76) hide show
  1. package/README.md +39 -12
  2. package/dist/adapters/shared/gate-runtime.js +13 -5
  3. package/dist/bundle/claude-runtime.mjs +1659 -371
  4. package/dist/bundle/codex-runtime.mjs +1658 -371
  5. package/dist/bundle/cursor-runtime.mjs +1659 -371
  6. package/dist/cli.js +198 -8
  7. package/dist/commands/approve.js +11 -0
  8. package/dist/commands/config.d.ts +70 -0
  9. package/dist/commands/config.js +494 -0
  10. package/dist/commands/doctor.js +2 -1
  11. package/dist/commands/health-snapshot.d.ts +6 -0
  12. package/dist/commands/health-snapshot.js +34 -0
  13. package/dist/commands/judge.d.ts +90 -0
  14. package/dist/commands/judge.js +285 -0
  15. package/dist/commands/status.js +2 -2
  16. package/dist/commands/stdin-key.d.ts +1 -0
  17. package/dist/commands/stdin-key.js +8 -0
  18. package/dist/conformance/guarantee-table.js +12 -0
  19. package/dist/conformance/types.d.ts +2 -0
  20. package/dist/core/audit-io.d.ts +2 -0
  21. package/dist/core/audit-io.js +14 -0
  22. package/dist/core/capability/index.d.ts +2 -2
  23. package/dist/core/capability/index.js +2 -2
  24. package/dist/core/capability/paths.d.ts +1 -0
  25. package/dist/core/capability/paths.js +74 -6
  26. package/dist/core/capability/reasons.d.ts +3 -0
  27. package/dist/core/capability/reasons.js +8 -0
  28. package/dist/core/classify-tool.js +97 -27
  29. package/dist/core/config-layers.js +2 -1
  30. package/dist/core/config.d.ts +22 -2
  31. package/dist/core/config.js +110 -11
  32. package/dist/core/credential-store.d.ts +11 -0
  33. package/dist/core/credential-store.js +60 -0
  34. package/dist/core/gate-engine.js +104 -13
  35. package/dist/core/integrity.d.ts +2 -0
  36. package/dist/core/integrity.js +13 -0
  37. package/dist/core/judge-api-key.d.ts +19 -0
  38. package/dist/core/judge-api-key.js +74 -0
  39. package/dist/core/judge-cloud-consent.d.ts +13 -0
  40. package/dist/core/judge-cloud-consent.js +38 -0
  41. package/dist/core/judge-config.d.ts +41 -4
  42. package/dist/core/judge-config.js +263 -57
  43. package/dist/core/judge-doctor.d.ts +6 -1
  44. package/dist/core/judge-doctor.js +147 -96
  45. package/dist/core/judge-model-discovery.d.ts +24 -0
  46. package/dist/core/judge-model-discovery.js +168 -0
  47. package/dist/core/judge-model-policy.d.ts +5 -0
  48. package/dist/core/judge-model-policy.js +21 -0
  49. package/dist/core/judge-runtime-detection.d.ts +9 -0
  50. package/dist/core/judge-runtime-detection.js +68 -0
  51. package/dist/core/transactional/diff-evaluator.js +1 -19
  52. package/dist/core/types.d.ts +2 -0
  53. package/dist/core/verdict/adapter.d.ts +1 -0
  54. package/dist/core/verdict/adapter.js +7 -1
  55. package/dist/core/verdict/containment.d.ts +5 -0
  56. package/dist/core/verdict/containment.js +32 -2
  57. package/dist/core/verdict/judge-catalog.d.ts +40 -0
  58. package/dist/core/verdict/judge-catalog.js +148 -0
  59. package/dist/core/verdict/judge-cli.d.ts +23 -0
  60. package/dist/core/verdict/judge-cli.js +280 -0
  61. package/dist/core/verdict/judge-factory.d.ts +15 -4
  62. package/dist/core/verdict/judge-factory.js +117 -14
  63. package/dist/core/verdict/judge.d.ts +20 -1
  64. package/dist/core/verdict/judge.js +83 -14
  65. package/dist/core/verdict/persistent-paths.d.ts +8 -0
  66. package/dist/core/verdict/persistent-paths.js +52 -0
  67. package/dist/core/verdict/types.d.ts +6 -2
  68. package/dist/core/verdict/verdict.js +161 -47
  69. package/dist/installer.js +66 -15
  70. package/dist/types.d.ts +7 -0
  71. package/dist/version.d.ts +1 -1
  72. package/dist/version.js +1 -1
  73. package/package.json +1 -1
  74. package/skills/belay/SKILL.md +8 -7
  75. package/dist/commands/init-wizard.d.ts +0 -21
  76. package/dist/commands/init-wizard.js +0 -63
@@ -1,10 +1,9 @@
1
- import path from 'node:path';
2
- import { relativeWithinRepo } from '../path-utils.js';
1
+ import { resolveMutationTarget } from '../path-utils.js';
3
2
  import { extractRedirectTargets, tokenizeShell } from '../shell-tokenizer.js';
4
- import { analyzePathTargets, cwdRelative, resolveTrustedPath } from './containment.js';
3
+ import { analyzePathTargets, cwdRelative, isDestructiveHighStakesMutation, resolveTrustedPath, touchesProtectedRoot, } from './containment.js';
5
4
  import { classifyEgressTool } from './egress-classify.js';
6
5
  import { verdictFingerprint } from './fingerprint.js';
7
- import { prescanInterpreterCode, tier1RequiresAsk } from './judge.js';
6
+ import { mutationPrescanRequiresAsk, prescanInterpreterCode, tier1RequiresAsk } from './judge.js';
8
7
  import { isRoutineLauncher, resolveLauncherRecipe } from './launcher-resolve.js';
9
8
  import { allowFromCustomOverride, askFromCustomExternal, customAllowMatch, customExternalMatch, } from './overrides.js';
10
9
  import { extractRecursiveScript, isBareInterpreter, isVariableIndirectHead, parseSegment, peelTransparentWrappers, redactCommand, segmentOpacity, splitTopLevelSegments, substitutionInners, } from './parser.js';
@@ -257,13 +256,45 @@ function tier0ExternalMatch(key, head, tokens) {
257
256
  }
258
257
  return false;
259
258
  }
259
+ function isBelayJudgeSelfCommand(tokens) {
260
+ return (tokens[0] === 'belay' &&
261
+ tokens[1] === 'judge' &&
262
+ (tokens[2] === 'use' ||
263
+ tokens[2] === 'status' ||
264
+ tokens[2] === 'list' ||
265
+ tokens[2] === 'test' ||
266
+ tokens[2] === 'consent'));
267
+ }
268
+ function isBelayConfigSelfCommand(tokens) {
269
+ if (tokens[0] !== 'belay' || tokens[1] !== 'config') {
270
+ return false;
271
+ }
272
+ const sub = tokens[2];
273
+ if (!sub) {
274
+ return true;
275
+ }
276
+ if (sub === 'list' || sub === 'get' || sub === 'judge') {
277
+ return true;
278
+ }
279
+ if (sub === 'credential') {
280
+ return true;
281
+ }
282
+ if (sub === 'set' || sub === 'unset') {
283
+ const pathKey = tokens[3] ?? '';
284
+ return pathKey.startsWith('judge.');
285
+ }
286
+ return false;
287
+ }
260
288
  function isBelaySelfCommand(tokens) {
261
289
  const head = tokens[0] ?? '';
262
290
  const subcommand = tokens[1] ?? '';
263
- return head === 'belay' && BELAY_SELF_COMMANDS.has(subcommand);
291
+ return (head === 'belay' &&
292
+ (BELAY_SELF_COMMANDS.has(subcommand) ||
293
+ isBelayJudgeSelfCommand(tokens) ||
294
+ isBelayConfigSelfCommand(tokens)));
264
295
  }
265
296
  function tier0HighStakesRm(tokens, context) {
266
- const head = tokens[0] ?? '';
297
+ const head = (tokens[0] ?? '').split('/').pop() ?? '';
267
298
  if (head !== 'rm') {
268
299
  return null;
269
300
  }
@@ -286,31 +317,40 @@ function tier0HighStakesRm(tokens, context) {
286
317
  signals: ['missing_trusted_cwd', ...analysis.signals],
287
318
  });
288
319
  }
289
- if (analysis.isHighStakes) {
290
- return askVerdict({
291
- location: analysis.location,
292
- opacity: 'transparent',
293
- effect: 'local_mutation',
294
- confidence: 'deterministic',
295
- reason: 'high_stakes_path',
296
- signals: ['high_stakes_path', ...analysis.signals],
297
- });
298
- }
299
320
  for (const target of targets) {
300
- if (target === '~' || target.startsWith('~/') || target.startsWith('/')) {
301
- const resolved = path.resolve(target === '~' || target.startsWith('~/') ? (process.env.HOME ?? '/') : context.cwd, target);
302
- const relative = relativeWithinRepo(context.repoRoot, resolved);
303
- if (relative === null) {
304
- return askVerdict({
305
- location: 'repo_outside',
306
- opacity: 'transparent',
307
- effect: 'local_mutation',
308
- confidence: 'deterministic',
309
- reason: 'repo_outside_mutation',
310
- signals: ['repo_outside_mutation'],
311
- });
321
+ const normalized = target.replace(/^['"]|['"]$/g, '');
322
+ if (normalized === '~' || normalized === '$HOME' || normalized.startsWith('~/')) {
323
+ return askVerdict({
324
+ location: 'repo_outside',
325
+ opacity: 'transparent',
326
+ effect: 'local_mutation',
327
+ confidence: 'deterministic',
328
+ reason: 'high_stakes_path',
329
+ signals: ['catastrophic_home_deletion', ...analysis.signals],
330
+ });
331
+ }
332
+ }
333
+ if (analysis.isHighStakes && analysis.signals.includes('high_stakes_path')) {
334
+ let destructiveHighStakes = false;
335
+ for (const target of targets) {
336
+ const resolved = resolveTrustedPath(target, context.cwd, context.trustedCwd) ??
337
+ resolveMutationTarget(target, context.cwd);
338
+ if (resolved &&
339
+ isDestructiveHighStakesMutation(head, resolved, context.repoRoot, context.sensitivePaths, context.protectedArtifactRoots)) {
340
+ destructiveHighStakes = true;
341
+ break;
312
342
  }
313
343
  }
344
+ if (destructiveHighStakes) {
345
+ return askVerdict({
346
+ location: analysis.location,
347
+ opacity: 'transparent',
348
+ effect: 'local_mutation',
349
+ confidence: 'deterministic',
350
+ reason: 'high_stakes_path',
351
+ signals: ['high_stakes_path', ...analysis.signals],
352
+ });
353
+ }
314
354
  }
315
355
  return null;
316
356
  }
@@ -515,6 +555,9 @@ async function evaluateSegment(command, context, depth) {
515
555
  effect = 'local_mutation';
516
556
  }
517
557
  const pathArgs = extractPathArgs(peeled);
558
+ if (extractRedirectTargets(peeled).length > 0 && effect === 'read_only') {
559
+ effect = 'local_mutation';
560
+ }
518
561
  const pathAnalysis = analyzePathTargets({
519
562
  targets: pathArgs,
520
563
  cwd: context.cwd,
@@ -545,15 +588,45 @@ async function evaluateSegment(command, context, depth) {
545
588
  });
546
589
  }
547
590
  }
548
- if (pathAnalysis.isHighStakes) {
549
- return askVerdict({
550
- location: pathAnalysis.location,
551
- opacity: 'transparent',
552
- effect: 'local_mutation',
553
- confidence: 'deterministic',
554
- reason: 'high_stakes_path',
555
- signals: pathAnalysis.signals,
556
- });
591
+ if ((effect === 'local_mutation' || effect === 'unknown') &&
592
+ context.protectedArtifactRoots &&
593
+ context.protectedArtifactRoots.length > 0) {
594
+ for (const target of pathArgs) {
595
+ const resolved = resolveTrustedPath(target, context.cwd, context.trustedCwd) ??
596
+ resolveMutationTarget(target, context.cwd);
597
+ if (resolved && touchesProtectedRoot(resolved, context.protectedArtifactRoots)) {
598
+ return askVerdict({
599
+ location: pathAnalysis.location,
600
+ opacity: 'transparent',
601
+ effect: 'local_mutation',
602
+ confidence: 'deterministic',
603
+ reason: 'high_stakes_path',
604
+ signals: ['high_stakes_path', 'control_plane_path'],
605
+ });
606
+ }
607
+ }
608
+ }
609
+ if (effect === 'local_mutation' || effect === 'unknown') {
610
+ let destructiveHighStakes = false;
611
+ for (const target of pathArgs) {
612
+ const resolved = resolveTrustedPath(target, context.cwd, context.trustedCwd) ??
613
+ resolveMutationTarget(target, context.cwd);
614
+ if (resolved &&
615
+ isDestructiveHighStakesMutation(segment.head, resolved, context.repoRoot, context.sensitivePaths, context.protectedArtifactRoots)) {
616
+ destructiveHighStakes = true;
617
+ break;
618
+ }
619
+ }
620
+ if (destructiveHighStakes) {
621
+ return askVerdict({
622
+ location: pathAnalysis.location,
623
+ opacity: 'transparent',
624
+ effect: 'local_mutation',
625
+ confidence: 'deterministic',
626
+ reason: 'high_stakes_path',
627
+ signals: pathAnalysis.signals,
628
+ });
629
+ }
557
630
  }
558
631
  if (segment.head === 'find' && isFindDangerous(peeled)) {
559
632
  return askVerdict({
@@ -565,15 +638,15 @@ async function evaluateSegment(command, context, depth) {
565
638
  signals: ['find_dangerous_action'],
566
639
  });
567
640
  }
568
- if (pathAnalysis.location === 'repo_outside' || pathAnalysis.location === 'mixed') {
569
- const outsideEffect = effect === 'read_only' ? 'read_only' : effect === 'unknown' ? 'local_mutation' : effect;
570
- return askVerdict({
641
+ if (effect === 'read_only' &&
642
+ (pathAnalysis.location === 'repo_outside' || pathAnalysis.location === 'mixed')) {
643
+ return allowVerdict({
571
644
  location: pathAnalysis.location,
572
- opacity: 'transparent',
573
- effect: outsideEffect,
645
+ opacity,
646
+ effect: 'read_only',
574
647
  confidence: 'deterministic',
575
- reason: 'repo_outside_mutation',
576
- signals: ['repo_outside_mutation', ...pathAnalysis.signals],
648
+ reason: 'read_only',
649
+ signals: ['read_only', ...pathAnalysis.signals],
577
650
  });
578
651
  }
579
652
  if (pathAnalysis.location === 'unknown' &&
@@ -588,7 +661,34 @@ async function evaluateSegment(command, context, depth) {
588
661
  signals: ['unknown_location_mutation'],
589
662
  });
590
663
  }
591
- const needsTier1 = effect === 'unknown' || TIER0_EXTERNAL_HEADS.has(segment.head) || egressClass === 'ambiguous';
664
+ const outsideMutation = pathAnalysis.location === 'repo_outside' || pathAnalysis.location === 'mixed';
665
+ const mutationPrescan = (effect === 'local_mutation' || effect === 'unknown') && pathArgs.length > 0
666
+ ? mutationPrescanRequiresAsk({
667
+ targets: pathArgs,
668
+ cwd: context.cwd,
669
+ repoRoot: context.repoRoot,
670
+ trustedCwd: context.trustedCwd,
671
+ sensitivePaths: context.sensitivePaths,
672
+ })
673
+ : null;
674
+ if (mutationPrescan) {
675
+ return askVerdict({
676
+ location: pathAnalysis.location === 'unknown'
677
+ ? 'unknown'
678
+ : pathAnalysis.location === 'repo_outside' || pathAnalysis.location === 'mixed'
679
+ ? pathAnalysis.location
680
+ : 'repo_local',
681
+ opacity,
682
+ effect: 'local_mutation',
683
+ confidence: 'deterministic',
684
+ reason: 'tier1_catastrophic',
685
+ signals: ['tier1_catastrophic', mutationPrescan.reason],
686
+ });
687
+ }
688
+ const needsTier1 = effect === 'unknown' ||
689
+ TIER0_EXTERNAL_HEADS.has(segment.head) ||
690
+ egressClass === 'ambiguous' ||
691
+ (outsideMutation && effect !== 'read_only');
592
692
  let tier1Trace;
593
693
  if (needsTier1) {
594
694
  const tier1Text = recursiveScript ?? command;
@@ -600,9 +700,13 @@ async function evaluateSegment(command, context, depth) {
600
700
  tier1Trace = context.judge.lastTrace;
601
701
  if (tier1RequiresAsk(tier1)) {
602
702
  return askVerdict({
603
- location: pathAnalysis.location === 'unknown' ? 'unknown' : 'repo_local',
703
+ location: pathAnalysis.location === 'unknown'
704
+ ? 'unknown'
705
+ : pathAnalysis.location === 'repo_outside' || pathAnalysis.location === 'mixed'
706
+ ? pathAnalysis.location
707
+ : 'repo_local',
604
708
  opacity,
605
- effect: tier1.external_change ? 'remote_mutation' : effect,
709
+ effect: !tier1.local_recoverable ? 'remote_mutation' : effect,
606
710
  confidence: 'llm',
607
711
  reason: 'tier1_catastrophic',
608
712
  signals: ['tier1_catastrophic', tier1.reason],
@@ -610,6 +714,16 @@ async function evaluateSegment(command, context, depth) {
610
714
  });
611
715
  }
612
716
  }
717
+ if (outsideMutation && effect !== 'read_only') {
718
+ return withJudgeTrace(allowVerdict({
719
+ location: pathAnalysis.location,
720
+ opacity,
721
+ effect: 'local_mutation',
722
+ confidence: tier1Trace ? 'llm' : 'deterministic',
723
+ reason: 'repo_outside_local_mutation',
724
+ signals: ['repo_outside_local_mutation', ...pathAnalysis.signals],
725
+ }), tier1Trace);
726
+ }
613
727
  if (pathAnalysis.location === 'repo_local' &&
614
728
  (effect === 'read_only' || effect === 'local_mutation') &&
615
729
  opacity !== 'opaque') {
package/dist/installer.js CHANGED
@@ -7,9 +7,10 @@ import { resolveScopedPaths } from './adapters/layouts/scope.js';
7
7
  import { getAdapter } from './adapters/registry.js';
8
8
  import { dogfoodProject } from './commands/dogfood.js';
9
9
  import { detectAdapterName, loadConfigFile, mergeAndWriteConfig, writeConfigFile, } from './config-io.js';
10
- import { isFreshConfigInput, mergeConfig, normalizeConfig } from './core/config.js';
10
+ import { appendCliAuditEvent } from './core/audit-io.js';
11
+ import { isFreshConfigInput, mergeConfig, normalizeConfig, } from './core/config.js';
11
12
  import { runtimeIntegrityFiles, writeIntegrityManifest } from './core/integrity.js';
12
- import { resolveInitJudgeConfig } from './core/judge-config.js';
13
+ import { hasValidCloudConsent, isCloudJudgeConfig, migrateImplicitLocalJudgeIfNeeded, resolveInitJudgeConfig, } from './core/judge-config.js';
13
14
  import { bootstrapStateFiles, writeSkillArtifacts } from './installer/bootstrap.js';
14
15
  import { writeRuntimeArtifacts } from './installer/runtime-artifacts.js';
15
16
  import { applyInstallScope, resolveOperationScope } from './installer/scope-config.js';
@@ -104,32 +105,62 @@ function resolveAdapterName(options, repoRoot) {
104
105
  }
105
106
  return 'cursor';
106
107
  }
107
- async function applyInitJudgeConfig(repoRoot, adapterName, options) {
108
- if (options.skipJudgeWrite) {
108
+ async function auditJudgeMigrationIfNeeded(repoRoot, adapterName, before, after, by) {
109
+ if (before.providerId === after.providerId && before.provider === after.provider) {
109
110
  return;
110
111
  }
111
- const layout = getAdapter(adapterName).layout;
112
- const configPath = layout.configPath(repoRoot);
113
- let existingConfig = {};
114
- if (await pathExists(configPath)) {
115
- existingConfig = JSON.parse(await readFile(configPath, 'utf8'));
112
+ const config = await loadConfigFile(repoRoot, adapterName);
113
+ await appendCliAuditEvent(repoRoot, config, {
114
+ event: 'judge_provider_changed',
115
+ from: { providerId: before.providerId, provider: before.provider },
116
+ to: { providerId: after.providerId, provider: after.provider },
117
+ by,
118
+ });
119
+ }
120
+ async function applyInitJudgeConfig(repoRoot, adapterName, options, isFreshBeforeInstall) {
121
+ if (options.skipJudgeWrite) {
122
+ return;
116
123
  }
117
- const isFresh = isFreshConfigInput(existingConfig);
118
124
  const mergedConfig = await loadConfigFile(repoRoot, adapterName);
119
- const hasExplicitJudgeFlags = options.judgeProfile || options.judgeProvider || options.judgeModel || options.judgeEndpoint;
125
+ const hasExplicitJudgeFlags = options.judgeProfile ||
126
+ options.judgeProvider ||
127
+ options.judgeProviderId ||
128
+ options.judgeModel ||
129
+ options.judgeEndpoint;
120
130
  const judge = resolveInitJudgeConfig({
121
- isFresh,
131
+ isFresh: isFreshBeforeInstall,
122
132
  hasExplicitJudgeFlags: Boolean(hasExplicitJudgeFlags),
123
133
  judgeProfile: options.judgeProfile,
124
134
  judgeProvider: options.judgeProvider,
135
+ judgeProviderId: options.judgeProviderId,
125
136
  judgeModel: options.judgeModel,
126
137
  judgeEndpoint: options.judgeEndpoint,
127
138
  acceptCloudJudge: options.acceptCloudJudge,
139
+ interactiveConsent: Boolean(options.acceptCloudJudge && process.stdin.isTTY),
140
+ cloudConsentApprovalId: options.cloudConsentApprovalId,
128
141
  existingJudge: mergedConfig.judge,
129
- defaultJudgeProfile: adapterName,
142
+ adapter: adapterName,
130
143
  });
131
- const configWithJudge = normalizeConfig({ ...mergedConfig, version: 4, judge });
144
+ const migrated = options.migrateJudgeDefault === true
145
+ ? migrateImplicitLocalJudgeIfNeeded(mergedConfig.judge, adapterName)
146
+ : null;
147
+ let finalJudge = migrated ?? judge;
148
+ if (options.judgeCredentialMode) {
149
+ finalJudge = {
150
+ ...finalJudge,
151
+ credential: options.judgeCredentialMode === 'apiKey'
152
+ ? { mode: 'apiKey', ref: 'store:judge' }
153
+ : { mode: 'project' },
154
+ };
155
+ }
156
+ const configWithJudge = normalizeConfig({ ...mergedConfig, version: 4, judge: finalJudge });
157
+ if (isCloudJudgeConfig(configWithJudge.judge) && !hasValidCloudConsent(configWithJudge.judge)) {
158
+ 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');
159
+ }
132
160
  await writeConfigFile(repoRoot, configWithJudge, adapterName);
161
+ if (migrated) {
162
+ await auditJudgeMigrationIfNeeded(repoRoot, adapterName, mergedConfig.judge, finalJudge, options.migrateJudgeDefault ? 'belay init --migrate-judge-default' : 'belay init');
163
+ }
133
164
  }
134
165
  async function refreshIntegrityManifest(repoRoot, adapterName) {
135
166
  const layout = getAdapter(adapterName).layout;
@@ -142,8 +173,19 @@ export async function initProject(options = {}) {
142
173
  const repoRoot = path.resolve(options.targetDir ?? process.cwd());
143
174
  const adapterName = resolveAdapterName(options, repoRoot);
144
175
  const adapter = getAdapter(adapterName);
176
+ const configPath = adapter.layout.configPath(repoRoot);
177
+ let isFreshBeforeInstall = true;
178
+ if (await pathExists(configPath)) {
179
+ try {
180
+ const raw = await readFile(configPath, 'utf8');
181
+ isFreshBeforeInstall = isFreshConfigInput(JSON.parse(raw));
182
+ }
183
+ catch {
184
+ isFreshBeforeInstall = false;
185
+ }
186
+ }
145
187
  const result = await adapter.install(repoRoot, options);
146
- await applyInitJudgeConfig(repoRoot, adapterName, options);
188
+ await applyInitJudgeConfig(repoRoot, adapterName, options, isFreshBeforeInstall);
147
189
  if (options.preset) {
148
190
  const existing = await loadConfigFile(repoRoot, adapterName);
149
191
  const presetConfig = mergeConfig(applyConfigPreset(options.preset));
@@ -165,6 +207,15 @@ export async function upgradeProject(options = {}) {
165
207
  const repoRoot = path.resolve(options.targetDir ?? process.cwd());
166
208
  const adapterName = resolveAdapterName(options, repoRoot);
167
209
  await getAdapter(adapterName).upgrade(repoRoot, options);
210
+ if (options.migrateJudgeDefault === true) {
211
+ const mergedConfig = await loadConfigFile(repoRoot, adapterName);
212
+ const migrated = migrateImplicitLocalJudgeIfNeeded(mergedConfig.judge, adapterName);
213
+ if (migrated) {
214
+ const configWithJudge = normalizeConfig({ ...mergedConfig, version: 4, judge: migrated });
215
+ await writeConfigFile(repoRoot, configWithJudge, adapterName);
216
+ await auditJudgeMigrationIfNeeded(repoRoot, adapterName, mergedConfig.judge, migrated, 'belay upgrade --migrate-judge-default');
217
+ }
218
+ }
168
219
  await refreshIntegrityManifest(repoRoot, adapterName);
169
220
  return { repoRoot, adapter: adapterName };
170
221
  }
package/dist/types.d.ts CHANGED
@@ -22,18 +22,25 @@ export interface InitOptions {
22
22
  preset?: import('./presets.js').ConfigPresetName;
23
23
  judgeProfile?: 'local-ollama' | 'cursor' | 'claude' | 'codex';
24
24
  judgeProvider?: 'ollama' | 'openai-compatible' | 'cursor';
25
+ judgeProviderId?: 'ollama' | 'codex' | 'claude' | 'cursor' | 'local' | 'openai';
25
26
  judgeModel?: string;
26
27
  judgeEndpoint?: string;
28
+ judgeCredentialMode?: 'project' | 'apiKey';
27
29
  /** Acknowledge cloud judge egress + redaction limits (R19). Required for openai-compatible provider. */
28
30
  acceptCloudJudge?: boolean;
31
+ cloudConsentApprovalId?: string;
29
32
  /** When true, skip writing judge config (used when user declines cloud consent). */
30
33
  skipJudgeWrite?: boolean;
34
+ /** Opt-in: migrate implicit factory-default ollama judge to host default provider. */
35
+ migrateJudgeDefault?: boolean;
31
36
  }
32
37
  export interface UpgradeOptions {
33
38
  targetDir?: string;
34
39
  withSkill?: boolean;
35
40
  adapter?: AdapterName;
36
41
  scope?: InstallScope;
42
+ /** Opt-in: migrate implicit factory-default ollama judge to host default provider. */
43
+ migrateJudgeDefault?: boolean;
37
44
  }
38
45
  export interface DoctorOptions {
39
46
  targetDir?: string;
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const PACKAGE_VERSION = "0.2.0";
1
+ export declare const PACKAGE_VERSION = "0.3.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/sync-version.mjs — do not edit.
2
- export const PACKAGE_VERSION = '0.2.0';
2
+ export const PACKAGE_VERSION = '0.3.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guilz-dev/belay",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Belay-style approval and audit gating for agent runtimes.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,10 +29,10 @@ you to the CLI. It does not classify commands itself.
29
29
 
30
30
  ## Prerequisites
31
31
 
32
- Run `npx @guilz-dev/belay init` in the project root before relying on enforcement.
33
- If you only installed this skill via `npx skills add`, approval instructions are
34
- available, but the runtime gate is not installed yet. Run `belay doctor` to
35
- check whether hooks are present.
32
+ `npx skills add` installs this skill only — the runtime gate is **not installed**
33
+ until hooks are configured. Run `belay config` in the project root for interactive
34
+ setup (or `npx @guilz-dev/belay init` for non-interactive install). Run
35
+ `belay doctor` to check whether hooks are present.
36
36
 
37
37
  ## When belay blocks an action
38
38
 
@@ -48,16 +48,17 @@ For recovery advice after a block, use `/belay recover` or `belay recover`.
48
48
 
49
49
  ## Install or repair
50
50
 
51
- - Full install: `npx @guilz-dev/belay init --with-skill`
52
- - Interactive wizard: `npx @guilz-dev/belay init-wizard`
51
+ - Interactive setup: `belay config`
52
+ - Non-interactive install: `npx @guilz-dev/belay init --with-skill`
53
53
  - Health check: `belay doctor`
54
54
 
55
- Do not run init or doctor implicitly from this skill — only when the user asks.
55
+ Do not run init, config, or doctor implicitly from this skill — only when the user asks.
56
56
 
57
57
  ## CLI mapping
58
58
 
59
59
  | User intent | Command |
60
60
  | --- | --- |
61
+ | Set up or change judge settings | `belay config` |
61
62
  | Why was this blocked? | `belay explain --command "..."` |
62
63
  | Explain latest pending ask | `belay explain` |
63
64
  | Status / dogfood / audit visibility | `belay status` |
@@ -1,21 +0,0 @@
1
- import type { AdapterName, InitOptions } from '../types.js';
2
- export interface WizardAnswers {
3
- adapter: AdapterName;
4
- scope: 'project' | 'global';
5
- withSkill: boolean;
6
- judgeProfile: 'local-ollama' | 'cursor' | 'claude' | 'codex';
7
- dogfood: boolean;
8
- }
9
- export declare function parseAdapter(value: string | undefined): AdapterName;
10
- export declare function parseScope(value: string | undefined): 'project' | 'global';
11
- export declare function parseYesNo(value: string | undefined, defaultValue: boolean): boolean;
12
- export declare function parseJudgeProfile(value: string | undefined, defaultProfile: 'local-ollama' | 'cursor' | 'claude' | 'codex'): 'local-ollama' | 'cursor' | 'claude' | 'codex';
13
- export declare function buildInitOptionsFromWizard(answers: WizardAnswers, targetDir?: string): InitOptions;
14
- export declare function runInitWizard(options?: {
15
- targetDir?: string;
16
- }): Promise<{
17
- repoRoot: string;
18
- withSkill: boolean;
19
- dogfood: boolean;
20
- adapter: import("../adapters/layouts/types.js").AdapterName;
21
- }>;
@@ -1,63 +0,0 @@
1
- import { stdin as input, stdout as output } from 'node:process';
2
- import readline from 'node:readline/promises';
3
- import { initProject } from '../installer.js';
4
- export function parseAdapter(value) {
5
- const normalized = (value?.trim() || 'cursor').toLowerCase();
6
- if (normalized === 'claude' || normalized === 'codex' || normalized === 'cursor') {
7
- return normalized;
8
- }
9
- throw new Error(`Unknown adapter: ${value ?? '(empty)'}`);
10
- }
11
- export function parseScope(value) {
12
- const normalized = (value?.trim() || 'project').toLowerCase();
13
- if (normalized === 'global' || normalized === 'project') {
14
- return normalized;
15
- }
16
- throw new Error(`Unknown scope: ${value ?? '(empty)'}`);
17
- }
18
- export function parseYesNo(value, defaultValue) {
19
- const normalized = (value?.trim() || (defaultValue ? 'y' : 'n')).toLowerCase();
20
- if (['y', 'yes', 'true', '1'].includes(normalized)) {
21
- return true;
22
- }
23
- if (['n', 'no', 'false', '0'].includes(normalized)) {
24
- return false;
25
- }
26
- return defaultValue;
27
- }
28
- export function parseJudgeProfile(value, defaultProfile) {
29
- const normalized = (value?.trim() || defaultProfile).toLowerCase();
30
- if (normalized === 'local-ollama' ||
31
- normalized === 'cursor' ||
32
- normalized === 'claude' ||
33
- normalized === 'codex') {
34
- return normalized;
35
- }
36
- throw new Error(`Unknown judge profile: ${value ?? '(empty)'}`);
37
- }
38
- export function buildInitOptionsFromWizard(answers, targetDir) {
39
- return {
40
- targetDir,
41
- adapter: answers.adapter,
42
- scope: answers.scope,
43
- withSkill: answers.withSkill,
44
- judgeProfile: answers.judgeProfile,
45
- dogfood: answers.dogfood,
46
- };
47
- }
48
- export async function runInitWizard(options = {}) {
49
- const rl = readline.createInterface({ input, output });
50
- try {
51
- output.write('belay init wizard\n');
52
- const adapter = parseAdapter(await rl.question('Adapter [cursor | claude | codex] (cursor): '));
53
- const scope = parseScope(await rl.question('Install scope [project | global] (project): '));
54
- const withSkill = parseYesNo(await rl.question('Install SKILL.md and slash commands? [y | n] (y): '), true);
55
- // Show Tier1 judge choice explicitly so init defaults are visible in wizard UX.
56
- const defaultJudgeProfile = adapter;
57
- const judgeProfile = parseJudgeProfile(await rl.question(`Tier1 judge profile [cursor | claude | codex | local-ollama] (${defaultJudgeProfile}): `), defaultJudgeProfile);
58
- return initProject(buildInitOptionsFromWizard({ adapter, scope, withSkill, judgeProfile, dogfood: false }, options.targetDir));
59
- }
60
- finally {
61
- rl.close();
62
- }
63
- }