@moda-ai/cli 1.17.2 → 1.18.1
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/dist/analyst-tui.js +1 -1
- package/dist/cli.js +4 -0
- package/dist/harness-remote.d.ts +102 -0
- package/dist/harness-remote.js +314 -52
- package/dist/harness.d.ts +35 -0
- package/dist/harness.js +140 -22
- package/dist/init/index.js +469 -185
- package/dist/init/progress-board.d.ts +80 -0
- package/dist/init/progress-board.js +225 -0
- package/dist/init/sdk-integration.d.ts +9 -1
- package/dist/init/sdk-integration.js +12 -2
- package/dist/manifest.js +1 -0
- package/dist/prompts-ab.d.ts +53 -0
- package/dist/prompts-ab.js +80 -10
- package/dist/prompts-propose.d.ts +8 -3
- package/dist/prompts-propose.js +145 -9
- package/dist/schemas.d.ts +2 -2
- package/dist/tui.d.ts +12 -4
- package/dist/tui.js +24 -17
- package/package.json +1 -1
- package/skills/integration/index.json +2 -2
package/dist/prompts-propose.js
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { writeFileSync } from 'node:fs';
|
|
2
2
|
import { isAbsolute, resolve } from 'node:path';
|
|
3
3
|
import { callControlAPI } from './api-client.js';
|
|
4
|
-
import { validateConfig } from './config.js';
|
|
4
|
+
import { resolveTenantId, validateConfig } from './config.js';
|
|
5
|
+
import { candidateOutcome, enqueueAndPollComparison, formatVerdict, resolvePromptArmSpec, } from './prompts-ab.js';
|
|
5
6
|
// Propose is a single OpenRouter revision behind the backend; give it room.
|
|
6
7
|
const PROPOSE_TIMEOUT_MS = 300_000;
|
|
7
8
|
/**
|
|
8
9
|
* `moda prompts propose` — turn a `prompts ab` run's failures into a revised
|
|
9
|
-
* candidate registered as a new (unlabeled) prompt version. Does NOT promote
|
|
10
|
-
* the operator gates the candidate with a fresh `prompts ab` (ideally on
|
|
11
|
-
* printed holdout cases the revision never saw) and promotes only if it wins.
|
|
10
|
+
* candidate registered as a new (unlabeled) prompt version. Does NOT promote by
|
|
11
|
+
* default: the operator gates the candidate with a fresh `prompts ab` (ideally on
|
|
12
|
+
* the printed holdout cases the revision never saw) and promotes only if it wins.
|
|
13
|
+
*
|
|
14
|
+
* `--gate` chains that gate automatically: it A/Bs the fresh candidate (proposed
|
|
15
|
+
* arm) vs the on-disk baseline (prod arm) over the same replay set and prints the
|
|
16
|
+
* verdict. `--promote-on-win` then promotes the candidate to `prod` ONLY on a
|
|
17
|
+
* strict candidate win. `--model=<slug>` overrides the revision model.
|
|
12
18
|
*/
|
|
13
19
|
export async function runPromptPropose(positionals, flags, profileOptions, context) {
|
|
14
20
|
validateConfig();
|
|
@@ -16,21 +22,124 @@ export async function runPromptPropose(positionals, flags, profileOptions, conte
|
|
|
16
22
|
const fromRunId = flags['from-run'] || flags['from-run-id'] || flags['run-id'];
|
|
17
23
|
const replaySetId = flags['set-id'] || flags['replay-set-id'];
|
|
18
24
|
if (!promptKey || !fromRunId || !replaySetId) {
|
|
19
|
-
throw new Error('Usage: moda prompts propose --prompt-key=<key> --from-run=<run_id> --set-id=<replay_set_id> [--max-dossiers=8] [--out=path.prompt.md]');
|
|
25
|
+
throw new Error('Usage: moda prompts propose --prompt-key=<key> --from-run=<run_id> --set-id=<replay_set_id> [--max-dossiers=8] [--model=<slug>] [--out=path.prompt.md] [--gate] [--promote-on-win]');
|
|
20
26
|
}
|
|
21
27
|
const maxDossiers = parsePositiveInt(flags['max-dossiers'], 8, 16);
|
|
22
|
-
const
|
|
28
|
+
const model = (flags.model ?? flags['revise-model'] ?? '').trim();
|
|
29
|
+
const result = (await callControlAPI(`/prompts/${encodeURIComponent(promptKey)}/propose`, {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
body: JSON.stringify({ fromRunId, replaySetId, maxDossiers, model: model || undefined }),
|
|
32
|
+
}, profileOptions, { timeoutMs: PROPOSE_TIMEOUT_MS, retries: 0 }));
|
|
23
33
|
let outPath;
|
|
24
34
|
if (result.status === 'proposed' && result.content && flags.out) {
|
|
25
35
|
outPath = isAbsolute(flags.out) ? flags.out : resolve(process.cwd(), flags.out);
|
|
26
36
|
writeFileSync(outPath, result.content, 'utf8');
|
|
27
37
|
}
|
|
38
|
+
// The gate needs the candidate content in-hand for the proposed arm.
|
|
39
|
+
const wantGate = flags.gate === 'true';
|
|
40
|
+
const promoteOnWin = flags['promote-on-win'] === 'true';
|
|
41
|
+
let gate;
|
|
42
|
+
if (wantGate) {
|
|
43
|
+
if (result.status !== 'proposed' || !result.content) {
|
|
44
|
+
gate = {
|
|
45
|
+
ran: false,
|
|
46
|
+
scope: 'full-set',
|
|
47
|
+
scopeNote: `no candidate to gate (${result.status})`,
|
|
48
|
+
verdict: 'skipped',
|
|
49
|
+
outcome: 'no-run',
|
|
50
|
+
runId: null,
|
|
51
|
+
promoted: false,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
gate = await runGate({
|
|
56
|
+
result,
|
|
57
|
+
promptKey,
|
|
58
|
+
replaySetId,
|
|
59
|
+
flags,
|
|
60
|
+
promoteOnWin,
|
|
61
|
+
profileOptions,
|
|
62
|
+
context,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (context.outputMode === 'human') {
|
|
67
|
+
printHuman(result, outPath, gate);
|
|
68
|
+
}
|
|
69
|
+
context.output.writeData({
|
|
70
|
+
...result,
|
|
71
|
+
...(outPath ? { outPath } : {}),
|
|
72
|
+
...(gate ? { gate } : {}),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
async function runGate(args) {
|
|
76
|
+
const { result, promptKey, replaySetId, flags, promoteOnWin, profileOptions, context } = args;
|
|
77
|
+
const tenantId = flags['tenant-id'] || resolveTenantId(profileOptions);
|
|
78
|
+
if (!tenantId) {
|
|
79
|
+
throw new Error('Missing tenant id for --gate. Run `moda init` or pass --tenant-id=<id>.');
|
|
80
|
+
}
|
|
81
|
+
// prod arm = the on-disk/registry baseline. `promptKey` is always a registry
|
|
82
|
+
// key here, so resolve key-first (`preferKey`) — otherwise a same-named file or
|
|
83
|
+
// dir in the cwd could shadow it and gate the candidate against wrong content.
|
|
84
|
+
// proposed arm = the freshly proposed candidate content returned above.
|
|
85
|
+
const prodArm = resolvePromptArmSpec(promptKey, 'baseline', { preferKey: true });
|
|
86
|
+
const proposedArm = {
|
|
87
|
+
content: result.content ?? '',
|
|
88
|
+
prompt_version_id: result.versionId ?? '',
|
|
89
|
+
prompt_id: '',
|
|
90
|
+
prompt_label: 'candidate',
|
|
91
|
+
};
|
|
92
|
+
const seedsPerCase = parsePositiveInt(flags.seeds ?? flags['seeds-per-case'], 3, 10);
|
|
93
|
+
const assistantModel = (flags['assistant-model'] ?? '').trim();
|
|
94
|
+
const timeoutMs = parsePositiveInt(flags['gate-timeout'] ?? flags.timeout, 2 * 60 * 60 * 1000, 24 * 60 * 60 * 1000);
|
|
95
|
+
const pollIntervalMs = parsePositiveInt(flags['poll-interval'], 15_000, 120_000);
|
|
96
|
+
// The /run endpoint scores the WHOLE replay set — there is no per-case subset
|
|
97
|
+
// knob — so the gate cannot be scoped to just the holdout ids the revision
|
|
98
|
+
// never saw. Say so plainly so a win isn't read as overfitting-proof.
|
|
99
|
+
const scopeNote = result.holdoutCaseIds.length
|
|
100
|
+
? `gated over the FULL replay set (${result.holdoutCaseIds.length} holdout case(s) could not be isolated — the run endpoint scores every case)`
|
|
101
|
+
: 'gated over the FULL replay set';
|
|
28
102
|
if (context.outputMode === 'human') {
|
|
29
|
-
|
|
103
|
+
process.stderr.write(`\nGating candidate for '${promptKey}' — ${scopeNote}...\n`);
|
|
30
104
|
}
|
|
31
|
-
|
|
105
|
+
const payload = await enqueueAndPollComparison({
|
|
106
|
+
tenantId,
|
|
107
|
+
replaySetId,
|
|
108
|
+
promptArms: { prod: prodArm, proposed: proposedArm },
|
|
109
|
+
seedsPerCase,
|
|
110
|
+
assistantModel: assistantModel || undefined,
|
|
111
|
+
promotePrimary: false,
|
|
112
|
+
timeoutMs,
|
|
113
|
+
pollIntervalMs,
|
|
114
|
+
onWaiting: context.outputMode === 'human'
|
|
115
|
+
? (status) => process.stderr.write(`Waiting for gate run (${status})...\n`)
|
|
116
|
+
: undefined,
|
|
117
|
+
}, profileOptions);
|
|
118
|
+
const outcome = candidateOutcome(payload);
|
|
119
|
+
const gate = {
|
|
120
|
+
ran: true,
|
|
121
|
+
scope: 'full-set',
|
|
122
|
+
scopeNote,
|
|
123
|
+
verdict: formatVerdict(payload),
|
|
124
|
+
outcome,
|
|
125
|
+
runId: payload.run?.runId ?? null,
|
|
126
|
+
promoted: false,
|
|
127
|
+
};
|
|
128
|
+
if (promoteOnWin) {
|
|
129
|
+
if (outcome === 'candidate' && result.versionId) {
|
|
130
|
+
await callControlAPI(`/prompts/${encodeURIComponent(promptKey)}/labels`, { method: 'POST', body: JSON.stringify({ label: 'prod', versionId: result.versionId }) }, profileOptions);
|
|
131
|
+
gate.promoted = true;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
gate.promoteSkippedReason =
|
|
135
|
+
outcome === 'candidate'
|
|
136
|
+
? 'no candidate versionId to promote'
|
|
137
|
+
: `candidate did not strictly win (${outcome})`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return gate;
|
|
32
141
|
}
|
|
33
|
-
function printHuman(result, outPath) {
|
|
142
|
+
function printHuman(result, outPath, gate) {
|
|
34
143
|
const w = (s) => process.stderr.write(s + '\n');
|
|
35
144
|
if (result.status !== 'proposed') {
|
|
36
145
|
w(`No candidate produced (${result.status}): ${result.note}`);
|
|
@@ -46,7 +155,33 @@ function printHuman(result, outPath) {
|
|
|
46
155
|
w(` risks: ${result.risks.map((r) => `- ${r}`).join('\n ')}`);
|
|
47
156
|
if (outPath)
|
|
48
157
|
w(` written to: ${outPath}`);
|
|
158
|
+
if (result.lowN) {
|
|
159
|
+
w('');
|
|
160
|
+
w(` ⚠ low sample: ${result.lowN}`);
|
|
161
|
+
}
|
|
49
162
|
w('');
|
|
163
|
+
if (gate?.ran) {
|
|
164
|
+
w('Gate (automatic A/B of candidate vs baseline):');
|
|
165
|
+
w(` scope: ${gate.scopeNote}`);
|
|
166
|
+
w(` verdict: ${gate.verdict}`);
|
|
167
|
+
if (gate.runId)
|
|
168
|
+
w(` run id: ${gate.runId}`);
|
|
169
|
+
if (gate.promoted) {
|
|
170
|
+
w(` promoted: candidate won — promoted version ${result.versionId} to label 'prod'`);
|
|
171
|
+
}
|
|
172
|
+
else if (gate.promoteSkippedReason) {
|
|
173
|
+
w(` promote: skipped (${gate.promoteSkippedReason})`);
|
|
174
|
+
}
|
|
175
|
+
w('');
|
|
176
|
+
if (!gate.promoted) {
|
|
177
|
+
w(` moda prompts promote ${result.promptKey} --label=prod --version=${result.versionId}`);
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (gate && !gate.ran) {
|
|
182
|
+
w(`Gate not run: ${gate.scopeNote}`);
|
|
183
|
+
w('');
|
|
184
|
+
}
|
|
50
185
|
w('Next — gate on holdout cases the revision never saw, then promote if it wins:');
|
|
51
186
|
if (result.holdoutCaseIds.length) {
|
|
52
187
|
w(` holdout cases (${result.holdoutCaseIds.length}): ${result.holdoutCaseIds.join(', ')}`);
|
|
@@ -65,6 +200,7 @@ function printHuman(result, outPath) {
|
|
|
65
200
|
w(` # re-run with --out=<path.prompt.md> to materialize the candidate, then:`);
|
|
66
201
|
w(` moda prompts ab --baseline-key=${result.promptKey} --candidate=<path.prompt.md> --auto-generate`);
|
|
67
202
|
}
|
|
203
|
+
w(` # or: moda prompts propose ... --gate --promote-on-win (auto-A/B, promote on a strict win)`);
|
|
68
204
|
w(` moda prompts promote ${result.promptKey} --label=prod --version=${result.versionId}`);
|
|
69
205
|
}
|
|
70
206
|
function parsePositiveInt(raw, fallback, max) {
|
package/dist/schemas.d.ts
CHANGED
|
@@ -43,24 +43,24 @@ export declare const ConversationsSchema: z.ZodObject<{
|
|
|
43
43
|
}, "strip", z.ZodTypeAny, {
|
|
44
44
|
search?: string | undefined;
|
|
45
45
|
environment?: "staging" | "all" | "development" | "production" | undefined;
|
|
46
|
+
outcome?: "any" | "positive" | "negative" | undefined;
|
|
46
47
|
time_range?: "all" | "1h" | "3d" | "7d" | "24h" | "30d" | "90d" | undefined;
|
|
47
48
|
limit?: number | undefined;
|
|
48
49
|
offset?: number | undefined;
|
|
49
50
|
cluster_id?: string | undefined;
|
|
50
51
|
user_id?: string | undefined;
|
|
51
52
|
world_state?: string | undefined;
|
|
52
|
-
outcome?: "any" | "positive" | "negative" | undefined;
|
|
53
53
|
include_world_state?: boolean | undefined;
|
|
54
54
|
}, {
|
|
55
55
|
search?: string | undefined;
|
|
56
56
|
environment?: "staging" | "all" | "development" | "production" | undefined;
|
|
57
|
+
outcome?: "any" | "positive" | "negative" | undefined;
|
|
57
58
|
time_range?: "all" | "1h" | "3d" | "7d" | "24h" | "30d" | "90d" | undefined;
|
|
58
59
|
limit?: number | undefined;
|
|
59
60
|
offset?: number | undefined;
|
|
60
61
|
cluster_id?: string | undefined;
|
|
61
62
|
user_id?: string | undefined;
|
|
62
63
|
world_state?: string | undefined;
|
|
63
|
-
outcome?: "any" | "positive" | "negative" | undefined;
|
|
64
64
|
include_world_state?: unknown;
|
|
65
65
|
}>;
|
|
66
66
|
/** moda world-state <conversationId> — per-conversation world-state drill-in. */
|
package/dist/tui.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DoctorCheckStatus, DoctorReport, StatusPayload } from './doctor.js';
|
|
2
|
-
import type {
|
|
2
|
+
import type { InitSetupAction } from './init/index.js';
|
|
3
3
|
type TerminalBadge = DoctorCheckStatus | 'info' | 'run';
|
|
4
4
|
export declare function renderDoctorHuman(report: DoctorReport, options?: {
|
|
5
5
|
color?: boolean;
|
|
@@ -11,13 +11,21 @@ export declare function renderStatusHuman(status: StatusPayload, options?: {
|
|
|
11
11
|
}): string;
|
|
12
12
|
export declare function renderInitBanner(): string;
|
|
13
13
|
export declare function renderInitStep(index: number, total: number, title: string, detail?: string): string;
|
|
14
|
-
export declare function renderInitNote(message: string, status?: DoctorCheckStatus): string;
|
|
15
|
-
export declare function renderInitSetupPlan(actions: InitSetupAction[]
|
|
14
|
+
export declare function renderInitNote(message: string, status?: DoctorCheckStatus | 'info'): string;
|
|
15
|
+
export declare function renderInitSetupPlan(actions: InitSetupAction[], options?: {
|
|
16
|
+
snapshotConsent?: boolean;
|
|
17
|
+
}): string;
|
|
16
18
|
export declare function renderInitComplete(options: {
|
|
17
19
|
tenantName: string;
|
|
18
20
|
filesChanged: string[];
|
|
19
21
|
skipped: InitSetupAction[];
|
|
20
|
-
|
|
22
|
+
dashboardUrl?: string;
|
|
23
|
+
harness?: {
|
|
24
|
+
state: 'mapped' | 'pending';
|
|
25
|
+
agents?: number;
|
|
26
|
+
artifacts?: number;
|
|
27
|
+
};
|
|
28
|
+
telemetrySeen?: boolean;
|
|
21
29
|
}): string;
|
|
22
30
|
export declare function renderHarnessProgress(message: string, status?: TerminalBadge): string;
|
|
23
31
|
export declare function renderHarnessAnalystPreflight(options: {
|
package/dist/tui.js
CHANGED
|
@@ -102,7 +102,7 @@ export function renderInitNote(message, status = 'pass') {
|
|
|
102
102
|
const ui = createTerminalUi();
|
|
103
103
|
return ` ${ui.status(status)} ${message}`;
|
|
104
104
|
}
|
|
105
|
-
export function renderInitSetupPlan(actions) {
|
|
105
|
+
export function renderInitSetupPlan(actions, options = {}) {
|
|
106
106
|
const ui = createTerminalUi();
|
|
107
107
|
const lines = actions.map((action) => {
|
|
108
108
|
const status = action.approved
|
|
@@ -113,17 +113,25 @@ export function renderInitSetupPlan(actions) {
|
|
|
113
113
|
const suffix = action.reason ? ` - ${action.reason}` : '';
|
|
114
114
|
return ui.wrap(` ${ui.status(status)} ${action.title}${suffix}`, 4);
|
|
115
115
|
});
|
|
116
|
+
if (options.snapshotConsent) {
|
|
117
|
+
lines.push('');
|
|
118
|
+
lines.push(ui.wrap(` ${ui.status('info')} Moda will analyze a snapshot of this repo on Moda's servers to map your agent harness. Secrets and .env files are excluded and the snapshot is deleted after the run.`, 4));
|
|
119
|
+
}
|
|
116
120
|
return lines.join('\n');
|
|
117
121
|
}
|
|
118
122
|
export function renderInitComplete(options) {
|
|
119
123
|
const ui = createTerminalUi();
|
|
124
|
+
const dashboardUrl = options.dashboardUrl ?? 'https://moda.dev';
|
|
120
125
|
const lines = [];
|
|
121
126
|
lines.push('');
|
|
122
|
-
lines.push(ui.title(`
|
|
127
|
+
lines.push(ui.title(`Workspace ready: ${options.tenantName}`));
|
|
128
|
+
lines.push(`Dashboard: ${ui.command(dashboardUrl)}`);
|
|
123
129
|
lines.push('');
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
130
|
+
if (options.harness?.state === 'pending') {
|
|
131
|
+
lines.push(ui.wrap(` ${ui.status('run')} Harness analysis is still running on Moda. Run ${ui.command('moda harness pull --sync')} in a few minutes.`, 4));
|
|
132
|
+
}
|
|
133
|
+
else if (options.harness) {
|
|
134
|
+
lines.push(ui.wrap(` ${ui.status('pass')} Harness mapped: ${options.harness.agents ?? 0} agent(s), ${options.harness.artifacts ?? 0} artifact(s). View: ${ui.command(dashboardUrl)}`, 4));
|
|
127
135
|
}
|
|
128
136
|
lines.push('');
|
|
129
137
|
lines.push(ui.section('Files changed'));
|
|
@@ -142,16 +150,14 @@ export function renderInitComplete(options) {
|
|
|
142
150
|
}
|
|
143
151
|
}
|
|
144
152
|
lines.push('');
|
|
145
|
-
lines.push(ui.section('Next
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
lines.push(
|
|
153
|
-
lines.push('');
|
|
154
|
-
lines.push('Dashboard: https://moda.dev');
|
|
153
|
+
lines.push(ui.section('Next steps'));
|
|
154
|
+
if (options.telemetrySeen === true) {
|
|
155
|
+
lines.push(` Telemetry is flowing. Explore it: ${ui.command('moda overview')}`);
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
lines.push(` Run your app to send telemetry, then: ${ui.command('moda doctor --online')}`);
|
|
159
|
+
}
|
|
160
|
+
lines.push(` Ask about your agents: ${ui.command('moda ask "what did my agents do today?"')}`);
|
|
155
161
|
return lines.join('\n');
|
|
156
162
|
}
|
|
157
163
|
export function renderHarnessProgress(message, status = 'run') {
|
|
@@ -366,11 +372,12 @@ function createTerminalUi(options = {}) {
|
|
|
366
372
|
function wrapLine(text, width, indent) {
|
|
367
373
|
if (text.length <= width)
|
|
368
374
|
return text;
|
|
369
|
-
const
|
|
375
|
+
const lead = text.match(/^\s*/)?.[0] ?? '';
|
|
376
|
+
const words = text.trimStart().split(/\s+/);
|
|
370
377
|
const lines = [];
|
|
371
378
|
let current = '';
|
|
372
379
|
for (const word of words) {
|
|
373
|
-
const next = current ? `${current} ${word}` : word
|
|
380
|
+
const next = current ? `${current} ${word}` : `${lead}${word}`;
|
|
374
381
|
if (next.length > width && current) {
|
|
375
382
|
lines.push(current);
|
|
376
383
|
current = `${' '.repeat(indent)}${word}`;
|
package/package.json
CHANGED