@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.
@@ -0,0 +1,80 @@
1
+ export type ProgressLaneStatus = 'running' | 'done' | 'warning' | 'failed';
2
+ export interface ProgressLane {
3
+ id: string;
4
+ title: string;
5
+ status: ProgressLaneStatus;
6
+ /** Latest one-line detail while running; frozen summary note when terminal. */
7
+ detail?: string;
8
+ /** Duration expectation appended to long-running lanes, e.g. "usually 2-4 min". */
9
+ expectation?: string;
10
+ startedAt: number;
11
+ finishedAt?: number;
12
+ }
13
+ export interface ProgressBoardFrameOptions {
14
+ now?: number;
15
+ spinnerFrame?: string;
16
+ color?: boolean;
17
+ /** Max visible line width; longer lines are truncated so in-place rewrites never wrap. */
18
+ width?: number;
19
+ }
20
+ /**
21
+ * Pure frame renderer (unit-testable without a TTY): one line per lane, in
22
+ * insertion order. Running lanes show spinner, title, elapsed, latest detail,
23
+ * and the "(usually X min)" expectation; terminal lanes freeze to a glyph,
24
+ * title, and their summary note.
25
+ */
26
+ export declare function renderProgressBoardFrame(lanes: readonly ProgressLane[], options?: ProgressBoardFrameOptions): string[];
27
+ export interface InitProgressBoardOptions {
28
+ stream?: NodeJS.WriteStream;
29
+ /** Overrides TTY detection (in-place rendering); defaults to stream.isTTY with TERM=dumb treated as non-TTY. */
30
+ isTTY?: boolean;
31
+ color?: boolean;
32
+ now?: () => number;
33
+ renderIntervalMs?: number;
34
+ fallbackIntervalMs?: number;
35
+ }
36
+ /**
37
+ * Stateful board driver. TTY mode repaints on a ~150ms interval (unref'd so it
38
+ * never holds the process open); non-TTY mode prints plain lines on lane
39
+ * start/terminal transitions and throttles intermediate updates to one line
40
+ * per lane per 10s.
41
+ */
42
+ export declare class InitProgressBoard {
43
+ private readonly stream;
44
+ private readonly tty;
45
+ private readonly color;
46
+ private readonly now;
47
+ private readonly renderIntervalMs;
48
+ private readonly fallbackIntervalMs;
49
+ private readonly laneList;
50
+ private readonly lanesById;
51
+ private readonly lastFallbackPrint;
52
+ private timer;
53
+ private renderedLines;
54
+ private spinnerIndex;
55
+ private stopped;
56
+ constructor(options?: InitProgressBoardOptions);
57
+ get lanes(): readonly ProgressLane[];
58
+ startLane(id: string, title: string, options?: {
59
+ detail?: string;
60
+ expectation?: string;
61
+ }): void;
62
+ update(id: string, detail: string): void;
63
+ complete(id: string, note?: string): void;
64
+ warn(id: string, note?: string): void;
65
+ fail(id: string, note?: string): void;
66
+ /** Freeze the final frame (TTY) and release the repaint timer. Idempotent. */
67
+ stop(): void;
68
+ /**
69
+ * Terminal transitions are refinable: finishing an already-terminal lane
70
+ * updates its glyph/note (keeping the first finish time for elapsed), so a
71
+ * lane frozen early (e.g. "analysis finished on Moda; finalizing after
72
+ * setup") can be corrected once the final outcome is known. Exact duplicate
73
+ * finishes stay no-ops; `update()` still applies to running lanes only.
74
+ */
75
+ private finish;
76
+ private ensureTimer;
77
+ private render;
78
+ private writeLine;
79
+ }
80
+ //# sourceMappingURL=progress-board.d.ts.map
@@ -0,0 +1,225 @@
1
+ // src/init/progress-board.ts. Multi-lane live progress board for moda init
2
+ // Step 4 (Set up), where several activities run at once (hosted harness
3
+ // analysis, fast local tasks, the SDK integration agent).
4
+ //
5
+ // Hand-rolled, no dependencies, stderr only. On a TTY the lane block is
6
+ // rewritten in place with cursor-up ANSI, throttled to ~150ms; completed lanes
7
+ // freeze to a check line so the board never grows unbounded. Non-TTY human
8
+ // mode falls back to plain progress lines at most every 10s per lane.
9
+ // Agent/json modes never construct a board (init emits NDJSON progress events
10
+ // instead). Color gating follows tui.ts conventions (NO_COLOR / TERM=dumb
11
+ // respected); spinner and duration helpers follow analyst-tui.ts.
12
+ import { stripAnsi } from '../markdown-terminal.js';
13
+ const SPINNER_FRAMES = ['◐', '◓', '◑', '◒'];
14
+ const GLYPHS = {
15
+ done: '✓',
16
+ warning: '!',
17
+ failed: '✗',
18
+ };
19
+ const GLYPH_COLORS = {
20
+ done: 32,
21
+ warning: 33,
22
+ failed: 31,
23
+ };
24
+ /** Same one-line cap as ask-stream's tool cards. */
25
+ const DETAIL_MAX = 100;
26
+ const RENDER_INTERVAL_MS = 150;
27
+ const NON_TTY_LANE_INTERVAL_MS = 10_000;
28
+ /**
29
+ * Pure frame renderer (unit-testable without a TTY): one line per lane, in
30
+ * insertion order. Running lanes show spinner, title, elapsed, latest detail,
31
+ * and the "(usually X min)" expectation; terminal lanes freeze to a glyph,
32
+ * title, and their summary note.
33
+ */
34
+ export function renderProgressBoardFrame(lanes, options = {}) {
35
+ const now = options.now ?? Date.now();
36
+ const color = options.color ?? false;
37
+ const width = Math.max(48, Math.min(options.width ?? 120, 200));
38
+ const paint = (code, text) => color ? `\u001b[${code}m${text}\u001b[0m` : text;
39
+ const titleWidth = Math.min(32, lanes.reduce((max, lane) => Math.max(max, lane.title.length), 0));
40
+ return lanes.map((lane) => {
41
+ const title = lane.title.padEnd(titleWidth);
42
+ if (lane.status === 'running') {
43
+ const elapsed = formatDuration(Math.max(0, Math.round((now - lane.startedAt) / 1000)));
44
+ const detail = lane.detail ? ` ${oneLine(lane.detail)}` : '';
45
+ const expectation = lane.expectation ? ` ${paint(90, `(${lane.expectation})`)}` : '';
46
+ const glyph = paint(36, options.spinnerFrame ?? SPINNER_FRAMES[0]);
47
+ return truncateVisible(` ${glyph} ${title} ${paint(90, elapsed.padStart(6))}${detail}${expectation}`, width);
48
+ }
49
+ const glyph = paint(GLYPH_COLORS[lane.status], GLYPHS[lane.status]);
50
+ const note = lane.detail
51
+ ? oneLine(lane.detail)
52
+ : formatDuration(Math.max(0, Math.round(((lane.finishedAt ?? now) - lane.startedAt) / 1000)));
53
+ return truncateVisible(` ${glyph} ${title} ${note}`, width);
54
+ });
55
+ }
56
+ /**
57
+ * Stateful board driver. TTY mode repaints on a ~150ms interval (unref'd so it
58
+ * never holds the process open); non-TTY mode prints plain lines on lane
59
+ * start/terminal transitions and throttles intermediate updates to one line
60
+ * per lane per 10s.
61
+ */
62
+ export class InitProgressBoard {
63
+ stream;
64
+ tty;
65
+ color;
66
+ now;
67
+ renderIntervalMs;
68
+ fallbackIntervalMs;
69
+ laneList = [];
70
+ lanesById = new Map();
71
+ lastFallbackPrint = new Map();
72
+ timer;
73
+ renderedLines = 0;
74
+ spinnerIndex = 0;
75
+ stopped = false;
76
+ constructor(options = {}) {
77
+ this.stream = options.stream ?? process.stderr;
78
+ const dumbTerm = process.env.TERM === 'dumb';
79
+ this.tty = options.isTTY ?? (this.stream.isTTY === true && !dumbTerm);
80
+ // NO_COLOR disables color when set to any value, per no-color.org.
81
+ this.color = options.color ?? (this.tty && process.env.NO_COLOR === undefined);
82
+ this.now = options.now ?? Date.now;
83
+ this.renderIntervalMs = options.renderIntervalMs ?? RENDER_INTERVAL_MS;
84
+ this.fallbackIntervalMs = options.fallbackIntervalMs ?? NON_TTY_LANE_INTERVAL_MS;
85
+ }
86
+ get lanes() {
87
+ return this.laneList;
88
+ }
89
+ startLane(id, title, options = {}) {
90
+ if (this.stopped || this.lanesById.has(id))
91
+ return;
92
+ const lane = {
93
+ id,
94
+ title,
95
+ status: 'running',
96
+ detail: options.detail,
97
+ expectation: options.expectation,
98
+ startedAt: this.now(),
99
+ };
100
+ this.laneList.push(lane);
101
+ this.lanesById.set(id, lane);
102
+ if (this.tty) {
103
+ this.ensureTimer();
104
+ this.render();
105
+ }
106
+ else {
107
+ this.lastFallbackPrint.set(id, this.now());
108
+ this.writeLine(` ${title}: ${lane.detail ? oneLine(lane.detail) : 'started'}${lane.expectation ? ` (${lane.expectation})` : ''}`);
109
+ }
110
+ }
111
+ update(id, detail) {
112
+ const lane = this.lanesById.get(id);
113
+ if (!lane || this.stopped || lane.status !== 'running')
114
+ return;
115
+ lane.detail = detail;
116
+ if (this.tty)
117
+ return; // interval repaints
118
+ const last = this.lastFallbackPrint.get(id) ?? 0;
119
+ if (this.now() - last < this.fallbackIntervalMs)
120
+ return;
121
+ this.lastFallbackPrint.set(id, this.now());
122
+ const elapsed = formatDuration(Math.max(0, Math.round((this.now() - lane.startedAt) / 1000)));
123
+ this.writeLine(` ${lane.title}: ${oneLine(detail)} (${elapsed} elapsed)`);
124
+ }
125
+ complete(id, note) {
126
+ this.finish(id, 'done', note);
127
+ }
128
+ warn(id, note) {
129
+ this.finish(id, 'warning', note);
130
+ }
131
+ fail(id, note) {
132
+ this.finish(id, 'failed', note);
133
+ }
134
+ /** Freeze the final frame (TTY) and release the repaint timer. Idempotent. */
135
+ stop() {
136
+ if (this.stopped)
137
+ return;
138
+ this.stopped = true;
139
+ if (this.timer) {
140
+ clearInterval(this.timer);
141
+ this.timer = undefined;
142
+ }
143
+ if (this.tty && this.laneList.length > 0)
144
+ this.render();
145
+ }
146
+ /**
147
+ * Terminal transitions are refinable: finishing an already-terminal lane
148
+ * updates its glyph/note (keeping the first finish time for elapsed), so a
149
+ * lane frozen early (e.g. "analysis finished on Moda; finalizing after
150
+ * setup") can be corrected once the final outcome is known. Exact duplicate
151
+ * finishes stay no-ops; `update()` still applies to running lanes only.
152
+ */
153
+ finish(id, status, note) {
154
+ const lane = this.lanesById.get(id);
155
+ if (!lane || this.stopped)
156
+ return;
157
+ if (lane.status !== 'running' && lane.status === status && lane.detail === note)
158
+ return;
159
+ lane.status = status;
160
+ lane.detail = note;
161
+ lane.finishedAt ??= this.now();
162
+ if (this.tty) {
163
+ this.render();
164
+ return;
165
+ }
166
+ const label = status === 'done' ? 'done' : status === 'warning' ? 'warning' : 'failed';
167
+ this.writeLine(` ${lane.title}: ${label}${note ? ` (${oneLine(note)})` : ''}`);
168
+ }
169
+ ensureTimer() {
170
+ if (this.timer || !this.tty)
171
+ return;
172
+ this.timer = setInterval(() => this.render(), this.renderIntervalMs);
173
+ // Never keep the process alive just to animate a spinner.
174
+ this.timer.unref?.();
175
+ }
176
+ render() {
177
+ if (!this.tty || this.laneList.length === 0)
178
+ return;
179
+ const lines = renderProgressBoardFrame(this.laneList, {
180
+ now: this.now(),
181
+ spinnerFrame: SPINNER_FRAMES[this.spinnerIndex++ % SPINNER_FRAMES.length],
182
+ color: this.color,
183
+ width: this.stream.columns ?? 120,
184
+ });
185
+ const up = this.renderedLines > 0 ? `\u001b[${this.renderedLines}A` : '';
186
+ this.stream.write(`${up}${lines.map((line) => `\u001b[2K${line}`).join('\n')}\n`);
187
+ this.renderedLines = lines.length;
188
+ }
189
+ writeLine(line) {
190
+ this.stream.write(`${line}\n`);
191
+ }
192
+ }
193
+ /**
194
+ * One-line convention shared with ask-stream tool cards: strip escapes,
195
+ * collapse whitespace, cap ~100 chars. Lane details carry backend/agent
196
+ * controlled text (hosted progress messages, tool inputs like file paths and
197
+ * shell commands), so ANSI/OSC escapes and remaining C0 controls must be
198
+ * removed before the text reaches the terminal; an embedded escape would
199
+ * otherwise corrupt the in-place cursor-up rewrite or inject control output.
200
+ */
201
+ function oneLine(text) {
202
+ const collapsed = stripAnsi(text)
203
+ .replace(/[\u0000-\u001f\u007f]+/g, ' ')
204
+ .replace(/\s+/g, ' ')
205
+ .trim();
206
+ return collapsed.length > DETAIL_MAX ? `${collapsed.slice(0, DETAIL_MAX - 1)}…` : collapsed;
207
+ }
208
+ /** Same shape as analyst-tui.ts formatDuration. */
209
+ function formatDuration(seconds) {
210
+ if (seconds < 60)
211
+ return `${seconds}s`;
212
+ const minutes = Math.floor(seconds / 60);
213
+ const remaining = seconds % 60;
214
+ return `${minutes}m ${remaining}s`;
215
+ }
216
+ function truncateVisible(value, width) {
217
+ if (visibleLength(value) <= width)
218
+ return value;
219
+ const stripped = stripAnsi(value);
220
+ return `${stripped.slice(0, Math.max(0, width - 3))}...`;
221
+ }
222
+ function visibleLength(value) {
223
+ return stripAnsi(value).length;
224
+ }
225
+ //# sourceMappingURL=progress-board.js.map
@@ -1,4 +1,5 @@
1
- import { type CodingAgentAdapter } from '../harness.js';
1
+ import { type CodingAgentAdapter, type CodingAgentEvent } from '../harness.js';
2
+ export { describeCodingAgentEvent } from '../harness.js';
2
3
  import { type SdkGuideContext } from './sdk-guide.js';
3
4
  export type SdkIntegrationResult = {
4
5
  status: 'integrated';
@@ -18,6 +19,13 @@ export interface SdkIntegrationOptions {
18
19
  /** Stream the agent's progress to the user (human terminals only). */
19
20
  human?: boolean;
20
21
  onProgress?: (message: string) => void;
22
+ /**
23
+ * Receive the structured coding-agent stream events incrementally (tool use,
24
+ * commands, agent text) so callers like the init progress board can render
25
+ * their own live view. Providing it enables the streaming invocation even
26
+ * when `human` is false.
27
+ */
28
+ onEvent?: (event: CodingAgentEvent) => void;
21
29
  }
22
30
  /**
23
31
  * Pick an installed coding-agent CLI and run it, in write mode, to integrate
@@ -8,7 +8,11 @@
8
8
  // stack, materialize it into .claude/skills/<id>/, and hand the agent a thin
9
9
  // prompt that points at the skill file — the prompt no longer inlines API
10
10
  // documentation.
11
- import { codingAgentDisplayName, detectAvailableCodingAgents, runCodingAgent, } from '../harness.js';
11
+ import { codingAgentDisplayName, describeCodingAgentEvent, detectAvailableCodingAgents, runCodingAgent, } from '../harness.js';
12
+ // Re-exported for callers/tests that treat this module as the card-rendering
13
+ // surface; the implementation lives in harness.ts next to the event parser so
14
+ // the agent-stream analyst path can reuse it without an import cycle.
15
+ export { describeCodingAgentEvent } from '../harness.js';
12
16
  import { installIntegrationSkill, readBundledSkillMd } from '../skills-bundled.js';
13
17
  import { detectSdkGuideContext, renderDetectionBlock, renderSdkIntegrationGuide, stripSkillFrontmatter, } from './sdk-guide.js';
14
18
  import { resolveIntegrationSkillId } from './skill-resolver.js';
@@ -70,9 +74,15 @@ export async function runSdkIntegration(options = {}) {
70
74
  prompt,
71
75
  rootDir: cwd,
72
76
  mode: 'write',
73
- stream: human,
77
+ stream: human || Boolean(options.onEvent),
74
78
  progressToStderr: false,
75
79
  onProgress: (message) => emit(message),
80
+ onAgentEvent: (event) => {
81
+ options.onEvent?.(event);
82
+ const card = describeCodingAgentEvent(event);
83
+ if (card)
84
+ emit(card);
85
+ },
76
86
  });
77
87
  if (run.failedToStart) {
78
88
  return { status: 'failed', adapter, detail: `${codingAgentDisplayName(adapter)} CLI failed to start.` };
package/dist/manifest.js CHANGED
@@ -76,6 +76,7 @@ export function buildManifest(commands) {
76
76
  { term: 'profile', meaning: 'A named local CLI configuration under ~/.moda that selects environment, tenant, and secret references.' },
77
77
  { term: 'harness', meaning: 'Moda’s local graph of runtime agents, prompts, tools, identities, and deployments in a codebase.' },
78
78
  { term: 'harness report', meaning: 'A cited analyst artifact under .moda/ that justifies the harness graph before sync.' },
79
+ { term: 'remote analyze run', meaning: 'A Moda-hosted harness analysis tracked in .moda/harness-remote-run.json; fetch its status or result with `moda harness pull`.' },
79
80
  { term: 'conversation', meaning: 'A user-agent interaction thread stored in Moda analytics.' },
80
81
  { term: 'trace', meaning: 'Raw OTLP span evidence for one run or conversation, exposed by `moda audit`.' },
81
82
  { term: 'ingest key', meaning: 'A `moda_sk_` API key for SDKs, CI, and Data API calls. Treat it as a secret.' },
@@ -1,5 +1,8 @@
1
1
  import type { CommandContext } from './command-context.js';
2
2
  import type { ProfileIOOptions } from './profiles.js';
3
+ export declare const DEFAULT_POLL_INTERVAL_MS = 15000;
4
+ export declare const DEFAULT_WAIT_TIMEOUT_MS: number;
5
+ export declare const TERMINAL_RUN_STATUSES: Set<string>;
3
6
  export interface PromptArmPayload {
4
7
  content: string;
5
8
  prompt_version_id?: string;
@@ -15,4 +18,54 @@ export interface PromptAbResult {
15
18
  cases: Array<Record<string, unknown>>;
16
19
  }
17
20
  export declare function runPromptAb(flags: Record<string, string>, profileOptions: ProfileIOOptions, context: CommandContext): Promise<void>;
21
+ export interface ComparisonPayload {
22
+ run: {
23
+ runId: string;
24
+ status: string;
25
+ caseCount: number;
26
+ prodPassCount: number;
27
+ proposedPassCount: number;
28
+ prodPassRate: number;
29
+ proposedPassRate: number;
30
+ notes: string;
31
+ } | null;
32
+ cases: Array<{
33
+ caseId: string;
34
+ prodPass: boolean | null;
35
+ proposedPass: boolean | null;
36
+ prodScore: number | null;
37
+ proposedScore: number | null;
38
+ }>;
39
+ }
40
+ export declare function resolvePromptArmSpec(source: string, label: string, opts?: {
41
+ preferKey?: boolean;
42
+ }): PromptArmPayload;
43
+ export declare function fetchLatestRun(tenantId: string, replaySetId: string, runId: string, profileOptions: ProfileIOOptions): Promise<ComparisonPayload>;
44
+ /**
45
+ * Strict outcome of a completed comparison for gating: 'candidate' only when the
46
+ * proposed arm passed strictly more cases than prod on a completed run. A tie,
47
+ * regression, skip, error, or missing run is never a candidate win.
48
+ */
49
+ export declare function candidateOutcome(payload: ComparisonPayload): 'candidate' | 'baseline' | 'tie' | 'no-run';
50
+ export declare function formatVerdict(payload: ComparisonPayload): string;
51
+ /**
52
+ * Enqueue a prod-vs-proposed replay comparison and poll `/runs/latest` (pinned to
53
+ * OUR run id) until it reaches a terminal status or the deadline. Shared by
54
+ * `prompts ab` and the `prompts propose --gate` auto-gate. Returns the terminal
55
+ * comparison payload; throws on enqueue/poll timeout.
56
+ */
57
+ export declare function enqueueAndPollComparison(args: {
58
+ tenantId: string;
59
+ replaySetId: string;
60
+ promptArms: {
61
+ prod: PromptArmPayload;
62
+ proposed: PromptArmPayload;
63
+ };
64
+ seedsPerCase?: number;
65
+ assistantModel?: string;
66
+ promotePrimary?: boolean;
67
+ timeoutMs?: number;
68
+ pollIntervalMs?: number;
69
+ onWaiting?: (status: string) => void;
70
+ }, profileOptions: ProfileIOOptions): Promise<ComparisonPayload>;
18
71
  //# sourceMappingURL=prompts-ab.d.ts.map
@@ -4,9 +4,9 @@ import { callControlAPI, callDataAPI } from './api-client.js';
4
4
  import { resolveTenantId, validateConfig } from './config.js';
5
5
  import { discoverPrompts, parsePromptFileForAb, readPromptLock, runPromptSync, } from './prompts.js';
6
6
  const DEFAULT_SEEDS_PER_CASE = 3;
7
- const DEFAULT_POLL_INTERVAL_MS = 15_000;
8
- const DEFAULT_WAIT_TIMEOUT_MS = 2 * 60 * 60 * 1000;
9
- const TERMINAL_RUN_STATUSES = new Set(['completed', 'skipped', 'error']);
7
+ export const DEFAULT_POLL_INTERVAL_MS = 15_000;
8
+ export const DEFAULT_WAIT_TIMEOUT_MS = 2 * 60 * 60 * 1000;
9
+ export const TERMINAL_RUN_STATUSES = new Set(['completed', 'skipped', 'error']);
10
10
  export async function runPromptAb(flags, profileOptions, context) {
11
11
  validateConfig();
12
12
  const tenantId = flags['tenant-id'] || resolveTenantId(profileOptions);
@@ -81,23 +81,40 @@ export async function runPromptAb(flags, profileOptions, context) {
81
81
  }
82
82
  context.output.writeData(result);
83
83
  }
84
- function resolvePromptArmSpec(source, label) {
84
+ export function resolvePromptArmSpec(source, label, opts = {}) {
85
85
  const trimmed = source.trim();
86
86
  if (!trimmed) {
87
87
  throw new Error(`Empty prompt source for ${label}`);
88
88
  }
89
+ const fromDiscovery = () => {
90
+ const discovered = discoverPrompts();
91
+ const match = discovered.find((prompt) => prompt.key === trimmed)
92
+ ?? discovered.find((prompt) => prompt.sourcePath === trimmed);
93
+ return match ? { key: match.key, content: match.content } : null;
94
+ };
89
95
  const absPath = isAbsolute(trimmed) ? trimmed : resolve(process.cwd(), trimmed);
90
96
  let key;
91
97
  let content;
92
- if (existsSync(absPath)) {
98
+ // When the caller knows `source` is a registry key (e.g. `propose --gate`'s
99
+ // `--prompt-key`), resolve by discovery first so a same-named file/dir in the
100
+ // cwd can't shadow the intended prompt; fall back to path resolution otherwise.
101
+ const preferred = opts.preferKey ? fromDiscovery() : null;
102
+ if (preferred) {
103
+ key = preferred.key;
104
+ content = preferred.content;
105
+ }
106
+ else if (opts.preferKey) {
107
+ // Key-first resolution must NOT fall through to path resolution — otherwise a
108
+ // cwd file named like the key would still shadow an unresolved registry key.
109
+ throw new Error(`Could not resolve prompt key '${trimmed}' — no discovered prompt matched this key`);
110
+ }
111
+ else if (existsSync(absPath)) {
93
112
  const parsed = parsePromptFileForAb(absPath);
94
113
  key = parsed.key;
95
114
  content = parsed.content;
96
115
  }
97
116
  else {
98
- const discovered = discoverPrompts();
99
- const match = discovered.find((prompt) => prompt.key === trimmed)
100
- ?? discovered.find((prompt) => prompt.sourcePath === trimmed);
117
+ const match = fromDiscovery();
101
118
  if (!match) {
102
119
  throw new Error(`Could not resolve prompt '${trimmed}' — pass a .prompt.md path or a discovered prompt key`);
103
120
  }
@@ -176,13 +193,29 @@ async function loadScenarioFromConversation(conversationId) {
176
193
  }
177
194
  return `Replay conversation ${conversationId}: help the user accomplish their original goal using the available tools.`;
178
195
  }
179
- async function fetchLatestRun(tenantId, replaySetId, runId, profileOptions) {
196
+ export async function fetchLatestRun(tenantId, replaySetId, runId, profileOptions) {
180
197
  // Pin the poll to OUR run id — otherwise a concurrent comparison on the same
181
198
  // set would make `/runs/latest` return the newer run forever and this command
182
199
  // would stall until timeout even after its own run finished.
183
200
  return callControlAPI(`/tenants/${encodeURIComponent(tenantId)}/replay-sets/${encodeURIComponent(replaySetId)}/runs/latest?runId=${encodeURIComponent(runId)}`, { method: 'GET' }, profileOptions, { timeoutMs: 60_000, retries: 2 });
184
201
  }
185
- function formatVerdict(payload) {
202
+ /**
203
+ * Strict outcome of a completed comparison for gating: 'candidate' only when the
204
+ * proposed arm passed strictly more cases than prod on a completed run. A tie,
205
+ * regression, skip, error, or missing run is never a candidate win.
206
+ */
207
+ export function candidateOutcome(payload) {
208
+ const run = payload.run;
209
+ if (!run || run.status !== 'completed')
210
+ return 'no-run';
211
+ const delta = run.proposedPassCount - run.prodPassCount;
212
+ if (delta > 0)
213
+ return 'candidate';
214
+ if (delta < 0)
215
+ return 'baseline';
216
+ return 'tie';
217
+ }
218
+ export function formatVerdict(payload) {
186
219
  const run = payload.run;
187
220
  if (!run)
188
221
  return 'no run';
@@ -224,6 +257,43 @@ function formatPass(value) {
224
257
  function pct(rate) {
225
258
  return `${Math.round(Math.max(0, Math.min(1, rate)) * 100)}%`;
226
259
  }
260
+ /**
261
+ * Enqueue a prod-vs-proposed replay comparison and poll `/runs/latest` (pinned to
262
+ * OUR run id) until it reaches a terminal status or the deadline. Shared by
263
+ * `prompts ab` and the `prompts propose --gate` auto-gate. Returns the terminal
264
+ * comparison payload; throws on enqueue/poll timeout.
265
+ */
266
+ export async function enqueueAndPollComparison(args, profileOptions) {
267
+ const enqueue = (await callControlAPI(`/tenants/${encodeURIComponent(args.tenantId)}/replay-sets/${encodeURIComponent(args.replaySetId)}/run`, {
268
+ method: 'POST',
269
+ body: JSON.stringify({
270
+ promptArms: args.promptArms,
271
+ seedsPerCase: args.seedsPerCase ?? DEFAULT_SEEDS_PER_CASE,
272
+ assistantModel: args.assistantModel || undefined,
273
+ promotePrimary: args.promotePrimary ?? false,
274
+ }),
275
+ }, profileOptions, { timeoutMs: 120_000, retries: 1 }));
276
+ const timeoutMs = args.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
277
+ const pollIntervalMs = args.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
278
+ const deadline = Date.now() + timeoutMs;
279
+ let latest = null;
280
+ while (Date.now() < deadline) {
281
+ latest = await fetchLatestRun(args.tenantId, args.replaySetId, enqueue.runId, profileOptions);
282
+ const status = latest.run?.status ?? '';
283
+ const runId = latest.run?.runId ?? '';
284
+ if (TERMINAL_RUN_STATUSES.has(status) && runId === enqueue.runId) {
285
+ return latest;
286
+ }
287
+ args.onWaiting?.(status || 'pending');
288
+ await sleep(pollIntervalMs);
289
+ }
290
+ if (!latest?.run
291
+ || latest.run.runId !== enqueue.runId
292
+ || !TERMINAL_RUN_STATUSES.has(latest.run.status)) {
293
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for replay run ${enqueue.runId} to finish`);
294
+ }
295
+ return latest;
296
+ }
227
297
  function parsePositiveInt(raw, fallback, max) {
228
298
  if (!raw)
229
299
  return fallback;
@@ -2,9 +2,14 @@ import type { CommandContext } from './command-context.js';
2
2
  import type { ProfileIOOptions } from './profiles.js';
3
3
  /**
4
4
  * `moda prompts propose` — turn a `prompts ab` run's failures into a revised
5
- * candidate registered as a new (unlabeled) prompt version. Does NOT promote:
6
- * the operator gates the candidate with a fresh `prompts ab` (ideally on the
7
- * printed holdout cases the revision never saw) and promotes only if it wins.
5
+ * candidate registered as a new (unlabeled) prompt version. Does NOT promote by
6
+ * default: the operator gates the candidate with a fresh `prompts ab` (ideally on
7
+ * the printed holdout cases the revision never saw) and promotes only if it wins.
8
+ *
9
+ * `--gate` chains that gate automatically: it A/Bs the fresh candidate (proposed
10
+ * arm) vs the on-disk baseline (prod arm) over the same replay set and prints the
11
+ * verdict. `--promote-on-win` then promotes the candidate to `prod` ONLY on a
12
+ * strict candidate win. `--model=<slug>` overrides the revision model.
8
13
  */
9
14
  export declare function runPromptPropose(positionals: string[], flags: Record<string, string>, profileOptions: ProfileIOOptions, context: CommandContext): Promise<void>;
10
15
  //# sourceMappingURL=prompts-propose.d.ts.map