@pellux/goodvibes-tui 1.13.0 → 1.14.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/CHANGELOG.md +101 -0
- package/README.md +6 -6
- package/bin/goodvibes +6 -0
- package/bin/goodvibes-daemon +6 -0
- package/bin/launcher-support.js +34 -1
- package/docs/foundation-artifacts/operator-contract.json +8076 -2047
- package/package.json +15 -11
- package/scripts/postinstall.js +73 -48
- package/src/cli/bundle-command.ts +5 -5
- package/src/cli/completion.ts +1 -1
- package/src/cli/completions/generate.ts +15 -3
- package/src/cli/doctor.ts +385 -0
- package/src/cli/entrypoint.ts +72 -1
- package/src/cli/help.ts +29 -9
- package/src/cli/hooks-command.ts +74 -0
- package/src/cli/hooks-report.ts +167 -0
- package/src/cli/index.ts +3 -0
- package/src/cli/management.ts +13 -1
- package/src/cli/parser.ts +10 -2
- package/src/cli/plugin-bundles-command.ts +228 -0
- package/src/cli/plugin-command.ts +179 -0
- package/src/cli/service-posture.ts +10 -0
- package/src/cli/status.ts +275 -2
- package/src/cli/tui-startup.ts +119 -1
- package/src/cli/types.ts +3 -1
- package/src/config/index.ts +16 -0
- package/src/config/tui-extension-settings.ts +169 -0
- package/src/core/approval-alert.ts +15 -2
- package/src/core/compaction-receipt.ts +127 -0
- package/src/core/model-routing-chip.ts +82 -0
- package/src/core/permission-mode.ts +89 -0
- package/src/core/rewind-receipt.ts +83 -0
- package/src/core/rewind-turn-anchors.ts +160 -0
- package/src/core/scriptable-statusline.ts +141 -0
- package/src/core/session-auto-titler.ts +110 -0
- package/src/core/stream-event-wiring.ts +20 -0
- package/src/core/system-message-router.ts +1 -1
- package/src/core/terminal-notifier.ts +102 -0
- package/src/core/turn-event-wiring.ts +80 -1
- package/src/input/command-reference.ts +96 -0
- package/src/input/command-registry.ts +25 -0
- package/src/input/commands/channel-pairing.ts +245 -0
- package/src/input/commands/channel-runtime.ts +152 -6
- package/src/input/commands/checkin-runtime.ts +89 -0
- package/src/input/commands/checkpoint-runtime.ts +7 -179
- package/src/input/commands/ci-runtime.ts +207 -0
- package/src/input/commands/cost-attribution-format.ts +52 -0
- package/src/input/commands/cost-runtime.ts +44 -4
- package/src/input/commands/editor-runtime.ts +22 -0
- package/src/input/commands/flags-runtime.ts +328 -0
- package/src/input/commands/health-metrics-format.ts +67 -0
- package/src/input/commands/health-runtime.ts +55 -1
- package/src/input/commands/memory.ts +23 -0
- package/src/input/commands/operator-rpc.ts +95 -0
- package/src/input/commands/palette-runtime.ts +22 -0
- package/src/input/commands/permissions-provenance.ts +264 -0
- package/src/input/commands/permissions-runtime.ts +21 -0
- package/src/input/commands/planning-runtime.ts +28 -1
- package/src/input/commands/platform-access-runtime.ts +2 -97
- package/src/input/commands/principals-runtime.ts +212 -0
- package/src/input/commands/product-runtime.ts +43 -3
- package/src/input/commands/recall-files-config.ts +54 -0
- package/src/input/commands/recall-files-git.ts +57 -0
- package/src/input/commands/recall-files.ts +185 -0
- package/src/input/commands/recall-query.ts +8 -3
- package/src/input/commands/recall-review.ts +2 -2
- package/src/input/commands/recall-shared.ts +13 -0
- package/src/input/commands/relay-runtime.ts +86 -0
- package/src/input/commands/review-runtime.ts +160 -0
- package/src/input/commands/rewind-runtime.ts +362 -0
- package/src/input/commands/schedule-nl.ts +147 -0
- package/src/input/commands/schedule-runtime.ts +29 -10
- package/src/input/commands/secret-runtime.ts +55 -0
- package/src/input/commands/session-content.ts +19 -8
- package/src/input/commands/session-workflow.ts +10 -0
- package/src/input/commands/update-runtime.ts +363 -0
- package/src/input/commands/workstream-attempt-dependents.ts +103 -0
- package/src/input/commands/workstream-attempts.ts +244 -0
- package/src/input/commands/workstream-runtime.ts +164 -26
- package/src/input/commands/worktree-runtime.ts +56 -2
- package/src/input/commands.ts +135 -80
- package/src/input/composer-capture.ts +67 -0
- package/src/input/composer-editor.ts +134 -0
- package/src/input/concealed-input.ts +94 -0
- package/src/input/exec-env-scrub-config.ts +84 -0
- package/src/input/feature-unit-layout.ts +142 -0
- package/src/input/feed-context-factory.ts +2 -0
- package/src/input/handler-feed-routes.ts +17 -0
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-modal-routes.ts +5 -2
- package/src/input/handler-shortcuts.ts +37 -0
- package/src/input/handler.ts +24 -2
- package/src/input/keybindings.ts +11 -2
- package/src/input/onboarding/onboarding-feature-units.ts +536 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +40 -6
- package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +324 -629
- package/src/input/onboarding/onboarding-wizard-steps.ts +5 -5
- package/src/input/onboarding/onboarding-wizard-types.ts +6 -0
- package/src/input/relay-settings-descriptions.ts +35 -0
- package/src/input/sandbox-exec-config.ts +107 -0
- package/src/input/settings-modal-adjustment.ts +4 -7
- package/src/input/settings-modal-data.ts +178 -8
- package/src/input/settings-modal-mutations.ts +41 -13
- package/src/input/settings-modal-types.ts +42 -5
- package/src/input/settings-modal.ts +67 -15
- package/src/input/submission-router.ts +4 -1
- package/src/input/worktree-setup-config.ts +93 -0
- package/src/main.ts +43 -43
- package/src/panels/builtin/development.ts +14 -0
- package/src/panels/diff-review-model.ts +189 -0
- package/src/panels/diff-review-panel.ts +395 -0
- package/src/panels/fleet-panel-format.ts +114 -2
- package/src/panels/fleet-panel.ts +65 -78
- package/src/panels/fleet-read-model.ts +91 -11
- package/src/panels/fleet-stop.ts +19 -4
- package/src/panels/modals/memory-modal.ts +11 -2
- package/src/permissions/hunk-selection.ts +86 -5
- package/src/permissions/prompt.ts +108 -7
- package/src/permissions/sandbox-exec-gate.ts +162 -0
- package/src/renderer/settings-modal-helpers.ts +21 -1
- package/src/renderer/settings-modal.ts +70 -44
- package/src/renderer/shell-surface.ts +23 -1
- package/src/renderer/system-message.ts +4 -0
- package/src/renderer/tool-result-summary.ts +95 -5
- package/src/renderer/ui-factory.ts +22 -0
- package/src/runtime/bootstrap-command-context.ts +15 -1
- package/src/runtime/bootstrap-command-parts.ts +14 -2
- package/src/runtime/bootstrap-core.ts +38 -38
- package/src/runtime/bootstrap-notifier-sync.ts +41 -0
- package/src/runtime/bootstrap-shell.ts +8 -0
- package/src/runtime/bootstrap.ts +21 -23
- package/src/runtime/context-accounting-source.ts +103 -0
- package/src/runtime/conversation-rewind-port.ts +130 -0
- package/src/runtime/fleet-needs-input-push.ts +70 -0
- package/src/runtime/install-self-check-startup.ts +36 -0
- package/src/runtime/install-self-check.ts +149 -0
- package/src/runtime/onboarding/fast-path.ts +93 -0
- package/src/runtime/process-lifecycle.ts +32 -24
- package/src/runtime/relay-reachability-bridge.ts +53 -0
- package/src/runtime/release-artifacts.ts +79 -0
- package/src/runtime/services.ts +38 -37
- package/src/runtime/trust/workspace-registration.ts +184 -0
- package/src/runtime/trust/workspace-trust.ts +202 -0
- package/src/runtime/ui-services.ts +2 -1
- package/src/runtime/update-check.ts +113 -0
- package/src/runtime/workspace-checkpointing.ts +32 -0
- package/src/runtime/workstream-attempts-validation.ts +77 -0
- package/src/runtime/workstream-draft-edits.ts +95 -0
- package/src/runtime/workstream-draft-store.ts +101 -0
- package/src/runtime/workstream-draft-types.ts +42 -0
- package/src/runtime/workstream-services.ts +70 -39
- package/src/shell/blocking-input.ts +29 -0
- package/src/shell/ui-openers.ts +65 -0
- package/src/verification/verification-ledger.ts +56 -2
- package/src/version.ts +1 -1
- package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +0 -130
- package/src/runtime/render-scheduler.ts +0 -90
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ci-runtime.ts
|
|
3
|
+
*
|
|
4
|
+
* `/ci` — the CI-watch surface: a one-shot per-job status check (`ci.status`)
|
|
5
|
+
* and standing watches (`ci.watches.*`) over the operator wire (see
|
|
6
|
+
* operator-rpc.ts for why this goes over HTTP rather than the in-process
|
|
7
|
+
* OperatorClient facade). The doctrine, verbatim from the operator contract's
|
|
8
|
+
* `ci.status` description: "the overall verdict is derived from the per-job
|
|
9
|
+
* conclusions (never a rollup); continue-on-error jobs are surfaced as
|
|
10
|
+
* violations and force the verdict off 'passed'." `platform/ci-watch` (the
|
|
11
|
+
* module implementing that doctrine server-side) is not in this SDK's public
|
|
12
|
+
* export map yet, so the per-job rendering below is a thin, doctrine-faithful
|
|
13
|
+
* mirror of the contract's own `ci.status`/`ci.watches.run` output — every job
|
|
14
|
+
* name and conclusion listed individually, never summarized.
|
|
15
|
+
*/
|
|
16
|
+
import type { CommandRegistry } from '../command-registry.ts';
|
|
17
|
+
import { describeOperatorRpcError, getOperatorRpc } from './operator-rpc.ts';
|
|
18
|
+
import type { OperatorMethodOutput } from '@pellux/goodvibes-sdk';
|
|
19
|
+
|
|
20
|
+
export type CiReport = OperatorMethodOutput<'ci.status'>['report'];
|
|
21
|
+
export type CiWatchSubscription = OperatorMethodOutput<'ci.watches.list'>['watches'][number];
|
|
22
|
+
|
|
23
|
+
interface CiTarget {
|
|
24
|
+
readonly repo: string;
|
|
25
|
+
readonly ref?: string;
|
|
26
|
+
readonly prNumber?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse a combined "repo-or-pr" token: `owner/repo`, `owner/repo#123` (PR),
|
|
31
|
+
* or `owner/repo@ref` (branch/tag/sha). Returns null when the repo segment
|
|
32
|
+
* is missing so callers can print a usage hint instead of a confusing error.
|
|
33
|
+
*/
|
|
34
|
+
export function parseCiTarget(token: string): CiTarget | null {
|
|
35
|
+
const trimmed = token.trim();
|
|
36
|
+
if (!trimmed) return null;
|
|
37
|
+
const prMatch = trimmed.match(/^([^#@]+)#(\d+)$/);
|
|
38
|
+
if (prMatch) {
|
|
39
|
+
const [, repo, prNumber] = prMatch;
|
|
40
|
+
if (!repo) return null;
|
|
41
|
+
return { repo, prNumber: Number(prNumber) };
|
|
42
|
+
}
|
|
43
|
+
const refMatch = trimmed.match(/^([^#@]+)@(.+)$/);
|
|
44
|
+
if (refMatch) {
|
|
45
|
+
const [, repo, ref] = refMatch;
|
|
46
|
+
if (!repo || !ref) return null;
|
|
47
|
+
return { repo, ref };
|
|
48
|
+
}
|
|
49
|
+
return { repo: trimmed };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function formatTarget(target: { readonly repo: string; readonly ref?: string | undefined; readonly prNumber?: number | undefined }): string {
|
|
53
|
+
if (target.prNumber) return `${target.repo}#${target.prNumber}`;
|
|
54
|
+
if (target.ref) return `${target.repo}@${target.ref}`;
|
|
55
|
+
return target.repo;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Per-job listing, doctrine-faithful: every job and its conclusion, never a rollup. */
|
|
59
|
+
export function renderReport(report: CiReport): string {
|
|
60
|
+
const failing = report.jobs
|
|
61
|
+
.filter((job) => job.status === 'completed' && job.conclusion !== 'success')
|
|
62
|
+
.map((job) => job.name);
|
|
63
|
+
const lines: string[] = [];
|
|
64
|
+
if (report.overall !== 'passed' && failing.length > 0) lines.push(`FAILING: ${failing.join(', ')}`);
|
|
65
|
+
lines.push(`CI ${report.overall.toUpperCase()} for ${formatTarget(report)}`);
|
|
66
|
+
for (const job of report.jobs) {
|
|
67
|
+
lines.push(` - ${job.name}: ${job.status === 'completed' ? (job.conclusion ?? 'no-conclusion') : job.status}${job.continueOnError ? ' [continue-on-error]' : ''}`);
|
|
68
|
+
}
|
|
69
|
+
for (const violation of report.violations) lines.push(` ! ${violation}`);
|
|
70
|
+
return lines.join('\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function renderWatch(watch: CiWatchSubscription): string {
|
|
74
|
+
const parts = [
|
|
75
|
+
` ${watch.id} ${formatTarget(watch)}`,
|
|
76
|
+
` delivery: ${watch.deliveryChannel}`,
|
|
77
|
+
` fix-session on failure: ${watch.triggerFixSession ? 'yes' : 'no'}`,
|
|
78
|
+
];
|
|
79
|
+
if (watch.lastOverall) parts.push(` last checked: ${watch.lastOverall}`);
|
|
80
|
+
return parts.join('\n');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function registerCiRuntimeCommands(registry: CommandRegistry): void {
|
|
84
|
+
registry.register({
|
|
85
|
+
name: 'ci',
|
|
86
|
+
description: 'CI-watch: one-shot per-job status and standing watches over the operator surface',
|
|
87
|
+
usage: 'status <repo-or-pr> | watch <repo-or-pr> <deliveryChannel> [--fix-session] | watches | unwatch <id>',
|
|
88
|
+
argsHint: '[status|watch|watches|unwatch]',
|
|
89
|
+
async handler(args, ctx) {
|
|
90
|
+
const sub = args[0];
|
|
91
|
+
const rpc = getOperatorRpc(ctx);
|
|
92
|
+
|
|
93
|
+
if (sub === 'status') {
|
|
94
|
+
const target = args[1] ? parseCiTarget(args[1]) : null;
|
|
95
|
+
if (!target) {
|
|
96
|
+
ctx.print('Usage: /ci status <repo-or-pr> (e.g. owner/repo, owner/repo#123, owner/repo@branch)');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (!rpc.available) {
|
|
100
|
+
ctx.print(`[ci] ${rpc.reason}`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const { report } = await rpc.sdk.operator.invoke('ci.status', {
|
|
105
|
+
repo: target.repo,
|
|
106
|
+
...(target.ref !== undefined ? { ref: target.ref } : {}),
|
|
107
|
+
...(target.prNumber !== undefined ? { prNumber: target.prNumber } : {}),
|
|
108
|
+
});
|
|
109
|
+
ctx.print(renderReport(report));
|
|
110
|
+
} catch (error) {
|
|
111
|
+
ctx.print(`[ci status] ${describeOperatorRpcError(error)}`);
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (sub === 'watch') {
|
|
117
|
+
const target = args[1] ? parseCiTarget(args[1]) : null;
|
|
118
|
+
const deliveryChannel = args[2];
|
|
119
|
+
const triggerFixSession = args.includes('--fix-session');
|
|
120
|
+
if (!target || !deliveryChannel) {
|
|
121
|
+
ctx.print('Usage: /ci watch <repo-or-pr> <deliveryChannel> [--fix-session] (deliveryChannel: "surfaceKind" or "surfaceKind:address")');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (!rpc.available) {
|
|
125
|
+
ctx.print(`[ci] ${rpc.reason}`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const { watch } = await rpc.sdk.operator.invoke('ci.watches.create', {
|
|
130
|
+
repo: target.repo,
|
|
131
|
+
...(target.ref !== undefined ? { ref: target.ref } : {}),
|
|
132
|
+
...(target.prNumber !== undefined ? { prNumber: target.prNumber } : {}),
|
|
133
|
+
deliveryChannel,
|
|
134
|
+
triggerFixSession,
|
|
135
|
+
});
|
|
136
|
+
ctx.print(`[ci watch] created watch ${watch.id} for ${formatTarget(watch)} -> ${watch.deliveryChannel}`);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
ctx.print(`[ci watch] ${describeOperatorRpcError(error)}`);
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (sub === 'watches') {
|
|
144
|
+
const runId = args[1] === 'run' ? args[2] : undefined;
|
|
145
|
+
if (!rpc.available) {
|
|
146
|
+
ctx.print(`[ci] ${rpc.reason}`);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (args[1] === 'run') {
|
|
150
|
+
if (!runId) {
|
|
151
|
+
ctx.print('Usage: /ci watches run <id>');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
const result = await rpc.sdk.operator.invoke('ci.watches.run', { watchId: runId });
|
|
156
|
+
const lines = [renderReport(result.report)];
|
|
157
|
+
lines.push(`notified: ${result.notified ? 'yes' : 'no'}${result.notificationId ? ` (${result.notificationId})` : ''}`);
|
|
158
|
+
lines.push(`fix-session triggered: ${result.fixSessionTriggered ? 'yes' : 'no'}${result.fixSessionId ? ` (${result.fixSessionId})` : ''}`);
|
|
159
|
+
ctx.print(lines.join('\n'));
|
|
160
|
+
} catch (error) {
|
|
161
|
+
ctx.print(`[ci watches run] ${describeOperatorRpcError(error)}`);
|
|
162
|
+
}
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
const { watches } = await rpc.sdk.operator.invoke('ci.watches.list', {});
|
|
167
|
+
if (watches.length === 0) {
|
|
168
|
+
ctx.print('[ci watches] no standing watches.');
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
ctx.print(['CI watches:', ...watches.map(renderWatch)].join('\n'));
|
|
172
|
+
} catch (error) {
|
|
173
|
+
ctx.print(`[ci watches] ${describeOperatorRpcError(error)}`);
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (sub === 'unwatch') {
|
|
179
|
+
const watchId = args[1];
|
|
180
|
+
if (!watchId) {
|
|
181
|
+
ctx.print('Usage: /ci unwatch <id>');
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (!rpc.available) {
|
|
185
|
+
ctx.print(`[ci] ${rpc.reason}`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
const result = await rpc.sdk.operator.invoke('ci.watches.delete', { watchId });
|
|
190
|
+
ctx.print(result.deleted ? `[ci unwatch] deleted watch ${watchId}.` : `[ci unwatch] no watch found with id ${watchId}.`);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
ctx.print(`[ci unwatch] ${describeOperatorRpcError(error)}`);
|
|
193
|
+
}
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
ctx.print(
|
|
198
|
+
'Usage: /ci <subcommand>\n'
|
|
199
|
+
+ ' /ci status <repo-or-pr> — per-job CI status (never a rollup)\n'
|
|
200
|
+
+ ' /ci watch <repo-or-pr> <deliveryChannel> [--fix-session] — create a standing watch\n'
|
|
201
|
+
+ ' /ci watches — list standing watches\n'
|
|
202
|
+
+ ' /ci watches run <id> — check one watch now\n'
|
|
203
|
+
+ ' /ci unwatch <id> — delete a standing watch'
|
|
204
|
+
);
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cost-attribution-format.ts — pure rendering helpers for /cost attribution.
|
|
3
|
+
*
|
|
4
|
+
* cost.attribution.get (SDK 1.6.1) is honest-unpriced by design: an unknown
|
|
5
|
+
* model contributes to unpricedRecordCount with a null cost rather than a
|
|
6
|
+
* fabricated amount, and costState is 'priced' | 'estimated' | 'unpriced'
|
|
7
|
+
* (a mix across contributors). These helpers preserve that distinction all
|
|
8
|
+
* the way to the rendered line — "unpriced" always reads as unpriced,
|
|
9
|
+
* "estimated" is always labeled, and a dollar figure only ever comes from a
|
|
10
|
+
* real priced/estimated costUsd.
|
|
11
|
+
*/
|
|
12
|
+
import type { OperatorMethodOutput } from '@pellux/goodvibes-sdk';
|
|
13
|
+
|
|
14
|
+
export type CostAttributionResult = OperatorMethodOutput<'cost.attribution.get'>;
|
|
15
|
+
export type CostAttributionDimension = CostAttributionResult['dimension'];
|
|
16
|
+
export type CostWindow = CostAttributionResult['window'];
|
|
17
|
+
|
|
18
|
+
/** Dimensions always queried and rendered, even when empty (agent/model/provider are the headline breakdown). */
|
|
19
|
+
export const COST_ATTRIBUTION_PRIMARY_DIMENSIONS: readonly CostAttributionDimension[] = ['agent', 'model', 'provider'];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Dimensions that exist on the wire but return honest-empty until their
|
|
23
|
+
* emit sites tag records with this dimension. Queried the same as the
|
|
24
|
+
* primary set, but rendered ONLY when non-empty — never a fabricated
|
|
25
|
+
* "0 tool calls" section implying tool-level attribution works today.
|
|
26
|
+
*/
|
|
27
|
+
export const COST_ATTRIBUTION_OPTIONAL_DIMENSIONS: readonly CostAttributionDimension[] = ['tool', 'hook', 'mcp'];
|
|
28
|
+
|
|
29
|
+
function formatCostAmount(costUsd: number | null, costState: CostAttributionResult['costState']): string {
|
|
30
|
+
if (costUsd === null || costState === 'unpriced') return 'unpriced';
|
|
31
|
+
const amount = `$${costUsd.toFixed(4)}`;
|
|
32
|
+
return costState === 'estimated' ? `${amount} (estimated)` : amount;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function formatTokens(tokens: CostAttributionResult['tokens']): string {
|
|
36
|
+
return `in=${tokens.inputTokens} out=${tokens.outputTokens} cacheR=${tokens.cacheReadTokens} cacheW=${tokens.cacheWriteTokens}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Render one dimension's windowed attribution result as text lines, or null when it's an optional dimension with no rows yet. */
|
|
40
|
+
export function formatCostAttributionSection(result: CostAttributionResult, isOptional: boolean): string[] | null {
|
|
41
|
+
if (isOptional && result.rows.length === 0) return null;
|
|
42
|
+
const lines: string[] = [`${result.dimension} (${result.window}, since ${new Date(result.windowStartMs).toLocaleString()}):`];
|
|
43
|
+
if (result.rows.length === 0) {
|
|
44
|
+
lines.push(' (no attributed records in this window)');
|
|
45
|
+
return lines;
|
|
46
|
+
}
|
|
47
|
+
for (const row of result.rows) {
|
|
48
|
+
lines.push(` ${row.key.padEnd(24)} ${formatCostAmount(row.costUsd, row.costState).padEnd(20)} ${formatTokens(row.tokens)}`);
|
|
49
|
+
}
|
|
50
|
+
lines.push(` total: ${formatCostAmount(result.totalCostUsd, result.costState)} — ${result.pricedRecordCount} priced, ${result.unpricedRecordCount} unpriced record(s)`);
|
|
51
|
+
return lines;
|
|
52
|
+
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { CommandRegistry } from '../command-registry.ts';
|
|
2
2
|
import { CostTrackerPanel } from '../../panels/cost-tracker-panel.ts';
|
|
3
3
|
import { openCommandPanel, requirePanelManager } from './runtime-services.ts';
|
|
4
|
+
import { describeOperatorRpcError, getOperatorRpc } from './operator-rpc.ts';
|
|
5
|
+
import {
|
|
6
|
+
COST_ATTRIBUTION_OPTIONAL_DIMENSIONS,
|
|
7
|
+
COST_ATTRIBUTION_PRIMARY_DIMENSIONS,
|
|
8
|
+
formatCostAttributionSection,
|
|
9
|
+
type CostAttributionResult,
|
|
10
|
+
type CostWindow,
|
|
11
|
+
} from './cost-attribution-format.ts';
|
|
4
12
|
|
|
5
13
|
/**
|
|
6
14
|
* /cost budget <usd> — makes the CostTrackerPanel's budget alert real (WO-139).
|
|
@@ -13,9 +21,9 @@ import { openCommandPanel, requirePanelManager } from './runtime-services.ts';
|
|
|
13
21
|
export function registerCostRuntimeCommands(registry: CommandRegistry): void {
|
|
14
22
|
registry.register({
|
|
15
23
|
name: 'cost',
|
|
16
|
-
description: 'Inspect session/agent cost tracking and
|
|
17
|
-
usage: '[panel|budget <usd
|
|
18
|
-
handler(args, ctx) {
|
|
24
|
+
description: 'Inspect session/agent cost tracking, windowed cost attribution, and the budget alert threshold',
|
|
25
|
+
usage: '[panel|budget <usd>|attribution [24h|7d] [--json]]',
|
|
26
|
+
async handler(args, ctx) {
|
|
19
27
|
const sub = (args[0] ?? 'panel').toLowerCase();
|
|
20
28
|
|
|
21
29
|
if (sub === 'panel' || sub === 'open') {
|
|
@@ -43,7 +51,39 @@ export function registerCostRuntimeCommands(registry: CommandRegistry): void {
|
|
|
43
51
|
return;
|
|
44
52
|
}
|
|
45
53
|
|
|
46
|
-
|
|
54
|
+
if (sub === 'attribution' || sub === 'attr') {
|
|
55
|
+
const window: CostWindow = args.includes('7d') ? '7d' : '24h';
|
|
56
|
+
const asJson = args.includes('--json');
|
|
57
|
+
const rpc = getOperatorRpc(ctx);
|
|
58
|
+
if (!rpc.available) {
|
|
59
|
+
ctx.print(`[cost attribution] ${rpc.reason}`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const dimensions = [...COST_ATTRIBUTION_PRIMARY_DIMENSIONS, ...COST_ATTRIBUTION_OPTIONAL_DIMENSIONS];
|
|
63
|
+
const results: CostAttributionResult[] = [];
|
|
64
|
+
try {
|
|
65
|
+
for (const dimension of dimensions) {
|
|
66
|
+
results.push(await rpc.sdk.operator.invoke('cost.attribution.get', { window, dimension }));
|
|
67
|
+
}
|
|
68
|
+
} catch (error) {
|
|
69
|
+
ctx.print(`[cost attribution] round-trip request failed: ${describeOperatorRpcError(error)}`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (asJson) {
|
|
73
|
+
ctx.print(JSON.stringify(results, null, 2));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const lines: string[] = [`Cost Attribution — ${window}`];
|
|
77
|
+
for (const result of results) {
|
|
78
|
+
const isOptional = (COST_ATTRIBUTION_OPTIONAL_DIMENSIONS as readonly string[]).includes(result.dimension);
|
|
79
|
+
const section = formatCostAttributionSection(result, isOptional);
|
|
80
|
+
if (section) lines.push('', ...section);
|
|
81
|
+
}
|
|
82
|
+
ctx.print(lines.join('\n'));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
ctx.print('Usage: /cost [panel|budget <usd>|attribution [24h|7d] [--json]]');
|
|
47
87
|
},
|
|
48
88
|
});
|
|
49
89
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { CommandRegistry } from '../command-registry.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `/editor` — open the current composer draft in the user's $EDITOR / $VISUAL,
|
|
5
|
+
* then load the edited text back into the composer. The actual terminal
|
|
6
|
+
* suspend/resume and buffer round-trip are wired in main.ts as the
|
|
7
|
+
* `openComposerEditor` command action; this command is the front door for it.
|
|
8
|
+
*/
|
|
9
|
+
export function registerEditorRuntimeCommands(registry: CommandRegistry): void {
|
|
10
|
+
registry.register({
|
|
11
|
+
name: 'editor',
|
|
12
|
+
aliases: ['ed'],
|
|
13
|
+
description: 'Edit the current composer draft in your $EDITOR, then resume with the result',
|
|
14
|
+
handler(_args, ctx) {
|
|
15
|
+
if (!ctx.openComposerEditor) {
|
|
16
|
+
ctx.print('External editor composition is not available in this runtime.');
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
ctx.openComposerEditor();
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/flags` — surface every feature flag grouped by state, with runtime toggling
|
|
3
|
+
* for the flags that support it and a doctor section that names the "dark"
|
|
4
|
+
* subsystems (built but disabled) alongside the command to switch each on.
|
|
5
|
+
*
|
|
6
|
+
* The flag registry and runtime state come from the FeatureFlagManager
|
|
7
|
+
* (ctx.platform.featureFlagManager); persistence of an override goes through
|
|
8
|
+
* the config `featureFlags` category, reusing persistFlagState so the on-disk
|
|
9
|
+
* behavior matches the settings modal exactly.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { CommandRegistry, CommandContext } from '../command-registry.ts';
|
|
13
|
+
import type { FeatureFlag, FlagState } from '@/runtime/index.ts';
|
|
14
|
+
import type { OperatorMethodOutput } from '@pellux/goodvibes-sdk';
|
|
15
|
+
import { FEATURE_FLAG_CONFIG } from '@pellux/goodvibes-sdk/platform/runtime/state';
|
|
16
|
+
import { persistFlagState } from '../settings-modal-mutations.ts';
|
|
17
|
+
import { describeOperatorRpcError, getOperatorRpc } from './operator-rpc.ts';
|
|
18
|
+
import { SETTINGS_CATEGORY_GROUPS, type SettingsCategory } from '../settings-modal-types.ts';
|
|
19
|
+
import { getFeatureUnitHostCategory } from '../feature-unit-layout.ts';
|
|
20
|
+
|
|
21
|
+
/** The flags.graduation.report response, as typed by the installed operator contract. */
|
|
22
|
+
type GraduationReport = OperatorMethodOutput<'flags.graduation.report'>;
|
|
23
|
+
type GraduationEntry = GraduationReport['entries'][number];
|
|
24
|
+
|
|
25
|
+
/** A flag paired with its live runtime state and any pending-restart marker. */
|
|
26
|
+
export interface FlagSnapshotEntry {
|
|
27
|
+
readonly flag: FeatureFlag;
|
|
28
|
+
/** Live effective state the runtime currently sees. */
|
|
29
|
+
readonly state: FlagState;
|
|
30
|
+
/** Persisted config-layer value; differs from `state` only for a startup-gated flag awaiting restart. */
|
|
31
|
+
readonly persistedState: FlagState;
|
|
32
|
+
/** True when a startup-gated flag's saved value needs a restart to take effect. */
|
|
33
|
+
readonly pendingRestart: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ENABLED_MARK = '●';
|
|
37
|
+
const DISABLED_MARK = '○';
|
|
38
|
+
const KILLED_MARK = '✕';
|
|
39
|
+
|
|
40
|
+
function toggleability(flag: FeatureFlag): string {
|
|
41
|
+
return flag.runtimeToggleable ? 'runtime-toggleable' : 'startup-only';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function stateMark(state: FlagState): string {
|
|
45
|
+
return state === 'enabled' ? ENABLED_MARK : state === 'killed' ? KILLED_MARK : DISABLED_MARK;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The config keys a flag tunes, from FEATURE_FLAG_CONFIG (empty for no-config flags). */
|
|
49
|
+
function flagConfigKeys(flagId: string): readonly string[] {
|
|
50
|
+
return FEATURE_FLAG_CONFIG[flagId]?.configKeys ?? [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Format one flag as a feature unit: state + name + toggleability, then a config-key summary. */
|
|
54
|
+
function formatUnit(entry: FlagSnapshotEntry): string {
|
|
55
|
+
const { flag } = entry;
|
|
56
|
+
const lines = [` ${stateMark(entry.state)} ${flag.id} — ${flag.name} [${toggleability(flag)}]`];
|
|
57
|
+
if (entry.pendingRestart) {
|
|
58
|
+
lines.push(` restart pending: saved ${entry.persistedState}; still ${entry.state} until next launch`);
|
|
59
|
+
}
|
|
60
|
+
const keys = flagConfigKeys(flag.id);
|
|
61
|
+
if (keys.length > 0) {
|
|
62
|
+
const shown = keys.slice(0, 4).join(', ');
|
|
63
|
+
const more = keys.length > 4 ? `, +${keys.length - 4} more` : '';
|
|
64
|
+
lines.push(` tunes: ${shown}${more} (edit in /settings)`);
|
|
65
|
+
} else {
|
|
66
|
+
lines.push(' no config keys — toggle only');
|
|
67
|
+
}
|
|
68
|
+
if (entry.state === 'killed' && flag.killReason) {
|
|
69
|
+
lines.push(` killed: ${flag.killReason}`);
|
|
70
|
+
}
|
|
71
|
+
return lines.join('\n');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Top-level settings group label that hosts a flag's feature unit (mirrors the settings modal grouping). */
|
|
75
|
+
function groupLabelForFlag(flagId: string): string {
|
|
76
|
+
const host = getFeatureUnitHostCategory(flagId);
|
|
77
|
+
for (const group of SETTINGS_CATEGORY_GROUPS) {
|
|
78
|
+
if (group.categories.includes(host as SettingsCategory)) return group.label;
|
|
79
|
+
}
|
|
80
|
+
return 'Advanced';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build the `/flags` overview: feature units grouped by their topical settings
|
|
85
|
+
* group — each flag with the config keys it tunes — matching how the settings
|
|
86
|
+
* modal now presents them. This replaces the old flat Enabled/Disabled/Killed
|
|
87
|
+
* dump; the doctor and graduation views keep their diagnostic form. Pure —
|
|
88
|
+
* exported for testing.
|
|
89
|
+
*/
|
|
90
|
+
export function formatFlagsOverview(entries: readonly FlagSnapshotEntry[]): string {
|
|
91
|
+
if (entries.length === 0) return 'No feature flags are registered in this runtime.';
|
|
92
|
+
|
|
93
|
+
const enabled = entries.filter((e) => e.state === 'enabled').length;
|
|
94
|
+
const dark = entries.filter((e) => e.state === 'disabled').length;
|
|
95
|
+
const killed = entries.filter((e) => e.state === 'killed').length;
|
|
96
|
+
|
|
97
|
+
const out: string[] = [
|
|
98
|
+
`Feature flags (${entries.length} total) — ${enabled} enabled · ${dark} dark · ${killed} killed`,
|
|
99
|
+
'Each flag is one feature unit: its toggle plus the config keys it tunes. Full editing lives in /settings.',
|
|
100
|
+
'',
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
// Bucket the (already tier-then-id sorted) entries by their topical group,
|
|
104
|
+
// preserving SETTINGS_CATEGORY_GROUPS order.
|
|
105
|
+
const byGroup = new Map<string, FlagSnapshotEntry[]>();
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
const label = groupLabelForFlag(entry.flag.id);
|
|
108
|
+
if (!byGroup.has(label)) byGroup.set(label, []);
|
|
109
|
+
byGroup.get(label)!.push(entry);
|
|
110
|
+
}
|
|
111
|
+
const orderedLabels = [
|
|
112
|
+
...SETTINGS_CATEGORY_GROUPS.map((g) => g.label).filter((label) => byGroup.has(label)),
|
|
113
|
+
...[...byGroup.keys()].filter((label) => !SETTINGS_CATEGORY_GROUPS.some((g) => g.label === label)),
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
for (const label of orderedLabels) {
|
|
117
|
+
out.push(`${label.toUpperCase()}:`);
|
|
118
|
+
for (const entry of byGroup.get(label)!) out.push(formatUnit(entry));
|
|
119
|
+
out.push('');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
out.push('Toggle runtime-toggleable flags: /flags on <id> /flags off <id>');
|
|
123
|
+
out.push('Startup-only flags apply on next launch. Interactive toggling + full config: /settings.');
|
|
124
|
+
out.push('See /flags doctor for dark subsystems you can switch on; /flags graduation for release readiness.');
|
|
125
|
+
return out.join('\n');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Build the `/flags doctor` text: disabled (built, dark) flags + how to enable each. Pure. */
|
|
129
|
+
export function formatFlagsDoctor(entries: readonly FlagSnapshotEntry[]): string {
|
|
130
|
+
const dark = entries.filter((e) => e.state === 'disabled');
|
|
131
|
+
if (dark.length === 0) {
|
|
132
|
+
return 'Feature flag doctor: no dark subsystems — every built flag is enabled or killed.';
|
|
133
|
+
}
|
|
134
|
+
const out: string[] = [
|
|
135
|
+
`Feature flag doctor — ${dark.length} dark subsystem${dark.length === 1 ? '' : 's'} (built but disabled):`,
|
|
136
|
+
'',
|
|
137
|
+
];
|
|
138
|
+
for (const entry of dark) {
|
|
139
|
+
const { flag } = entry;
|
|
140
|
+
out.push(` ${DISABLED_MARK} ${flag.id} — ${flag.name}`);
|
|
141
|
+
out.push(` ${flag.description}`);
|
|
142
|
+
if (flag.runtimeToggleable) {
|
|
143
|
+
out.push(` enable now: /flags on ${flag.id}`);
|
|
144
|
+
} else {
|
|
145
|
+
out.push(` enable (next launch, startup-only): /flags on ${flag.id}`);
|
|
146
|
+
}
|
|
147
|
+
out.push('');
|
|
148
|
+
}
|
|
149
|
+
return out.join('\n').trimEnd();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Snapshot the manager's flags into a stable, tier-then-id-sorted list. */
|
|
153
|
+
function snapshotEntries(ctx: CommandContext): FlagSnapshotEntry[] {
|
|
154
|
+
const manager = ctx.platform.featureFlagManager;
|
|
155
|
+
if (!manager) return [];
|
|
156
|
+
return Array.from(manager.getAll().values())
|
|
157
|
+
.map(({ flag, state, persistedState, pendingRestart }) => ({ flag, state, persistedState, pendingRestart }))
|
|
158
|
+
.sort((a, b) => (a.flag.tier - b.flag.tier) || a.flag.id.localeCompare(b.flag.id));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function applyToggle(ctx: CommandContext, id: string, target: 'enabled' | 'disabled'): void {
|
|
162
|
+
const manager = ctx.platform.featureFlagManager;
|
|
163
|
+
if (!manager) {
|
|
164
|
+
ctx.print('Feature flags are not available in this runtime.');
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const entry = snapshotEntries(ctx).find((e) => e.flag.id === id);
|
|
168
|
+
if (!entry) {
|
|
169
|
+
ctx.print(`Unknown feature flag: ${id}. Run /flags to list them.`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (entry.state === target) {
|
|
173
|
+
ctx.print(`Flag ${id} is already ${target}.`);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const { flag } = entry;
|
|
177
|
+
|
|
178
|
+
// Persist the override, then apply the same value to the live manager through
|
|
179
|
+
// applyConfigState — the same consistent path the settings modal uses. It
|
|
180
|
+
// flips runtime-toggleable flags immediately and records a pending-restart
|
|
181
|
+
// marker (never a fake live flip) for startup-gated ones.
|
|
182
|
+
try {
|
|
183
|
+
persistFlagState(ctx.platform.configManager, flag.id, target, flag.defaultState);
|
|
184
|
+
manager.applyConfigState(flag.id, target);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
ctx.print(`Could not ${target === 'enabled' ? 'enable' : 'disable'} ${id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (manager.hasPendingRestart(flag.id)) {
|
|
191
|
+
ctx.print(`Flag ${id} is startup-only. Saved ${target} — it applies on next launch (currently ${manager.getState(flag.id)}).`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
ctx.print(`Flag ${id} is now ${manager.getState(flag.id)} (runtime + persisted).`);
|
|
195
|
+
ctx.renderRequest();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// /flags graduation — render flags.graduation.report (the release-readiness lane
|
|
200
|
+
// for each flag). Candidates awaiting a decision are the only release-blocking
|
|
201
|
+
// state, so they sort first; every flag shows its evidence, with "no evidence
|
|
202
|
+
// collected" stated plainly rather than implied.
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
const GRADUATION_MARK: Record<GraduationEntry['state'], string> = {
|
|
206
|
+
'graduate-candidate': '◆',
|
|
207
|
+
blocked: '✕',
|
|
208
|
+
soaking: '◐',
|
|
209
|
+
dark: '○',
|
|
210
|
+
graduated: '●',
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/** Sort rank: candidates (need a decision) first, then blocked, soaking, dark, graduated. */
|
|
214
|
+
const GRADUATION_RANK: Record<GraduationEntry['state'], number> = {
|
|
215
|
+
'graduate-candidate': 0,
|
|
216
|
+
blocked: 1,
|
|
217
|
+
soaking: 2,
|
|
218
|
+
dark: 3,
|
|
219
|
+
graduated: 4,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/** One evidence line — real divergence readings, or an explicit absence of them. */
|
|
223
|
+
function formatEvidence(entry: GraduationEntry): string {
|
|
224
|
+
const { evidence } = entry;
|
|
225
|
+
const parts = [evidence.note];
|
|
226
|
+
if (evidence.divergence) {
|
|
227
|
+
const d = evidence.divergence;
|
|
228
|
+
parts.push(`divergence ${(d.divergenceRate * 100).toFixed(2)}% over ${d.totalEvaluations} evals, gate ${d.gateStatus}`);
|
|
229
|
+
}
|
|
230
|
+
return `${parts.join(' · ')} (instrumentation: ${evidence.instrumentation})`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function formatGraduationEntry(entry: GraduationEntry): string {
|
|
234
|
+
const toggle = entry.runtimeToggleable ? 'runtime-toggleable' : 'startup-only';
|
|
235
|
+
const lines = [
|
|
236
|
+
` ${GRADUATION_MARK[entry.state]} ${entry.flagId} — ${entry.name} [tier ${entry.tier}, default ${entry.currentDefault}, ${toggle}]`,
|
|
237
|
+
` state: ${entry.state}`,
|
|
238
|
+
` evidence: ${formatEvidence(entry)}`,
|
|
239
|
+
];
|
|
240
|
+
if (entry.blocker) lines.push(` blocked ${entry.blocker.date}: ${entry.blocker.reason}`);
|
|
241
|
+
if (entry.note) lines.push(` note: ${entry.note}`);
|
|
242
|
+
return lines.join('\n');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Build the `/flags graduation` overview. Pure — exported for testing. */
|
|
246
|
+
export function formatGraduationReport(report: GraduationReport): string {
|
|
247
|
+
const { summary } = report;
|
|
248
|
+
const out: string[] = [
|
|
249
|
+
`Feature-flag graduation (${summary.total} flags) — generated ${new Date(report.generatedAt).toISOString()}`,
|
|
250
|
+
` candidates awaiting decision: ${summary.graduateCandidate} · graduated: ${summary.graduated} · soaking: ${summary.soaking} · dark: ${summary.dark} · blocked: ${summary.blocked}`,
|
|
251
|
+
'',
|
|
252
|
+
];
|
|
253
|
+
|
|
254
|
+
const sorted = [...report.entries].sort(
|
|
255
|
+
(a, b) => (GRADUATION_RANK[a.state] - GRADUATION_RANK[b.state]) || (a.tier - b.tier) || a.flagId.localeCompare(b.flagId),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
if (report.releaseBlockers.length > 0) {
|
|
259
|
+
out.push(`RELEASE BLOCKERS (${report.releaseBlockers.length}) — each must graduate (flip default ON) or record a dated blocker:`);
|
|
260
|
+
for (const entry of sorted.filter((e) => e.state === 'graduate-candidate')) out.push(formatGraduationEntry(entry));
|
|
261
|
+
out.push('');
|
|
262
|
+
} else {
|
|
263
|
+
out.push('No release blockers — nothing sits in graduate-candidate.');
|
|
264
|
+
out.push('');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const rest = sorted.filter((e) => e.state !== 'graduate-candidate');
|
|
268
|
+
if (rest.length > 0) {
|
|
269
|
+
out.push('All other flags:');
|
|
270
|
+
for (const entry of rest) out.push(formatGraduationEntry(entry));
|
|
271
|
+
}
|
|
272
|
+
return out.join('\n').trimEnd();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function runGraduationReport(ctx: CommandContext): Promise<void> {
|
|
276
|
+
const rpc = getOperatorRpc(ctx);
|
|
277
|
+
if (!rpc.available) {
|
|
278
|
+
ctx.print(`[flags graduation] ${rpc.reason}`);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
const report = await rpc.sdk.operator.invoke('flags.graduation.report', {});
|
|
283
|
+
ctx.print(formatGraduationReport(report));
|
|
284
|
+
} catch (error) {
|
|
285
|
+
ctx.print(`[flags graduation] ${describeOperatorRpcError(error)}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function registerFlagsRuntimeCommands(registry: CommandRegistry): void {
|
|
290
|
+
registry.register({
|
|
291
|
+
name: 'flags',
|
|
292
|
+
description: 'List feature flags by state, toggle runtime-toggleable ones, surface dark subsystems, and report graduation readiness',
|
|
293
|
+
usage: '[list|on <id>|off <id>|doctor|graduation]',
|
|
294
|
+
argsHint: '[on|off <id> | doctor | graduation]',
|
|
295
|
+
async handler(args, ctx) {
|
|
296
|
+
const sub = (args[0] ?? 'list').toLowerCase();
|
|
297
|
+
switch (sub) {
|
|
298
|
+
case 'list':
|
|
299
|
+
case 'ls':
|
|
300
|
+
ctx.print(formatFlagsOverview(snapshotEntries(ctx)));
|
|
301
|
+
return;
|
|
302
|
+
case 'doctor':
|
|
303
|
+
ctx.print(formatFlagsDoctor(snapshotEntries(ctx)));
|
|
304
|
+
return;
|
|
305
|
+
case 'graduation':
|
|
306
|
+
case 'grad':
|
|
307
|
+
await runGraduationReport(ctx);
|
|
308
|
+
return;
|
|
309
|
+
case 'on':
|
|
310
|
+
case 'enable': {
|
|
311
|
+
const id = args[1];
|
|
312
|
+
if (!id) { ctx.print('Usage: /flags on <id>'); return; }
|
|
313
|
+
applyToggle(ctx, id, 'enabled');
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
case 'off':
|
|
317
|
+
case 'disable': {
|
|
318
|
+
const id = args[1];
|
|
319
|
+
if (!id) { ctx.print('Usage: /flags off <id>'); return; }
|
|
320
|
+
applyToggle(ctx, id, 'disabled');
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
default:
|
|
324
|
+
ctx.print(`Unknown /flags subcommand: ${sub}. Use: list | on <id> | off <id> | doctor | graduation`);
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
}
|