@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.
@@ -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 the
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 result = (await callControlAPI(`/prompts/${encodeURIComponent(promptKey)}/propose`, { method: 'POST', body: JSON.stringify({ fromRunId, replaySetId, maxDossiers }) }, profileOptions, { timeoutMs: PROPOSE_TIMEOUT_MS, retries: 0 }));
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
- printHuman(result, outPath);
103
+ process.stderr.write(`\nGating candidate for '${promptKey}' — ${scopeNote}...\n`);
30
104
  }
31
- context.output.writeData(outPath ? { ...result, outPath } : result);
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 { InitPhaseResult, InitSetupAction } from './init/index.js';
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[]): string;
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
- phases: InitPhaseResult[];
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(`Setup complete for ${options.tenantName}`));
127
+ lines.push(ui.title(`Workspace ready: ${options.tenantName}`));
128
+ lines.push(`Dashboard: ${ui.command(dashboardUrl)}`);
123
129
  lines.push('');
124
- lines.push(ui.section('Phases'));
125
- for (const phase of options.phases) {
126
- lines.push(` ${ui.status(phase.status === 'warning' ? 'warning' : phase.status === 'skipped' ? 'skipped' : 'pass')} ${phase.title}${phase.details ? ` - ${phase.details}` : ''}`);
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 commands'));
146
- lines.push(` ${ui.command('moda status')}`);
147
- lines.push(` ${ui.command('moda doctor')}`);
148
- lines.push(` ${ui.command('moda harness sync --summary')}`);
149
- lines.push('');
150
- lines.push(ui.section('Agent handoff'));
151
- lines.push(' Integrate the Moda SDK into this project using .moda/SDK_INTEGRATION.md');
152
- lines.push(' and .moda/HARNESS.md. Package installs and source edits stay manual.');
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 words = text.split(/\s+/);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moda-ai/cli",
3
- "version": "1.17.2",
3
+ "version": "1.18.1",
4
4
  "description": "CLI for Moda - AI agent analytics and observability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": "moda.skill_index.v1",
3
- "bundled_at": "2026-07-09T02:37:26.434Z",
4
- "cli_version": "1.17.2",
3
+ "bundled_at": "2026-07-10T23:46:28.191Z",
4
+ "cli_version": "1.18.1",
5
5
  "skills": [
6
6
  {
7
7
  "id": "integration-node-anthropic",