@moda-ai/cli 1.17.1 → 1.18.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/dist/analyst-tui.js +1 -1
- package/dist/cli.js +3 -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-propose.d.ts +10 -0
- package/dist/prompts-propose.js +76 -0
- package/dist/prompts.js +5 -1
- 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
|
@@ -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.' },
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { CommandContext } from './command-context.js';
|
|
2
|
+
import type { ProfileIOOptions } from './profiles.js';
|
|
3
|
+
/**
|
|
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.
|
|
8
|
+
*/
|
|
9
|
+
export declare function runPromptPropose(positionals: string[], flags: Record<string, string>, profileOptions: ProfileIOOptions, context: CommandContext): Promise<void>;
|
|
10
|
+
//# sourceMappingURL=prompts-propose.d.ts.map
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
3
|
+
import { callControlAPI } from './api-client.js';
|
|
4
|
+
import { validateConfig } from './config.js';
|
|
5
|
+
// Propose is a single OpenRouter revision behind the backend; give it room.
|
|
6
|
+
const PROPOSE_TIMEOUT_MS = 300_000;
|
|
7
|
+
/**
|
|
8
|
+
* `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.
|
|
12
|
+
*/
|
|
13
|
+
export async function runPromptPropose(positionals, flags, profileOptions, context) {
|
|
14
|
+
validateConfig();
|
|
15
|
+
const promptKey = flags['prompt-key'] || positionals[1];
|
|
16
|
+
const fromRunId = flags['from-run'] || flags['from-run-id'] || flags['run-id'];
|
|
17
|
+
const replaySetId = flags['set-id'] || flags['replay-set-id'];
|
|
18
|
+
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]');
|
|
20
|
+
}
|
|
21
|
+
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 }));
|
|
23
|
+
let outPath;
|
|
24
|
+
if (result.status === 'proposed' && result.content && flags.out) {
|
|
25
|
+
outPath = isAbsolute(flags.out) ? flags.out : resolve(process.cwd(), flags.out);
|
|
26
|
+
writeFileSync(outPath, result.content, 'utf8');
|
|
27
|
+
}
|
|
28
|
+
if (context.outputMode === 'human') {
|
|
29
|
+
printHuman(result, outPath);
|
|
30
|
+
}
|
|
31
|
+
context.output.writeData(outPath ? { ...result, outPath } : result);
|
|
32
|
+
}
|
|
33
|
+
function printHuman(result, outPath) {
|
|
34
|
+
const w = (s) => process.stderr.write(s + '\n');
|
|
35
|
+
if (result.status !== 'proposed') {
|
|
36
|
+
w(`No candidate produced (${result.status}): ${result.note}`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
w(`Proposed candidate for '${result.promptKey}'`);
|
|
40
|
+
w(` new version: ${result.versionId} (unlabeled — not promoted)`);
|
|
41
|
+
w(` base version: ${result.baseVersionId ?? '(none)'}`);
|
|
42
|
+
w(` from run: built from ${result.dossierCount} failure dossier(s)`);
|
|
43
|
+
if (result.changelog)
|
|
44
|
+
w(` changelog: ${result.changelog}`);
|
|
45
|
+
if (result.risks.length)
|
|
46
|
+
w(` risks: ${result.risks.map((r) => `- ${r}`).join('\n ')}`);
|
|
47
|
+
if (outPath)
|
|
48
|
+
w(` written to: ${outPath}`);
|
|
49
|
+
w('');
|
|
50
|
+
w('Next — gate on holdout cases the revision never saw, then promote if it wins:');
|
|
51
|
+
if (result.holdoutCaseIds.length) {
|
|
52
|
+
w(` holdout cases (${result.holdoutCaseIds.length}): ${result.holdoutCaseIds.join(', ')}`);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
w(' (too few failures to reserve a holdout — gate on a fresh --auto-generate set)');
|
|
56
|
+
}
|
|
57
|
+
if (outPath) {
|
|
58
|
+
// The candidate lives only in the registry as an unlabeled version; `prompts ab`
|
|
59
|
+
// resolves --candidate from local file content, so point it at the written --out
|
|
60
|
+
// file. Using --candidate-key=<key> would resolve to the baseline and compare the
|
|
61
|
+
// prompt against itself.
|
|
62
|
+
w(` moda prompts ab --baseline-key=${result.promptKey} --candidate=${outPath} --auto-generate`);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
w(` # re-run with --out=<path.prompt.md> to materialize the candidate, then:`);
|
|
66
|
+
w(` moda prompts ab --baseline-key=${result.promptKey} --candidate=<path.prompt.md> --auto-generate`);
|
|
67
|
+
}
|
|
68
|
+
w(` moda prompts promote ${result.promptKey} --label=prod --version=${result.versionId}`);
|
|
69
|
+
}
|
|
70
|
+
function parsePositiveInt(raw, fallback, max) {
|
|
71
|
+
const n = Number.parseInt(raw ?? '', 10);
|
|
72
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
73
|
+
return fallback;
|
|
74
|
+
return Math.min(n, max);
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=prompts-propose.js.map
|
package/dist/prompts.js
CHANGED
|
@@ -5,6 +5,7 @@ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
|
5
5
|
import { callControlAPI } from './api-client.js';
|
|
6
6
|
import { createCommandContext } from './command-context.js';
|
|
7
7
|
import { runPromptAb } from './prompts-ab.js';
|
|
8
|
+
import { runPromptPropose } from './prompts-propose.js';
|
|
8
9
|
const MODA_DIR = '.moda';
|
|
9
10
|
const MANIFEST_PATH = join(MODA_DIR, 'prompts.yml');
|
|
10
11
|
const LOCK_PATH = join(MODA_DIR, 'prompts.lock.json');
|
|
@@ -55,8 +56,11 @@ export async function runPromptsCommand(input, legacyFlags, legacyProfileOptions
|
|
|
55
56
|
case 'ab':
|
|
56
57
|
await runPromptAb(flags, profileOptions, context);
|
|
57
58
|
break;
|
|
59
|
+
case 'propose':
|
|
60
|
+
await runPromptPropose(positionals, flags, profileOptions, context);
|
|
61
|
+
break;
|
|
58
62
|
default:
|
|
59
|
-
throw new Error(`Unknown prompts command '${subcommand}'. Use init, status, diff, sync, promote, or
|
|
63
|
+
throw new Error(`Unknown prompts command '${subcommand}'. Use init, status, diff, sync, promote, ab, or propose.`);
|
|
60
64
|
}
|
|
61
65
|
}
|
|
62
66
|
export async function initPrompts(options = {}) {
|
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