@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.
- package/README.md +7 -1
- package/dist/adapters/cursor/runtime-entry.js +1 -0
- package/dist/adapters/layouts/scope.js +3 -2
- package/dist/adapters/shared/gate-runtime.d.ts +12 -16
- package/dist/adapters/shared/gate-runtime.js +499 -43
- package/dist/bundle/claude-runtime.mjs +8004 -2676
- package/dist/bundle/codex-runtime.mjs +8021 -2683
- package/dist/bundle/cursor-runtime.mjs +7993 -2667
- package/dist/cli.js +180 -6
- package/dist/commands/approve.d.ts +1 -0
- package/dist/commands/approve.js +45 -26
- package/dist/commands/classify-for-report.js +1 -1
- package/dist/commands/config.d.ts +8 -0
- package/dist/commands/config.js +264 -58
- package/dist/commands/doctor.js +47 -2
- package/dist/commands/explain.js +17 -2
- package/dist/commands/harvest.d.ts +29 -0
- package/dist/commands/harvest.js +72 -0
- package/dist/commands/judge.d.ts +11 -1
- package/dist/commands/judge.js +24 -4
- package/dist/commands/metrics.js +34 -2
- package/dist/commands/quality.d.ts +38 -0
- package/dist/commands/quality.js +105 -0
- package/dist/commands/session.d.ts +20 -0
- package/dist/commands/session.js +51 -0
- package/dist/commands/simulate.d.ts +1 -0
- package/dist/commands/simulate.js +7 -2
- package/dist/commands/standing-allow.d.ts +10 -0
- package/dist/commands/standing-allow.js +26 -0
- package/dist/commands/tui.d.ts +48 -0
- package/dist/commands/tui.js +150 -0
- package/dist/config-io.js +59 -17
- package/dist/conformance/guarantee-posture.d.ts +21 -0
- package/dist/conformance/guarantee-posture.js +46 -0
- package/dist/conformance/guarantee-table.d.ts +1 -0
- package/dist/conformance/guarantee-table.js +14 -9
- package/dist/core/approval-replay-cli.d.ts +7 -0
- package/dist/core/approval-replay-cli.js +36 -0
- package/dist/core/approval-replay.d.ts +45 -0
- package/dist/core/approval-replay.js +141 -0
- package/dist/core/approval-service.d.ts +15 -0
- package/dist/core/approval-service.js +53 -4
- package/dist/core/approval.d.ts +26 -3
- package/dist/core/approval.js +61 -7
- package/dist/core/audit-analysis.d.ts +7 -1
- package/dist/core/audit-analysis.js +111 -0
- package/dist/core/audit-metrics.d.ts +7 -0
- package/dist/core/audit-metrics.js +24 -4
- package/dist/core/audit-query.d.ts +1 -0
- package/dist/core/audit-query.js +3 -0
- package/dist/core/audit-replay-context.d.ts +35 -0
- package/dist/core/audit-replay-context.js +88 -0
- package/dist/core/audit-types.d.ts +24 -1
- package/dist/core/audit-types.js +1 -1
- package/dist/core/capability/approval-state-mutation.d.ts +13 -0
- package/dist/core/capability/approval-state-mutation.js +92 -0
- package/dist/core/capability/approval-v3.d.ts +15 -0
- package/dist/core/capability/approval-v3.js +74 -0
- package/dist/core/capability/attestation.d.ts +13 -0
- package/dist/core/capability/attestation.js +55 -0
- package/dist/core/capability/boundary-attestation-sign.d.ts +21 -0
- package/dist/core/capability/boundary-attestation-sign.js +84 -0
- package/dist/core/capability/boundary-driver-container.d.ts +7 -0
- package/dist/core/capability/boundary-driver-container.js +122 -0
- package/dist/core/capability/boundary-driver.d.ts +29 -0
- package/dist/core/capability/boundary-driver.js +43 -0
- package/dist/core/capability/boundary-egress.d.ts +21 -0
- package/dist/core/capability/boundary-egress.js +109 -0
- package/dist/core/capability/boundary-grant-materialize.d.ts +13 -0
- package/dist/core/capability/boundary-grant-materialize.js +78 -0
- package/dist/core/capability/boundary-profile.d.ts +15 -0
- package/dist/core/capability/boundary-profile.js +25 -0
- package/dist/core/capability/boundary-run.d.ts +23 -0
- package/dist/core/capability/boundary-run.js +23 -0
- package/dist/core/capability/boundary-session.d.ts +57 -0
- package/dist/core/capability/boundary-session.js +129 -0
- package/dist/core/capability/capability-request-hash.d.ts +2 -0
- package/dist/core/capability/capability-request-hash.js +8 -0
- package/dist/core/capability/gate-latency-ratchet.d.ts +7 -0
- package/dist/core/capability/gate-latency-ratchet.js +18 -0
- package/dist/core/capability/gate-policy-shadow.d.ts +25 -0
- package/dist/core/capability/gate-policy-shadow.js +108 -0
- package/dist/core/capability/gate-shadow-audit.d.ts +1 -0
- package/dist/core/capability/gate-shadow-audit.js +1 -0
- package/dist/core/capability/gate-shadow-ratchet.d.ts +14 -0
- package/dist/core/capability/gate-shadow-ratchet.js +81 -0
- package/dist/core/capability/grant-lease.d.ts +9 -0
- package/dist/core/capability/grant-lease.js +46 -0
- package/dist/core/capability/grant-loader.d.ts +12 -0
- package/dist/core/capability/grant-loader.js +23 -0
- package/dist/core/capability/grant-match.d.ts +6 -0
- package/dist/core/capability/grant-match.js +93 -0
- package/dist/core/capability/grant.d.ts +16 -0
- package/dist/core/capability/grant.js +16 -0
- package/dist/core/capability/index.d.ts +10 -1
- package/dist/core/capability/index.js +8 -0
- package/dist/core/capability/limits.d.ts +7 -0
- package/dist/core/capability/limits.js +55 -0
- package/dist/core/capability/paths.d.ts +2 -2
- package/dist/core/capability/paths.js +9 -9
- package/dist/core/capability/policy-bridge.d.ts +18 -0
- package/dist/core/capability/policy-bridge.js +51 -0
- package/dist/core/capability/policy-engine.d.ts +70 -0
- package/dist/core/capability/policy-engine.js +476 -0
- package/dist/core/capability/policy-types.d.ts +20 -0
- package/dist/core/capability/policy-types.js +1 -0
- package/dist/core/capability/request.d.ts +45 -0
- package/dist/core/capability/request.js +1 -0
- package/dist/core/capability/resolver.d.ts +4 -0
- package/dist/core/capability/resolver.js +4 -0
- package/dist/core/capability/trusted-workspace-roots.d.ts +25 -0
- package/dist/core/capability/trusted-workspace-roots.js +149 -0
- package/dist/core/capability/types.d.ts +11 -1
- package/dist/core/capability-approval.d.ts +2 -1
- package/dist/core/capability-approval.js +100 -2
- package/dist/core/classify-subagent.d.ts +2 -1
- package/dist/core/classify-subagent.js +40 -22
- package/dist/core/classify-tool.js +157 -64
- package/dist/core/config.d.ts +32 -3
- package/dist/core/config.js +105 -3
- package/dist/core/gate-contract.d.ts +3 -0
- package/dist/core/gate-contract.js +3 -0
- package/dist/core/gate-engine.js +12 -7
- package/dist/core/harvest.d.ts +53 -0
- package/dist/core/harvest.js +276 -0
- package/dist/core/index.d.ts +9 -3
- package/dist/core/index.js +6 -2
- package/dist/core/judge-doctor.d.ts +3 -0
- package/dist/core/judge-doctor.js +90 -11
- package/dist/core/judge-fallback-hints.d.ts +13 -0
- package/dist/core/judge-fallback-hints.js +108 -0
- package/dist/core/path-utils.d.ts +12 -0
- package/dist/core/path-utils.js +60 -7
- package/dist/core/reclassify.d.ts +3 -0
- package/dist/core/reclassify.js +44 -14
- package/dist/core/recovery/capability.d.ts +3 -0
- package/dist/core/recovery/capability.js +22 -0
- package/dist/core/recovery/fail-closed.d.ts +6 -0
- package/dist/core/recovery/fail-closed.js +25 -0
- package/dist/core/recovery/index.d.ts +3 -0
- package/dist/core/recovery/index.js +2 -0
- package/dist/core/recovery/types.d.ts +20 -0
- package/dist/core/recovery/types.js +1 -0
- package/dist/core/replay-scrub.d.ts +9 -0
- package/dist/core/replay-scrub.js +43 -0
- package/dist/core/shell-tokenizer.d.ts +2 -0
- package/dist/core/shell-tokenizer.js +82 -23
- package/dist/core/standing-allow.d.ts +50 -0
- package/dist/core/standing-allow.js +178 -0
- package/dist/core/transactional/diff-evaluator.js +4 -1
- package/dist/core/transactional/eligibility.js +4 -0
- package/dist/core/transactional/git-worktree.d.ts +10 -3
- package/dist/core/transactional/git-worktree.js +81 -11
- package/dist/core/transactional/reasons.d.ts +1 -0
- package/dist/core/transactional/reasons.js +4 -0
- package/dist/core/transactional/runner.js +22 -6
- package/dist/core/transactional/types.d.ts +4 -0
- package/dist/core/types.d.ts +36 -3
- package/dist/core/verdict/adapter.d.ts +3 -4
- package/dist/core/verdict/adapter.js +19 -6
- package/dist/core/verdict/containment.d.ts +4 -3
- package/dist/core/verdict/containment.js +17 -12
- package/dist/core/verdict/cursor-acp-client.d.ts +67 -0
- package/dist/core/verdict/cursor-acp-client.js +345 -0
- package/dist/core/verdict/egress-classify.d.ts +2 -2
- package/dist/core/verdict/egress-classify.js +2 -2
- package/dist/core/verdict/git-classifier.d.ts +26 -0
- package/dist/core/verdict/git-classifier.js +533 -0
- package/dist/core/verdict/judge-audit.d.ts +1 -0
- package/dist/core/verdict/judge-audit.js +33 -1
- package/dist/core/verdict/judge-baseline.d.ts +20 -0
- package/dist/core/verdict/judge-baseline.js +49 -0
- package/dist/core/verdict/judge-broker-service.d.ts +42 -0
- package/dist/core/verdict/judge-broker-service.js +291 -0
- package/dist/core/verdict/judge-catalog.js +1 -0
- package/dist/core/verdict/judge-cli-fingerprint.d.ts +6 -0
- package/dist/core/verdict/judge-cli-fingerprint.js +67 -0
- package/dist/core/verdict/judge-cli.d.ts +23 -0
- package/dist/core/verdict/judge-cli.js +139 -16
- package/dist/core/verdict/judge-factory.js +13 -4
- package/dist/core/verdict/judge-provider-matrix.d.ts +20 -0
- package/dist/core/verdict/judge-provider-matrix.js +66 -0
- package/dist/core/verdict/judge-runtime-config.d.ts +44 -0
- package/dist/core/verdict/judge-runtime-config.js +96 -0
- package/dist/core/verdict/judge-session-broker.d.ts +48 -0
- package/dist/core/verdict/judge-session-broker.js +200 -0
- package/dist/core/verdict/judge-session-guard.d.ts +27 -0
- package/dist/core/verdict/judge-session-guard.js +91 -0
- package/dist/core/verdict/judge-session-kill-switch.d.ts +11 -0
- package/dist/core/verdict/judge-session-kill-switch.js +43 -0
- package/dist/core/verdict/judge-session-mutex.d.ts +9 -0
- package/dist/core/verdict/judge-session-mutex.js +23 -0
- package/dist/core/verdict/judge-shadow.d.ts +24 -0
- package/dist/core/verdict/judge-shadow.js +116 -0
- package/dist/core/verdict/judge-transport.d.ts +44 -0
- package/dist/core/verdict/judge-transport.js +268 -0
- package/dist/core/verdict/judge.d.ts +14 -14
- package/dist/core/verdict/judge.js +5 -67
- package/dist/core/verdict/launcher-resolve.d.ts +2 -0
- package/dist/core/verdict/launcher-resolve.js +16 -0
- package/dist/core/verdict/parser.js +6 -1
- package/dist/core/verdict/prescan.d.ts +15 -0
- package/dist/core/verdict/prescan.js +68 -0
- package/dist/core/verdict/shell-policy.d.ts +25 -0
- package/dist/core/verdict/shell-policy.js +40 -0
- package/dist/core/verdict/shell-semantics.d.ts +24 -0
- package/dist/core/verdict/shell-semantics.js +225 -0
- package/dist/core/verdict/types.d.ts +26 -1
- package/dist/core/verdict/verdict.js +300 -179
- package/dist/corpus/adversarial-probe.d.ts +86 -0
- package/dist/corpus/adversarial-probe.js +220 -0
- package/dist/corpus/evaluate.d.ts +45 -12
- package/dist/corpus/evaluate.js +61 -8
- package/dist/corpus/gate-latency-budget.d.ts +39 -0
- package/dist/corpus/gate-latency-budget.js +33 -0
- package/dist/corpus/gates.d.ts +35 -0
- package/dist/corpus/gates.js +81 -0
- package/dist/corpus/judge-accuracy.d.ts +8 -0
- package/dist/corpus/judge-accuracy.js +39 -0
- package/dist/corpus/must-allow-commands.d.ts +5 -0
- package/dist/corpus/must-allow-commands.js +13 -0
- package/dist/corpus/mutators.d.ts +28 -0
- package/dist/corpus/mutators.js +162 -0
- package/dist/corpus/ratchet.d.ts +42 -0
- package/dist/corpus/ratchet.js +116 -0
- package/dist/corpus/runtime-match.d.ts +19 -0
- package/dist/corpus/runtime-match.js +43 -0
- package/dist/corpus/standing-allow-catalog.generated.d.ts +13 -0
- package/dist/corpus/standing-allow-catalog.generated.js +99 -0
- package/dist/corpus/types.d.ts +47 -0
- package/dist/corpus/types.js +157 -0
- package/dist/installer.js +4 -1
- package/dist/judge-broker-daemon.d.ts +1 -0
- package/dist/judge-broker-daemon.js +137 -0
- package/dist/services/sandbox-service.d.ts +5 -0
- package/dist/services/sandbox-service.js +30 -2
- package/dist/types.d.ts +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +7 -3
- package/skills/belay/SKILL.md +7 -1
- package/skills/belay/belay-approve.md +17 -0
package/dist/commands/judge.d.ts
CHANGED
|
@@ -2,7 +2,8 @@ import type { BelayJudgeConfig } from '../core/config.js';
|
|
|
2
2
|
export interface JudgeCommandOptions {
|
|
3
3
|
targetDir?: string;
|
|
4
4
|
json?: boolean;
|
|
5
|
-
subcommand?: 'status' | 'list' | 'use' | 'test' | 'consent';
|
|
5
|
+
subcommand?: 'status' | 'list' | 'use' | 'test' | 'bench' | 'consent';
|
|
6
|
+
liveProbe?: boolean;
|
|
6
7
|
providerId?: string;
|
|
7
8
|
model?: string;
|
|
8
9
|
endpoint?: string;
|
|
@@ -53,7 +54,16 @@ export declare function judgeTest(options?: JudgeCommandOptions): Promise<string
|
|
|
53
54
|
warnings: string[];
|
|
54
55
|
notes: string[];
|
|
55
56
|
}>;
|
|
57
|
+
export declare function judgeBench(options?: JudgeCommandOptions): Promise<string | {
|
|
58
|
+
slo: typeof import("../core/verdict/judge-runtime-config.js").JUDGE_LATENCY_SLO;
|
|
59
|
+
measured: Record<import("../core/verdict/judge-baseline.js").JudgeLatencyPhase, import("../core/verdict/judge-baseline.js").JudgeLatencyPercentiles>;
|
|
60
|
+
sessionMeetsTarget: boolean;
|
|
61
|
+
}>;
|
|
56
62
|
export declare function runJudgeCommand(options: JudgeCommandOptions): Promise<string | {
|
|
63
|
+
slo: typeof import("../core/verdict/judge-runtime-config.js").JUDGE_LATENCY_SLO;
|
|
64
|
+
measured: Record<import("../core/verdict/judge-baseline.js").JudgeLatencyPhase, import("../core/verdict/judge-baseline.js").JudgeLatencyPercentiles>;
|
|
65
|
+
sessionMeetsTarget: boolean;
|
|
66
|
+
} | {
|
|
57
67
|
providerId: import("../core/config.js").JudgeProviderId | import("../core/config.js").DeprecatedJudgeProviderId | undefined;
|
|
58
68
|
provider: import("../core/config.js").JudgeProvider;
|
|
59
69
|
endpoint: string | null;
|
package/dist/commands/judge.js
CHANGED
|
@@ -63,10 +63,11 @@ export async function judgeStatus(options = {}) {
|
|
|
63
63
|
});
|
|
64
64
|
const { resolved } = resolveJudgeModel(judge);
|
|
65
65
|
const transport = resolveJudgeTransport(judge);
|
|
66
|
+
const effectiveTransport = transport === 'cursor-cli' && judge.runtime?.session.enabled ? 'cursor-acp' : transport;
|
|
66
67
|
const lines = [
|
|
67
68
|
`Judge providerId : ${judge.providerId ?? '(inferred)'}`,
|
|
68
69
|
`Judge driver : ${judge.provider}`,
|
|
69
|
-
`Transport : ${
|
|
70
|
+
`Transport : ${effectiveTransport}`,
|
|
70
71
|
`Endpoint : ${judge.endpoint ?? '(none)'}`,
|
|
71
72
|
`Model : ${resolved} (requested: ${judge.model})`,
|
|
72
73
|
`Credential : ${credential.mode} (${credential.sourceKind})${credential.source ? ` → ${credential.source}` : ''} ${credential.key ? '✓ set' : '✗ missing'}`,
|
|
@@ -82,7 +83,7 @@ export async function judgeStatus(options = {}) {
|
|
|
82
83
|
endpoint: judge.endpoint,
|
|
83
84
|
model: judge.model,
|
|
84
85
|
modelResolved: resolved,
|
|
85
|
-
transport,
|
|
86
|
+
transport: effectiveTransport,
|
|
86
87
|
credential,
|
|
87
88
|
cloudConsent: judge.cloudConsent ?? null,
|
|
88
89
|
cloudJudgeActive: isCloudJudgeConfig(judge) && hasValidCloudConsent(judge),
|
|
@@ -254,7 +255,9 @@ export async function judgeRequestCloudConsent(options = {}) {
|
|
|
254
255
|
export async function judgeTest(options = {}) {
|
|
255
256
|
const repoRoot = path.resolve(options.targetDir ?? process.cwd());
|
|
256
257
|
const config = await loadConfigFile(repoRoot);
|
|
257
|
-
const diagnosis = await diagnoseJudge(config, repoRoot
|
|
258
|
+
const diagnosis = await diagnoseJudge(config, repoRoot, {
|
|
259
|
+
liveProbe: options.liveProbe === true,
|
|
260
|
+
});
|
|
258
261
|
const modelCheck = diagnosis.modelCheck;
|
|
259
262
|
if (options.json) {
|
|
260
263
|
return { ...diagnosis, modelCheck };
|
|
@@ -267,6 +270,21 @@ export async function judgeTest(options = {}) {
|
|
|
267
270
|
...diagnosis.issues.map((i) => `Issue: ${i}`),
|
|
268
271
|
].join('\n');
|
|
269
272
|
}
|
|
273
|
+
export async function judgeBench(options = {}) {
|
|
274
|
+
const { judgeLatencySloReport } = await import('../core/verdict/judge-baseline.js');
|
|
275
|
+
const report = judgeLatencySloReport();
|
|
276
|
+
if (options.json) {
|
|
277
|
+
return report;
|
|
278
|
+
}
|
|
279
|
+
const lines = [
|
|
280
|
+
`SLO target: Tier1 session p95 <= ${Math.round(report.slo.spawnBaselineP95Ms * (1 - report.slo.tier1P95ReductionTarget))}ms`,
|
|
281
|
+
`Tier0 p50/p95: ${report.measured.tier0.p50}/${report.measured.tier0.p95}ms (${report.measured.tier0.count} samples)`,
|
|
282
|
+
`Tier1 spawn p50/p95: ${report.measured.tier1_spawn.p50}/${report.measured.tier1_spawn.p95}ms (${report.measured.tier1_spawn.count} samples)`,
|
|
283
|
+
`Tier1 session p50/p95: ${report.measured.tier1_session.p50}/${report.measured.tier1_session.p95}ms (${report.measured.tier1_session.count} samples)`,
|
|
284
|
+
`Session meets target: ${report.sessionMeetsTarget ? 'yes' : 'no'}`,
|
|
285
|
+
];
|
|
286
|
+
return lines.join('\n');
|
|
287
|
+
}
|
|
270
288
|
export async function runJudgeCommand(options) {
|
|
271
289
|
switch (options.subcommand) {
|
|
272
290
|
case 'status':
|
|
@@ -277,9 +295,11 @@ export async function runJudgeCommand(options) {
|
|
|
277
295
|
return judgeUse(options);
|
|
278
296
|
case 'test':
|
|
279
297
|
return judgeTest(options);
|
|
298
|
+
case 'bench':
|
|
299
|
+
return judgeBench(options);
|
|
280
300
|
case 'consent':
|
|
281
301
|
return judgeRequestCloudConsent(options);
|
|
282
302
|
default:
|
|
283
|
-
throw new Error('judge requires subcommand: status, list, use, test, or consent');
|
|
303
|
+
throw new Error('judge requires subcommand: status, list, use, test, bench, or consent');
|
|
284
304
|
}
|
|
285
305
|
}
|
package/dist/commands/metrics.js
CHANGED
|
@@ -2,6 +2,12 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { loadConfigFile } from '../config-io.js';
|
|
4
4
|
import { computeAuditMetrics, parseAuditNdjson } from '../core/audit-metrics.js';
|
|
5
|
+
function formatFingerprintPreview(fingerprint) {
|
|
6
|
+
if (fingerprint.length <= 12) {
|
|
7
|
+
return fingerprint;
|
|
8
|
+
}
|
|
9
|
+
return `${fingerprint.slice(0, 12)}…`;
|
|
10
|
+
}
|
|
5
11
|
export async function metricsProject(options = {}) {
|
|
6
12
|
const repoRoot = path.resolve(options.targetDir ?? process.cwd());
|
|
7
13
|
const config = await loadConfigFile(repoRoot);
|
|
@@ -26,6 +32,7 @@ export function formatMetricsReport(report) {
|
|
|
26
32
|
`Schema: v${report.schemaVersion}`,
|
|
27
33
|
`Gate events: ${report.gateEvents}`,
|
|
28
34
|
`Would-block: ${report.wouldBlockCount} (${(report.wouldBlockRate * 100).toFixed(1)}%)`,
|
|
35
|
+
`Classifier-quality would-block: ${report.classifierWouldBlockCount} (${(report.classifierWouldBlockRate * 100).toFixed(1)}%)`,
|
|
29
36
|
`Approvals recorded during audit: ${report.approvalRecordedCount}`,
|
|
30
37
|
];
|
|
31
38
|
if (report.approvalLatency.count > 0) {
|
|
@@ -34,6 +41,31 @@ export function formatMetricsReport(report) {
|
|
|
34
41
|
if (report.bypassAttemptCount > 0) {
|
|
35
42
|
lines.push(`Bypass attempts detected: ${report.bypassAttemptCount}`);
|
|
36
43
|
}
|
|
44
|
+
if (report.availabilityAsks.total > 0) {
|
|
45
|
+
lines.push('', 'Availability-caused asks (infrastructure, not classifier ground truth):');
|
|
46
|
+
lines.push(`- missing trusted cwd: ${report.availabilityAsks.missingTrustedCwd}`);
|
|
47
|
+
lines.push(`- judge timeout: ${report.availabilityAsks.judgeTimeout}`);
|
|
48
|
+
lines.push(`- other judge fallback: ${report.availabilityAsks.judgeFallback}`);
|
|
49
|
+
lines.push(`- total: ${report.availabilityAsks.total}`);
|
|
50
|
+
}
|
|
51
|
+
if (report.repeatedFingerprintAsks.length > 0) {
|
|
52
|
+
lines.push('', 'Repeated fingerprint asks (repeat friction):');
|
|
53
|
+
for (const entry of report.repeatedFingerprintAsks) {
|
|
54
|
+
lines.push(`- [${entry.reason}] x${entry.askCount} ${formatFingerprintPreview(entry.fingerprint)}: ${entry.summary}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (Object.keys(report.wouldBlockByReason).length > 0) {
|
|
58
|
+
lines.push('', 'Would-block by reason:');
|
|
59
|
+
for (const [reason, count] of Object.entries(report.wouldBlockByReason).sort((a, b) => b[1] - a[1])) {
|
|
60
|
+
lines.push(`- ${reason}: ${count}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (report.approvalRatioByReason.length > 0) {
|
|
64
|
+
lines.push('', 'Approval ratio by reason (candidate signal, not ground truth):');
|
|
65
|
+
for (const entry of report.approvalRatioByReason.slice(0, 10)) {
|
|
66
|
+
lines.push(`- ${entry.reason}: ${(entry.approvalRate * 100).toFixed(0)}% approved after deny (${entry.approvedAfterDenyCount}/${entry.wouldBlockCount})`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
37
69
|
if (Object.keys(report.byVerdict).length > 0) {
|
|
38
70
|
lines.push('', 'By verdict:');
|
|
39
71
|
for (const [verdict, count] of Object.entries(report.byVerdict).sort((a, b) => b[1] - a[1])) {
|
|
@@ -61,13 +93,13 @@ export function formatMetricsReport(report) {
|
|
|
61
93
|
}
|
|
62
94
|
}
|
|
63
95
|
if (report.noisyRuleCandidates.length > 0) {
|
|
64
|
-
lines.push('', 'Noisy rule candidates:');
|
|
96
|
+
lines.push('', 'Noisy rule candidates (high-signal subset of approval ratios; ≥50% approved after deny):');
|
|
65
97
|
for (const rule of report.noisyRuleCandidates) {
|
|
66
98
|
lines.push(`- ${rule.reason}: ${(rule.approvalRate * 100).toFixed(0)}% approved after deny (${rule.approvedCount}/${rule.denyCount})`);
|
|
67
99
|
}
|
|
68
100
|
}
|
|
69
101
|
if (Object.keys(report.byReason).length > 0) {
|
|
70
|
-
lines.push('', '
|
|
102
|
+
lines.push('', 'All gate events by reason:');
|
|
71
103
|
for (const [reason, count] of Object.entries(report.byReason).sort((a, b) => b[1] - a[1])) {
|
|
72
104
|
lines.push(`- ${reason}: ${count}`);
|
|
73
105
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { CorpusCategory, CorpusProvenanceCounts } from '../corpus/types.js';
|
|
2
|
+
export declare const QUALITY_REPORT_SCHEMA_VERSION = 1;
|
|
3
|
+
export interface QualityReport {
|
|
4
|
+
schemaVersion: typeof QUALITY_REPORT_SCHEMA_VERSION;
|
|
5
|
+
ok: boolean;
|
|
6
|
+
corpus: {
|
|
7
|
+
path: string;
|
|
8
|
+
passesHardGates: boolean;
|
|
9
|
+
totalCases: number;
|
|
10
|
+
categoryCounts: Record<CorpusCategory, number>;
|
|
11
|
+
provenanceCounts: CorpusProvenanceCounts;
|
|
12
|
+
mustAskMisses: number;
|
|
13
|
+
provablyBenignBlocks: number;
|
|
14
|
+
acceptedBenignMismatches: number;
|
|
15
|
+
accuracy: number;
|
|
16
|
+
};
|
|
17
|
+
audit: {
|
|
18
|
+
logPath: string;
|
|
19
|
+
gateEvents: number;
|
|
20
|
+
classifierWouldBlockRate: number;
|
|
21
|
+
availabilityAsks: number;
|
|
22
|
+
readyForEnforce: boolean;
|
|
23
|
+
repeatedFingerprintPatterns: number;
|
|
24
|
+
};
|
|
25
|
+
harvest: {
|
|
26
|
+
scope: 'shell';
|
|
27
|
+
benignCandidates: number;
|
|
28
|
+
availabilityQueue: number;
|
|
29
|
+
};
|
|
30
|
+
notes: string[];
|
|
31
|
+
}
|
|
32
|
+
export interface QualityOptions {
|
|
33
|
+
targetDir?: string;
|
|
34
|
+
corpusDir?: string;
|
|
35
|
+
json?: boolean;
|
|
36
|
+
}
|
|
37
|
+
export declare function qualityCheck(options?: QualityOptions): Promise<QualityReport>;
|
|
38
|
+
export declare function formatQualityReport(report: QualityReport): string;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { loadConfigFile } from '../config-io.js';
|
|
3
|
+
import { computeAuditMetrics } from '../core/audit-metrics.js';
|
|
4
|
+
import { runCorpusEvaluation } from '../corpus/evaluate.js';
|
|
5
|
+
import { passesHardGates } from '../corpus/gates.js';
|
|
6
|
+
import { loadAuditRecords } from './audit.js';
|
|
7
|
+
import { harvestReportFromRecords } from './harvest.js';
|
|
8
|
+
export const QUALITY_REPORT_SCHEMA_VERSION = 1;
|
|
9
|
+
export async function qualityCheck(options = {}) {
|
|
10
|
+
const repoRoot = path.resolve(options.targetDir ?? process.cwd());
|
|
11
|
+
const config = await loadConfigFile(repoRoot);
|
|
12
|
+
const corpusDir = path.resolve(repoRoot, options.corpusDir ?? 'corpus');
|
|
13
|
+
const corpusMetrics = await runCorpusEvaluation(corpusDir);
|
|
14
|
+
const hardGatesOk = passesHardGates(corpusMetrics.gates);
|
|
15
|
+
const auditRecords = await loadAuditRecords(repoRoot);
|
|
16
|
+
const metrics = computeAuditMetrics(auditRecords, {
|
|
17
|
+
auditLogPath: config.audit.logPath,
|
|
18
|
+
mode: config.mode,
|
|
19
|
+
unknownLocalEffect: config.policy.unknownLocalEffect,
|
|
20
|
+
});
|
|
21
|
+
const harvest = harvestReportFromRecords(auditRecords, {
|
|
22
|
+
allowPatterns: config.overrides.allow,
|
|
23
|
+
});
|
|
24
|
+
const notes = [
|
|
25
|
+
'Overall ok reflects corpus hard gates only; audit and harvest signals are advisory.',
|
|
26
|
+
'Recursive quality loop: corpus hard gates are the FN/FP safety boundary.',
|
|
27
|
+
'Harvest candidates and audit metrics inform review — approvals are not ground truth.',
|
|
28
|
+
'Simulate (`belay simulate`) is triage only; it does not replace `pnpm corpus`.',
|
|
29
|
+
];
|
|
30
|
+
if (!hardGatesOk) {
|
|
31
|
+
notes.push('Corpus hard gates failed — fix must-ask misses and provably-benign blocks before tuning friction.');
|
|
32
|
+
}
|
|
33
|
+
if (metrics.availabilityAsks.total > 0) {
|
|
34
|
+
notes.push(`${metrics.availabilityAsks.total} availability-caused ask(s) — tune judge/cwd infrastructure before corpus promotion.`);
|
|
35
|
+
}
|
|
36
|
+
if (harvest.availabilityQueue.length > 0) {
|
|
37
|
+
notes.push(`${harvest.availabilityQueue.length} shell pattern(s) in the availability queue — do not harvest into corpus.`);
|
|
38
|
+
}
|
|
39
|
+
const ok = hardGatesOk;
|
|
40
|
+
return {
|
|
41
|
+
schemaVersion: QUALITY_REPORT_SCHEMA_VERSION,
|
|
42
|
+
ok,
|
|
43
|
+
corpus: {
|
|
44
|
+
path: path.relative(repoRoot, corpusDir) || corpusDir,
|
|
45
|
+
passesHardGates: hardGatesOk,
|
|
46
|
+
totalCases: corpusMetrics.total,
|
|
47
|
+
categoryCounts: corpusMetrics.categoryCounts,
|
|
48
|
+
provenanceCounts: corpusMetrics.provenanceCounts,
|
|
49
|
+
mustAskMisses: corpusMetrics.gates.mustAsk.mismatches,
|
|
50
|
+
provablyBenignBlocks: corpusMetrics.gates.provablyBenign.mismatches,
|
|
51
|
+
acceptedBenignMismatches: corpusMetrics.gates.acceptedBenign.mismatches,
|
|
52
|
+
accuracy: corpusMetrics.accuracy,
|
|
53
|
+
},
|
|
54
|
+
audit: {
|
|
55
|
+
logPath: config.audit.logPath,
|
|
56
|
+
gateEvents: metrics.gateEvents,
|
|
57
|
+
classifierWouldBlockRate: metrics.classifierWouldBlockRate,
|
|
58
|
+
availabilityAsks: metrics.availabilityAsks.total,
|
|
59
|
+
readyForEnforce: metrics.dogfood.readyForEnforce,
|
|
60
|
+
repeatedFingerprintPatterns: metrics.repeatedFingerprintAsks.length,
|
|
61
|
+
},
|
|
62
|
+
harvest: {
|
|
63
|
+
scope: 'shell',
|
|
64
|
+
benignCandidates: harvest.candidates.length,
|
|
65
|
+
availabilityQueue: harvest.availabilityQueue.length,
|
|
66
|
+
},
|
|
67
|
+
notes,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export function formatQualityReport(report) {
|
|
71
|
+
const lines = [
|
|
72
|
+
'belay quality — recursive quality loop status',
|
|
73
|
+
`Schema: v${report.schemaVersion}`,
|
|
74
|
+
`Overall: ${report.ok ? 'OK' : 'ATTENTION NEEDED'}`,
|
|
75
|
+
'',
|
|
76
|
+
'Corpus hard gates:',
|
|
77
|
+
` path: ${report.corpus.path}`,
|
|
78
|
+
` passes: ${report.corpus.passesHardGates ? 'yes' : 'no'}`,
|
|
79
|
+
` total cases: ${report.corpus.totalCases}`,
|
|
80
|
+
` categories: must-ask=${report.corpus.categoryCounts['must-ask']} provably-benign=${report.corpus.categoryCounts['provably-benign']} accepted-benign=${report.corpus.categoryCounts['accepted-benign']}`,
|
|
81
|
+
` provenance: manual=${report.corpus.provenanceCounts.manual} mutation=${report.corpus.provenanceCounts.mutation} harvest=${report.corpus.provenanceCounts.harvest} redteam=${report.corpus.provenanceCounts.redteam} unspecified=${report.corpus.provenanceCounts.unspecified}`,
|
|
82
|
+
` must-ask misses: ${report.corpus.mustAskMisses}`,
|
|
83
|
+
` provably-benign blocks: ${report.corpus.provablyBenignBlocks}`,
|
|
84
|
+
` accepted-benign mismatches (soft): ${report.corpus.acceptedBenignMismatches}`,
|
|
85
|
+
` accuracy: ${(report.corpus.accuracy * 100).toFixed(1)}%`,
|
|
86
|
+
'',
|
|
87
|
+
'Audit metrics:',
|
|
88
|
+
` log: ${report.audit.logPath}`,
|
|
89
|
+
` gate events: ${report.audit.gateEvents}`,
|
|
90
|
+
` classifier would-block rate: ${(report.audit.classifierWouldBlockRate * 100).toFixed(1)}%`,
|
|
91
|
+
` availability asks: ${report.audit.availabilityAsks}`,
|
|
92
|
+
` ready for enforce: ${report.audit.readyForEnforce ? 'yes' : 'no'}`,
|
|
93
|
+
` repeated fingerprint patterns: ${report.audit.repeatedFingerprintPatterns}`,
|
|
94
|
+
'',
|
|
95
|
+
'Harvest (shell only):',
|
|
96
|
+
` benign candidates: ${report.harvest.benignCandidates}`,
|
|
97
|
+
` availability queue: ${report.harvest.availabilityQueue}`,
|
|
98
|
+
'',
|
|
99
|
+
'Notes:',
|
|
100
|
+
];
|
|
101
|
+
for (const note of report.notes) {
|
|
102
|
+
lines.push(`- ${note}`);
|
|
103
|
+
}
|
|
104
|
+
return lines.join('\n');
|
|
105
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { boundarySessionStatus } from '../core/capability/boundary-session.js';
|
|
2
|
+
export declare function sessionStartProject(params: {
|
|
3
|
+
targetDir?: string;
|
|
4
|
+
agentCommand?: string;
|
|
5
|
+
}): Promise<{
|
|
6
|
+
ok: boolean;
|
|
7
|
+
message: string;
|
|
8
|
+
attestationPath?: string;
|
|
9
|
+
exitCode?: number | null;
|
|
10
|
+
}>;
|
|
11
|
+
export declare function sessionStatusProject(params: {
|
|
12
|
+
targetDir?: string;
|
|
13
|
+
json?: boolean;
|
|
14
|
+
}): Promise<{
|
|
15
|
+
ok: boolean;
|
|
16
|
+
attestationPath: string;
|
|
17
|
+
fresh: boolean;
|
|
18
|
+
attestation: Awaited<ReturnType<typeof boundarySessionStatus>>['attestation'];
|
|
19
|
+
}>;
|
|
20
|
+
export declare function formatSessionStatusReport(report: Awaited<ReturnType<typeof sessionStatusProject>>): string;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { loadConfigFile } from '../config-io.js';
|
|
4
|
+
import { boundarySessionStatus, runBoundaryAgentCommand, startBoundarySession, } from '../core/capability/boundary-session.js';
|
|
5
|
+
export async function sessionStartProject(params) {
|
|
6
|
+
const repoRoot = path.resolve(params.targetDir ?? process.cwd());
|
|
7
|
+
const config = await loadConfigFile(repoRoot);
|
|
8
|
+
const started = await startBoundarySession({ repoRoot, config });
|
|
9
|
+
if (!params.agentCommand) {
|
|
10
|
+
return {
|
|
11
|
+
ok: true,
|
|
12
|
+
message: `Boundary session started (${started.attestation.driver}); attestation written to ${path.relative(repoRoot, started.attestationPath)}.`,
|
|
13
|
+
attestationPath: started.attestationPath,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
const run = await runBoundaryAgentCommand({
|
|
17
|
+
repoRoot,
|
|
18
|
+
config,
|
|
19
|
+
command: params.agentCommand,
|
|
20
|
+
});
|
|
21
|
+
const exitCode = run.exitCode ?? 1;
|
|
22
|
+
return {
|
|
23
|
+
ok: exitCode === 0,
|
|
24
|
+
message: `Boundary session started (${started.attestation.driver}); agent exited with code ${exitCode}.`,
|
|
25
|
+
attestationPath: started.attestationPath,
|
|
26
|
+
exitCode,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export async function sessionStatusProject(params) {
|
|
30
|
+
const repoRoot = path.resolve(params.targetDir ?? process.cwd());
|
|
31
|
+
const config = await loadConfigFile(repoRoot);
|
|
32
|
+
const status = await boundarySessionStatus({ repoRoot, config });
|
|
33
|
+
return {
|
|
34
|
+
ok: status.fresh,
|
|
35
|
+
attestationPath: status.attestationPath,
|
|
36
|
+
fresh: status.fresh,
|
|
37
|
+
attestation: status.attestation,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function formatSessionStatusReport(report) {
|
|
41
|
+
if (!report.attestation) {
|
|
42
|
+
return `No boundary attestation at ${report.attestationPath}. Run belay session start.`;
|
|
43
|
+
}
|
|
44
|
+
return [
|
|
45
|
+
`Attestation: ${report.attestationPath}`,
|
|
46
|
+
`Driver: ${report.attestation.driver}`,
|
|
47
|
+
`Fresh: ${report.fresh ? 'yes' : 'no'}`,
|
|
48
|
+
`Expires: ${report.attestation.expiresAt}`,
|
|
49
|
+
`Materializes grants: ${report.attestation.materializesGrants ? 'yes' : 'no'}`,
|
|
50
|
+
].join('\n');
|
|
51
|
+
}
|
|
@@ -9,6 +9,7 @@ export declare function simulateProject(options: SimulateOptions): Promise<{
|
|
|
9
9
|
changedCount: number;
|
|
10
10
|
allowToDenyCount: number;
|
|
11
11
|
denyToAllowCount: number;
|
|
12
|
+
missingSnapshotCount: number;
|
|
12
13
|
diffs: import("../core/reclassify.js").ReclassifyDiff[];
|
|
13
14
|
}>;
|
|
14
15
|
export declare function formatSimulateReport(report: Awaited<ReturnType<typeof simulateProject>>): string;
|
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { loadConfigFile } from '../config-io.js';
|
|
5
5
|
import { parseAuditNdjson, toAuditRecord } from '../core/audit-metrics.js';
|
|
6
6
|
import { mergeConfig } from '../core/config.js';
|
|
7
|
-
import { diffReclassification } from '../core/reclassify.js';
|
|
7
|
+
import { countMissingActionSnapshots, diffReclassification } from '../core/reclassify.js';
|
|
8
8
|
export async function simulateProject(options) {
|
|
9
9
|
const repoRoot = path.resolve(options.targetDir ?? process.cwd());
|
|
10
10
|
const currentConfig = await loadConfigFile(repoRoot);
|
|
@@ -22,6 +22,7 @@ export async function simulateProject(options) {
|
|
|
22
22
|
raw = '';
|
|
23
23
|
}
|
|
24
24
|
const records = parseAuditNdjson(raw).map(toAuditRecord);
|
|
25
|
+
const missingSnapshotCount = countMissingActionSnapshots(records);
|
|
25
26
|
const diffs = (await Promise.all(records.map((record) => diffReclassification(record, candidateConfig, repoRoot)))).filter((diff) => diff !== null);
|
|
26
27
|
const allowToDeny = diffs.filter((diff) => (diff.previousVerdict === 'allow' || diff.previousVerdict === 'allow_flagged') &&
|
|
27
28
|
diff.nextVerdict === 'deny_pending_approval');
|
|
@@ -33,20 +34,24 @@ export async function simulateProject(options) {
|
|
|
33
34
|
changedCount: diffs.length,
|
|
34
35
|
allowToDenyCount: allowToDeny.length,
|
|
35
36
|
denyToAllowCount: denyToAllow.length,
|
|
37
|
+
missingSnapshotCount,
|
|
36
38
|
diffs,
|
|
37
39
|
};
|
|
38
40
|
}
|
|
39
41
|
export function formatSimulateReport(report) {
|
|
40
42
|
const lines = [
|
|
41
43
|
`simulate ${report.candidateConfigPath}`,
|
|
44
|
+
'Triage only — corpus hard gates (`pnpm corpus`), not simulate counts, are the safety boundary.',
|
|
42
45
|
`Records scanned: ${report.totalRecords}`,
|
|
46
|
+
`Missing action snapshots: ${report.missingSnapshotCount}`,
|
|
43
47
|
`Verdict changes: ${report.changedCount}`,
|
|
44
48
|
`allow/flagged → deny: ${report.allowToDenyCount}`,
|
|
45
49
|
`deny → allow/flagged: ${report.denyToAllowCount}`,
|
|
46
50
|
'',
|
|
47
51
|
];
|
|
48
52
|
for (const diff of report.diffs.slice(0, 30)) {
|
|
49
|
-
|
|
53
|
+
const context = diff.replayCwd && diff.replayKind ? ` [${diff.replayKind} cwd=${diff.replayCwd}]` : '';
|
|
54
|
+
lines.push(`- ${diff.summary ?? diff.fingerprint}${context}: ${diff.previousVerdict}/${diff.previousReason} → ${diff.nextVerdict}/${diff.nextReason}`);
|
|
50
55
|
}
|
|
51
56
|
if (report.diffs.length > 30) {
|
|
52
57
|
lines.push(`... ${report.diffs.length - 30} more`);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { GatedActionKind } from '../core/gate-contract.js';
|
|
2
|
+
export interface RevokeStandingAllowOptions {
|
|
3
|
+
targetDir?: string;
|
|
4
|
+
fingerprint: string;
|
|
5
|
+
kind?: GatedActionKind;
|
|
6
|
+
}
|
|
7
|
+
export declare function revokeStandingAllow(options: RevokeStandingAllowOptions): Promise<{
|
|
8
|
+
ok: boolean;
|
|
9
|
+
message: string;
|
|
10
|
+
}>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { loadConfigFile, repoLocalStateDirFor } from '../config-io.js';
|
|
3
|
+
import { loadStandingAllow, revokeStandingAllowEntry, saveStandingAllow, standingAllowFile, } from '../core/standing-allow.js';
|
|
4
|
+
export async function revokeStandingAllow(options) {
|
|
5
|
+
const repoRoot = path.resolve(options.targetDir ?? process.cwd());
|
|
6
|
+
const config = await loadConfigFile(repoRoot);
|
|
7
|
+
const filePath = standingAllowFile(config, repoLocalStateDirFor(repoRoot, config));
|
|
8
|
+
const state = await loadStandingAllow(filePath);
|
|
9
|
+
const kind = options.kind ?? 'shell';
|
|
10
|
+
const { state: next, removed } = revokeStandingAllowEntry(state, {
|
|
11
|
+
kind,
|
|
12
|
+
fingerprint: options.fingerprint,
|
|
13
|
+
repoRoot,
|
|
14
|
+
});
|
|
15
|
+
if (!removed) {
|
|
16
|
+
return {
|
|
17
|
+
ok: false,
|
|
18
|
+
message: `Standing-allow entry not found for ${kind} fingerprint ${options.fingerprint}.`,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
await saveStandingAllow(filePath, next);
|
|
22
|
+
return {
|
|
23
|
+
ok: true,
|
|
24
|
+
message: `Revoked standing-allow for ${kind} fingerprint ${options.fingerprint}.`,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zero-dependency terminal UI helpers for the interactive `belay config` wizard.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is built on Node's built-in `readline`/`tty` only — no runtime
|
|
5
|
+
* dependencies — so it stays aligned with belay's zero-dependency posture.
|
|
6
|
+
*
|
|
7
|
+
* This module is intentionally TTY-only. Callers are responsible for non-TTY
|
|
8
|
+
* fallback behavior.
|
|
9
|
+
*/
|
|
10
|
+
export declare const c: {
|
|
11
|
+
bold: (s: string) => string;
|
|
12
|
+
dim: (s: string) => string;
|
|
13
|
+
italic: (s: string) => string;
|
|
14
|
+
underline: (s: string) => string;
|
|
15
|
+
inverse: (s: string) => string;
|
|
16
|
+
cyan: (s: string) => string;
|
|
17
|
+
green: (s: string) => string;
|
|
18
|
+
yellow: (s: string) => string;
|
|
19
|
+
red: (s: string) => string;
|
|
20
|
+
gray: (s: string) => string;
|
|
21
|
+
};
|
|
22
|
+
/** A select wizard only makes sense when both stdin and stdout are real TTYs. */
|
|
23
|
+
export declare function isInteractiveTTY(): boolean;
|
|
24
|
+
/** Top banner shown once at the start of the wizard. */
|
|
25
|
+
export declare function intro(title: string, subtitle?: string): void;
|
|
26
|
+
/** Closing line shown once at the end of the wizard. */
|
|
27
|
+
export declare function outro(message: string): void;
|
|
28
|
+
/** Collapsed one-line summary of an answered question. */
|
|
29
|
+
export declare function summary(label: string, value: string): void;
|
|
30
|
+
export interface SelectChoice<T extends string> {
|
|
31
|
+
value: T;
|
|
32
|
+
/** Visible label; defaults to `value`. */
|
|
33
|
+
label?: string;
|
|
34
|
+
/** Dim hint shown to the right of the focused row. */
|
|
35
|
+
hint?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface SelectOptions<T extends string> {
|
|
38
|
+
message: string;
|
|
39
|
+
choices: SelectChoice<T>[];
|
|
40
|
+
defaultValue: T;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Interactive single-choice prompt driven by arrow keys (or j/k). Resolves with
|
|
44
|
+
* the selected choice value. Must only be called in an interactive TTY.
|
|
45
|
+
*/
|
|
46
|
+
export declare function selectPrompt<T extends string>(opts: SelectOptions<T>): Promise<T>;
|
|
47
|
+
/** Interactive yes/no prompt rendered as a two-row arrow-key selection. */
|
|
48
|
+
export declare function confirmPrompt(message: string, defaultValue: boolean): Promise<boolean>;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zero-dependency terminal UI helpers for the interactive `belay config` wizard.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is built on Node's built-in `readline`/`tty` only — no runtime
|
|
5
|
+
* dependencies — so it stays aligned with belay's zero-dependency posture.
|
|
6
|
+
*
|
|
7
|
+
* This module is intentionally TTY-only. Callers are responsible for non-TTY
|
|
8
|
+
* fallback behavior.
|
|
9
|
+
*/
|
|
10
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
11
|
+
import readline from 'node:readline';
|
|
12
|
+
const isColorEnabled = () => isInteractiveTTY() && !process.env.NO_COLOR && process.env.TERM !== 'dumb';
|
|
13
|
+
const symbol = (pretty, plain) => (isColorEnabled() ? pretty : plain);
|
|
14
|
+
const wrap = (open, close) => (s) => isColorEnabled() ? `\x1b[${open}m${s}\x1b[${close}m` : s;
|
|
15
|
+
export const c = {
|
|
16
|
+
bold: wrap(1, 22),
|
|
17
|
+
dim: wrap(2, 22),
|
|
18
|
+
italic: wrap(3, 23),
|
|
19
|
+
underline: wrap(4, 24),
|
|
20
|
+
inverse: wrap(7, 27),
|
|
21
|
+
cyan: wrap(36, 39),
|
|
22
|
+
green: wrap(32, 39),
|
|
23
|
+
yellow: wrap(33, 39),
|
|
24
|
+
red: wrap(31, 39),
|
|
25
|
+
gray: wrap(90, 39),
|
|
26
|
+
};
|
|
27
|
+
/** A select wizard only makes sense when both stdin and stdout are real TTYs. */
|
|
28
|
+
export function isInteractiveTTY() {
|
|
29
|
+
return Boolean(input.isTTY && output.isTTY);
|
|
30
|
+
}
|
|
31
|
+
const symPointer = () => symbol('❯', '>');
|
|
32
|
+
const symCheck = () => symbol('✓', 'v');
|
|
33
|
+
const symBar = () => symbol('│', '|');
|
|
34
|
+
const symQuestion = () => '?';
|
|
35
|
+
/** Top banner shown once at the start of the wizard. */
|
|
36
|
+
export function intro(title, subtitle) {
|
|
37
|
+
output.write('\n');
|
|
38
|
+
output.write(`${c.green(symBar())} ${c.bold(title)}\n`);
|
|
39
|
+
if (subtitle) {
|
|
40
|
+
output.write(`${c.green(symBar())} ${c.dim(subtitle)}\n`);
|
|
41
|
+
}
|
|
42
|
+
output.write(`${c.green(symBar())}\n`);
|
|
43
|
+
}
|
|
44
|
+
/** Closing line shown once at the end of the wizard. */
|
|
45
|
+
export function outro(message) {
|
|
46
|
+
output.write(`${c.green(symBar())}\n`);
|
|
47
|
+
output.write(`${c.green(symCheck())} ${message}\n\n`);
|
|
48
|
+
}
|
|
49
|
+
/** Collapsed one-line summary of an answered question. */
|
|
50
|
+
export function summary(label, value) {
|
|
51
|
+
output.write(`${c.green(symCheck())} ${c.dim(label)} ${c.cyan(value)}\n`);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Interactive single-choice prompt driven by arrow keys (or j/k). Resolves with
|
|
55
|
+
* the selected choice value. Must only be called in an interactive TTY.
|
|
56
|
+
*/
|
|
57
|
+
export function selectPrompt(opts) {
|
|
58
|
+
if (!isInteractiveTTY()) {
|
|
59
|
+
throw new Error('selectPrompt requires an interactive TTY.');
|
|
60
|
+
}
|
|
61
|
+
const { message, choices } = opts;
|
|
62
|
+
if (choices.length === 0) {
|
|
63
|
+
throw new Error('selectPrompt requires at least one choice.');
|
|
64
|
+
}
|
|
65
|
+
const startIndex = Math.max(0, choices.findIndex((ch) => ch.value === opts.defaultValue));
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
let index = startIndex;
|
|
68
|
+
let rendered = 0;
|
|
69
|
+
const lineFor = (ch, active) => {
|
|
70
|
+
const label = ch.label ?? ch.value;
|
|
71
|
+
if (active) {
|
|
72
|
+
const hint = ch.hint ? ` ${c.dim(ch.hint)}` : '';
|
|
73
|
+
return `${c.cyan(symPointer())} ${c.cyan(c.underline(label))}${hint}`;
|
|
74
|
+
}
|
|
75
|
+
return ` ${c.dim(label)}`;
|
|
76
|
+
};
|
|
77
|
+
const clearRendered = () => {
|
|
78
|
+
if (rendered > 0) {
|
|
79
|
+
output.write(`\x1b[${rendered}A`);
|
|
80
|
+
}
|
|
81
|
+
output.write('\x1b[J');
|
|
82
|
+
};
|
|
83
|
+
const render = () => {
|
|
84
|
+
clearRendered();
|
|
85
|
+
const lines = [
|
|
86
|
+
`${c.green(symQuestion())} ${c.bold(message)}`,
|
|
87
|
+
...choices.map((ch, i) => lineFor(ch, i === index)),
|
|
88
|
+
];
|
|
89
|
+
output.write(`${lines.join('\n')}\n`);
|
|
90
|
+
rendered = lines.length;
|
|
91
|
+
};
|
|
92
|
+
const cleanup = () => {
|
|
93
|
+
input.off('keypress', onKey);
|
|
94
|
+
if (input.isTTY && typeof input.setRawMode === 'function') {
|
|
95
|
+
input.setRawMode(false);
|
|
96
|
+
}
|
|
97
|
+
input.pause();
|
|
98
|
+
output.write('\x1b[?25h'); // show cursor
|
|
99
|
+
};
|
|
100
|
+
const finish = () => {
|
|
101
|
+
clearRendered();
|
|
102
|
+
cleanup();
|
|
103
|
+
const chosen = choices[index];
|
|
104
|
+
summary(message, chosen.label ?? chosen.value);
|
|
105
|
+
resolve(chosen.value);
|
|
106
|
+
};
|
|
107
|
+
const onKey = (_str, key) => {
|
|
108
|
+
if (!key)
|
|
109
|
+
return;
|
|
110
|
+
if (key.ctrl && key.name === 'c') {
|
|
111
|
+
clearRendered();
|
|
112
|
+
cleanup();
|
|
113
|
+
output.write('\n');
|
|
114
|
+
reject(new Error('Cancelled.'));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (key.name === 'up' || key.name === 'k') {
|
|
118
|
+
index = (index - 1 + choices.length) % choices.length;
|
|
119
|
+
render();
|
|
120
|
+
}
|
|
121
|
+
else if (key.name === 'down' || key.name === 'j') {
|
|
122
|
+
index = (index + 1) % choices.length;
|
|
123
|
+
render();
|
|
124
|
+
}
|
|
125
|
+
else if (key.name === 'return' || key.name === 'enter') {
|
|
126
|
+
finish();
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
readline.emitKeypressEvents(input);
|
|
130
|
+
if (input.isTTY && typeof input.setRawMode === 'function') {
|
|
131
|
+
input.setRawMode(true);
|
|
132
|
+
}
|
|
133
|
+
input.resume();
|
|
134
|
+
output.write('\x1b[?25l'); // hide cursor
|
|
135
|
+
input.on('keypress', onKey);
|
|
136
|
+
render();
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/** Interactive yes/no prompt rendered as a two-row arrow-key selection. */
|
|
140
|
+
export async function confirmPrompt(message, defaultValue) {
|
|
141
|
+
const value = await selectPrompt({
|
|
142
|
+
message,
|
|
143
|
+
defaultValue: defaultValue ? 'yes' : 'no',
|
|
144
|
+
choices: [
|
|
145
|
+
{ value: 'yes', label: 'Yes' },
|
|
146
|
+
{ value: 'no', label: 'No' },
|
|
147
|
+
],
|
|
148
|
+
});
|
|
149
|
+
return value === 'yes';
|
|
150
|
+
}
|