@moda-ai/cli 1.12.0 → 1.12.2

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/cli.js CHANGED
@@ -219,6 +219,9 @@ Init flags:
219
219
  --yes Accept all defaults (non-interactive)
220
220
  --analyst=auto|claude|codex|cursor|local-scan
221
221
  Choose harness analyst adapter
222
+ --remote Run harness analysis server-side (Moda-hosted):
223
+ upload a safe source snapshot; no local agent
224
+ (also MODA_HARNESS_REMOTE=1)
222
225
  --tui Show full-screen analyst dashboard in human TTYs
223
226
  --no-tui Show raw external analyst output
224
227
  --sdk-integrate Run your coding agent to install the Moda SDK and instrument LLM calls
@@ -238,6 +241,7 @@ Examples:
238
241
  moda profiles use staging
239
242
  moda config show --profile=prod
240
243
  moda harness analyze --summary
244
+ moda harness analyze --remote
241
245
  moda harness analyze --analyst=claude --tui
242
246
  moda harness analyze --analyst=claude --no-tui
243
247
  moda harness analyze --analyst=claude --summary
@@ -829,6 +833,7 @@ export const commandRegistry = createCommandRegistry([
829
833
  description: 'Analyze, validate, approve, sync, and explain the local multi-agent harness graph',
830
834
  examples: [
831
835
  'moda harness analyze --summary',
836
+ 'moda harness analyze --remote',
832
837
  'moda harness approve --yes',
833
838
  'moda harness sync --from-report .moda/harness-report.json',
834
839
  'moda harness explain --agent=agent_support',
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Server-side ("Moda-hosted") harness analyze client.
3
+ *
4
+ * `moda harness analyze --remote` builds a safe source snapshot with
5
+ * `buildSourceSnapshot` (harness.ts), uploads it to the ingest worker
6
+ * (`POST /v1/harness/analyze`), polls `GET /v1/harness/analyze/:id` for the
7
+ * validated report, then writes the same local artifacts and emits the same
8
+ * lifecycle events as the local analyze paths.
9
+ */
10
+ import type { CommandContext } from './command-context.js';
11
+ import { type HarnessCommandScanPlan, type HarnessTenantContext } from './harness.js';
12
+ /**
13
+ * True when `moda harness analyze` should run server-side (Moda-hosted) instead
14
+ * of spawning a local coding agent: `--remote` (flags.remote === 'true') or the
15
+ * `MODA_HARNESS_REMOTE` env opt-in.
16
+ */
17
+ export declare function isRemoteHarnessAnalyze(context: CommandContext): boolean;
18
+ /**
19
+ * Server-side ("Moda-hosted") harness analysis. Builds a safe source snapshot,
20
+ * uploads it to the ingest worker, polls for the validated report, then writes
21
+ * the same local artifacts the local analyze path writes. Emits the same
22
+ * lifecycle events so `--agent --stream` behaves consistently with the local
23
+ * path.
24
+ */
25
+ export declare function runRemoteHarnessAnalyst(options: {
26
+ context: CommandContext;
27
+ rootDir: string;
28
+ scanPlan: HarnessCommandScanPlan;
29
+ tenant?: HarnessTenantContext;
30
+ }): Promise<{
31
+ exitCode: number;
32
+ }>;
33
+ //# sourceMappingURL=harness-remote.d.ts.map
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Server-side ("Moda-hosted") harness analyze client.
3
+ *
4
+ * `moda harness analyze --remote` builds a safe source snapshot with
5
+ * `buildSourceSnapshot` (harness.ts), uploads it to the ingest worker
6
+ * (`POST /v1/harness/analyze`), polls `GET /v1/harness/analyze/:id` for the
7
+ * validated report, then writes the same local artifacts and emits the same
8
+ * lifecycle events as the local analyze paths.
9
+ */
10
+ import { resolveApiKey, resolveIngestUrl } from './config.js';
11
+ import { CliInputError } from './errors.js';
12
+ import { normalizeHarnessReport, validateHarnessReport, writeHarnessReportArtifacts, } from './harness-report.js';
13
+ import { buildSourceSnapshot, emitWrittenArtifacts, numericEnv, numericEnvAllowZero, repairExternalAnalystReport, sleep, summarizeHarness, summarizeInvalidAnalystReport, truncateText, writeHarnessAnalyzeResult, writeHarnessArtifacts, writeHumanProgress, } from './harness.js';
14
+ const DEFAULT_REMOTE_ANALYZE_TIMEOUT_MS = 900_000;
15
+ const DEFAULT_REMOTE_POLL_INITIAL_MS = 3_000;
16
+ const DEFAULT_REMOTE_POLL_MAX_MS = 15_000;
17
+ /** Lower bound on the inter-poll delay so a 0 interval can't busy-loop the status endpoint. */
18
+ const MIN_REMOTE_POLL_DELAY_MS = 250;
19
+ const REMOTE_UPLOAD_TIMEOUT_MS = 120_000;
20
+ const REMOTE_STATUS_TIMEOUT_MS = 30_000;
21
+ /**
22
+ * True when `moda harness analyze` should run server-side (Moda-hosted) instead
23
+ * of spawning a local coding agent: `--remote` (flags.remote === 'true') or the
24
+ * `MODA_HARNESS_REMOTE` env opt-in.
25
+ */
26
+ export function isRemoteHarnessAnalyze(context) {
27
+ // The CLI normalizes numeric flag values to numbers, so `--remote=1` arrives
28
+ // as the number 1 rather than the string '1'; accept every truthy spelling.
29
+ const flag = context.flags.remote;
30
+ if (flag === true || flag === 1 || flag === 'true' || flag === '1' || flag === 'yes')
31
+ return true;
32
+ const env = (context.env.MODA_HARNESS_REMOTE ?? '').trim().toLowerCase();
33
+ return env !== '' && env !== '0' && env !== 'false';
34
+ }
35
+ /**
36
+ * Server-side ("Moda-hosted") harness analysis. Builds a safe source snapshot,
37
+ * uploads it to the ingest worker, polls for the validated report, then writes
38
+ * the same local artifacts the local analyze path writes. Emits the same
39
+ * lifecycle events so `--agent --stream` behaves consistently with the local
40
+ * path.
41
+ */
42
+ export async function runRemoteHarnessAnalyst(options) {
43
+ const { context, rootDir } = options;
44
+ if (context.dryRun) {
45
+ throw new CliInputError('Remote harness analysis cannot run in dry-run mode.', 'Use `moda harness analyze --analyst=local-scan --dry-run` for deterministic local inspection.');
46
+ }
47
+ const apiKey = resolveApiKey({ profile: context.profile, env: context.env });
48
+ if (!apiKey) {
49
+ throw new CliInputError('No Moda API key found for remote harness analysis.', 'Run `moda init` first (or set MODA_API_KEY), then rerun `moda harness analyze --remote`.');
50
+ }
51
+ const ingestUrl = resolveIngestUrl({ profile: context.profile, env: context.env }).replace(/\/+$/, '');
52
+ context.output.writeEvent({
53
+ event: 'progress',
54
+ phase: 'inventory',
55
+ message: 'Inventorying local harness files for upload.',
56
+ elapsed_ms: Date.now() - context.startedAt,
57
+ });
58
+ writeHumanProgress(context, 'Moda Analyst (remote): inventorying local harness files...');
59
+ const snapshot = buildSourceSnapshot(rootDir);
60
+ context.output.writeEvent({
61
+ event: 'progress',
62
+ phase: 'remote_upload',
63
+ message: `Uploading ${snapshot.fileCount} file(s) (${snapshot.byteCount} bytes) to Moda for server-side analysis.`,
64
+ elapsed_ms: Date.now() - context.startedAt,
65
+ });
66
+ writeHumanProgress(context, `Moda Analyst (remote): uploading ${snapshot.fileCount} file(s) for server-side analysis...`);
67
+ const accepted = await postRemoteAnalyze(ingestUrl, apiKey, {
68
+ snapshot,
69
+ options: buildRemoteAnalyzeOptions(context.flags),
70
+ });
71
+ const runId = accepted.analysis_run_id;
72
+ context.output.writeEvent({
73
+ event: 'progress',
74
+ phase: 'remote_wait',
75
+ message: `Moda accepted analysis run ${runId}; waiting for the server-side agent.`,
76
+ elapsed_ms: Date.now() - context.startedAt,
77
+ });
78
+ writeHumanProgress(context, `Moda Analyst (remote): analysis run ${runId} accepted; waiting for results...`);
79
+ const final = await pollRemoteAnalyze({ ingestUrl, apiKey, runId, context });
80
+ if (final.status !== 'completed' || !final.report) {
81
+ const detail = final.status === 'failed'
82
+ ? `Remote harness analysis failed: ${final.error ?? 'unknown error'}.`
83
+ : `Remote harness analysis did not finish before the timeout (last status: ${final.status}).`;
84
+ throw new CliInputError(detail, [
85
+ `Retry \`moda harness analyze --remote\`${runId ? ` (run ${runId})` : ''}.`,
86
+ 'Or run `moda harness analyze --analyst=local-scan` to analyze locally.',
87
+ 'Increase MODA_HARNESS_REMOTE_TIMEOUT_MS if large repos need longer.',
88
+ ].join('\n'));
89
+ }
90
+ const report = repairExternalAnalystReport(normalizeHarnessReport(final.report), rootDir);
91
+ const validation = validateHarnessReport(report, rootDir);
92
+ context.output.writeEvent({
93
+ event: 'progress',
94
+ phase: 'validation',
95
+ message: `Harness report validation ${validation.status}.`,
96
+ elapsed_ms: Date.now() - context.startedAt,
97
+ });
98
+ writeHumanProgress(context, `Moda Analyst (remote): validation ${validation.status}.`);
99
+ const written = [
100
+ ...writeHarnessReportArtifacts({
101
+ rootDir,
102
+ report,
103
+ validation,
104
+ markdown: context.flags.summary === 'true' || context.outputMode === 'human',
105
+ }),
106
+ ];
107
+ if (validation.status === 'pass') {
108
+ written.push(...writeHarnessArtifacts(report.graph, rootDir, {
109
+ summary: context.flags.summary === 'true',
110
+ }));
111
+ }
112
+ emitWrittenArtifacts(context, written, 'Written by Moda Analyst (remote).');
113
+ writeHarnessAnalyzeResult(context, {
114
+ scanPlan: options.scanPlan,
115
+ summary: validation.status === 'pass'
116
+ ? summarizeHarness(report.graph)
117
+ : summarizeInvalidAnalystReport(report),
118
+ report,
119
+ validation,
120
+ written,
121
+ dryRun: false,
122
+ });
123
+ return { exitCode: validation.status === 'pass' ? 0 : 1 };
124
+ }
125
+ /** Map CLI flags to the optional server-side analyze options in the wire contract. */
126
+ function buildRemoteAnalyzeOptions(flags) {
127
+ const options = {};
128
+ const analyst = flags.analyst;
129
+ if (analyst === 'claude' || analyst === 'codex' || analyst === 'cursor' || analyst === 'auto') {
130
+ options.agent = analyst;
131
+ }
132
+ const shards = Number(flags.shards);
133
+ if (Number.isFinite(shards) && shards > 0)
134
+ options.shards = Math.floor(shards);
135
+ return Object.keys(options).length > 0 ? options : undefined;
136
+ }
137
+ async function postRemoteAnalyze(ingestUrl, apiKey, body) {
138
+ const parsed = await remoteAnalyzeFetch(`${ingestUrl}/v1/harness/analyze`, apiKey, {
139
+ method: 'POST',
140
+ headers: { 'Content-Type': 'application/json' },
141
+ body: JSON.stringify(body),
142
+ }, REMOTE_UPLOAD_TIMEOUT_MS);
143
+ if (!parsed || parsed.success !== true || typeof parsed.analysis_run_id !== 'string' || parsed.analysis_run_id.length === 0) {
144
+ throw new CliInputError('Moda did not accept the harness analysis request.', 'The ingest worker returned an unexpected response. Retry, or run `moda harness analyze --analyst=local-scan`.');
145
+ }
146
+ return parsed;
147
+ }
148
+ async function pollRemoteAnalyze(options) {
149
+ const { ingestUrl, apiKey, runId, context } = options;
150
+ const timeoutMs = numericEnv('MODA_HARNESS_REMOTE_TIMEOUT_MS', DEFAULT_REMOTE_ANALYZE_TIMEOUT_MS);
151
+ const initialMs = numericEnvAllowZero('MODA_HARNESS_REMOTE_POLL_INITIAL_MS', DEFAULT_REMOTE_POLL_INITIAL_MS);
152
+ const maxMs = numericEnvAllowZero('MODA_HARNESS_REMOTE_POLL_MAX_MS', DEFAULT_REMOTE_POLL_MAX_MS);
153
+ const deadline = Date.now() + timeoutMs;
154
+ const url = `${ingestUrl}/v1/harness/analyze/${encodeURIComponent(runId)}`;
155
+ // Floor the inter-poll delay so a 0 (or unset) interval can't turn a long
156
+ // hosted run into a tight loop hammering the status endpoint.
157
+ const capMs = Math.max(maxMs, MIN_REMOTE_POLL_DELAY_MS);
158
+ let delay = Math.min(Math.max(initialMs, MIN_REMOTE_POLL_DELAY_MS), capMs);
159
+ let last = { success: false, status: 'accepted' };
160
+ while (true) {
161
+ last = await remoteAnalyzeFetch(url, apiKey, { method: 'GET' }, REMOTE_STATUS_TIMEOUT_MS);
162
+ if (last?.status === 'completed' || last?.status === 'failed')
163
+ return last;
164
+ const remaining = deadline - Date.now();
165
+ if (remaining <= 0)
166
+ return last ?? { success: false, status: 'running' };
167
+ context.output.writeEvent({
168
+ event: 'progress',
169
+ phase: 'remote_wait',
170
+ message: `Server-side analysis status: ${last?.status ?? 'running'}.`,
171
+ elapsed_ms: Date.now() - context.startedAt,
172
+ });
173
+ await sleep(Math.min(delay, remaining));
174
+ delay = Math.min(delay * 2, capMs);
175
+ }
176
+ }
177
+ async function remoteAnalyzeFetch(url, apiKey, init, timeoutMs) {
178
+ const controller = new AbortController();
179
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
180
+ let response;
181
+ try {
182
+ response = await fetch(url, {
183
+ ...init,
184
+ headers: { Authorization: `Bearer ${apiKey}`, ...(init.headers ?? {}) },
185
+ signal: controller.signal,
186
+ });
187
+ }
188
+ catch (error) {
189
+ const isAbort = error instanceof DOMException && error.name === 'AbortError';
190
+ throw new CliInputError(isAbort
191
+ ? `Remote harness analysis request timed out after ${timeoutMs}ms.`
192
+ : `Network error reaching Moda ingest: ${error instanceof Error ? error.message : String(error)}.`, 'Check your connection and MODA_INGEST_URL, then retry `moda harness analyze --remote`.');
193
+ }
194
+ finally {
195
+ clearTimeout(timeout);
196
+ }
197
+ if (!response.ok) {
198
+ const text = await response.text().catch(() => '');
199
+ throw new CliInputError(`Remote harness analysis request failed (HTTP ${response.status}).`, text ? truncateText(text, 500) : 'Retry, or run `moda harness analyze --analyst=local-scan`.');
200
+ }
201
+ return response.json();
202
+ }
203
+ //# sourceMappingURL=harness-remote.js.map
package/dist/harness.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { CommandContext } from './command-context.js';
2
+ import type { HarnessReport } from './harness-report.js';
2
3
  declare const SCHEMA_VERSION = "harness.v0.1";
3
4
  export interface HarnessEvidence {
4
5
  path: string;
@@ -228,6 +229,30 @@ export type CodingAgentMode = 'read-only' | 'write';
228
229
  export declare function runHarnessCommand(context: CommandContext): Promise<{
229
230
  exitCode: number;
230
231
  }>;
232
+ /** Scan plan (scope, approval, read plan) built for every harness command run. */
233
+ export type HarnessCommandScanPlan = ReturnType<typeof buildCommandScanPlan>;
234
+ interface HarnessAnalyzePayload {
235
+ scanPlan: HarnessCommandScanPlan;
236
+ summary: HarnessSummary | Record<string, unknown>;
237
+ report: unknown;
238
+ validation: {
239
+ status: string;
240
+ warnings?: unknown[];
241
+ errors?: unknown[];
242
+ };
243
+ written: string[];
244
+ dryRun: boolean;
245
+ }
246
+ export declare function writeHarnessAnalyzeResult(context: CommandContext, payload: HarnessAnalyzePayload): void;
247
+ /**
248
+ * Emit one `artifact` lifecycle event per written file, classifying report vs
249
+ * graph artifacts. Shared by the local-scan, external-analyst, and remote
250
+ * analyze paths.
251
+ */
252
+ export declare function emitWrittenArtifacts(context: CommandContext, written: string[], description: string): void;
253
+ export declare function repairExternalAnalystReport(report: HarnessReport, rootDir: string): HarnessReport;
254
+ export declare function sleep(ms: number): Promise<void>;
255
+ export declare function summarizeInvalidAnalystReport(report: unknown): Record<string, unknown>;
231
256
  export type CodingAgentAdapter = ExternalHarnessAnalystAdapter;
232
257
  export interface CodingAgentRun {
233
258
  adapter: CodingAgentAdapter;
@@ -256,9 +281,59 @@ export declare function runCodingAgent(options: {
256
281
  progressToStderr?: boolean;
257
282
  onProgress?: (message: string, phase?: string) => void;
258
283
  }): Promise<CodingAgentRun>;
284
+ export declare function truncateText(value: string, maxLength: number): string;
259
285
  export declare function scanHarness(options?: ScanOptions): HarnessGraph;
260
286
  export declare function summarizeHarness(graph: HarnessGraph): HarnessSummary;
287
+ /** One repo-relative source file included in a {@link SourceSnapshot}. */
288
+ export interface SourceSnapshotFile {
289
+ path: string;
290
+ text: string;
291
+ }
292
+ /**
293
+ * A safe, bounded source snapshot uploaded to Moda for server-side ("Moda-hosted")
294
+ * harness analysis. Built by reusing the same permissioned inventory the local
295
+ * scanner uses, so it inherits every safety property already implemented in
296
+ * `inventoryFiles`: secret-bearing files are skipped, symlinks are skipped, and
297
+ * the per-file (512KB) plus whole-scan (12k-file / 48MB) budgets bound the size.
298
+ * Only files whose text was actually read are included; `truncated` records
299
+ * whether any file was dropped for exceeding a size/budget cap.
300
+ */
301
+ export interface SourceSnapshot {
302
+ repo: {
303
+ name: string;
304
+ root: '.';
305
+ };
306
+ files: SourceSnapshotFile[];
307
+ truncated: boolean;
308
+ fileCount: number;
309
+ byteCount: number;
310
+ gitCommit?: string;
311
+ }
312
+ /**
313
+ * Build a safe source snapshot of `rootDir` by reusing the permissioned
314
+ * `inventoryFiles` walk (secret-skip, symlink-skip, 512KB/file, 12k-file/48MB
315
+ * caps) plus `detectRepo`/`getGitCommit`. Only files with text content are
316
+ * included; `truncated` is set when the scan dropped any file for a size/budget
317
+ * cap so the server knows the snapshot is incomplete.
318
+ */
319
+ export declare function buildSourceSnapshot(rootDir: string): SourceSnapshot;
320
+ declare function buildCommandScanPlan(context: CommandContext, rootDir: string): {
321
+ scope: 'broad' | 'narrow';
322
+ root: string;
323
+ approval: {
324
+ required: boolean;
325
+ granted: boolean;
326
+ reason: string;
327
+ };
328
+ readPlan: HarnessScanReadPlan;
329
+ };
330
+ export declare function writeHumanProgress(context: CommandContext, message: string): void;
261
331
  export declare function readHarnessSyncState(rootDir: string): HarnessSyncState | undefined;
332
+ export declare function writeHarnessArtifacts(graph: HarnessGraph, rootDir: string, options?: {
333
+ summary?: boolean;
334
+ }): string[];
262
335
  export declare function graphForSync(graph: HarnessGraph): HarnessGraph;
336
+ export declare function numericEnv(name: string, fallback: number): number;
337
+ export declare function numericEnvAllowZero(name: string, fallback: number): number;
263
338
  export {};
264
339
  //# sourceMappingURL=harness.d.ts.map
package/dist/harness.js CHANGED
@@ -8,6 +8,7 @@ import { callControlAPI } from './api-client.js';
8
8
  import { AnalystTui, shouldUseAnalystTui } from './analyst-tui.js';
9
9
  import { resolveApiKey, resolveTenantId } from './config.js';
10
10
  import { CliInputError, CliInputRequiredError } from './errors.js';
11
+ import { isRemoteHarnessAnalyze, runRemoteHarnessAnalyst } from './harness-remote.js';
11
12
  import { getActiveProfile } from './profiles.js';
12
13
  import { approveHarnessReport, createHarnessReport, graphForReport, hashHarnessReport, hashStableJson as hashHarnessReportStableJson, HARNESS_REPORT_PATH, loadHarnessReportForSync, normalizeHarnessReport, readHarnessReport, resolveHarnessReportPath, validateHarnessReport, writeHarnessReportArtifacts, } from './harness-report.js';
13
14
  import { renderHarnessAnalystChoice, renderHarnessAnalystLaunch, renderHarnessAnalystPreflight, renderHarnessAnalyzeHumanResult, renderHarnessProgress, } from './tui.js';
@@ -250,6 +251,9 @@ export async function runHarnessCommand(context) {
250
251
  message: 'Starting Moda Analyst harness analysis.',
251
252
  elapsed_ms: Date.now() - context.startedAt,
252
253
  });
254
+ if (isRemoteHarnessAnalyze(context)) {
255
+ return runRemoteHarnessAnalyst({ context, rootDir, scanPlan, tenant });
256
+ }
253
257
  const analyst = await resolveHarnessAnalystAdapter(context);
254
258
  if (analyst !== 'local-scan') {
255
259
  return runExternalHarnessAnalyst({
@@ -308,20 +312,7 @@ export async function runHarnessCommand(context) {
308
312
  summary: context.flags.summary === 'true',
309
313
  }),
310
314
  ];
311
- for (const file of written) {
312
- context.output.writeEvent({
313
- event: 'artifact',
314
- phase: 'write',
315
- artifact: {
316
- kind: file.includes('harness-report') || file.includes('HARNESS_REPORT')
317
- ? 'harness_report_artifact'
318
- : 'harness_graph_artifact',
319
- path: file,
320
- description: 'Written by Moda Analyst.',
321
- },
322
- elapsed_ms: Date.now() - context.startedAt,
323
- });
324
- }
315
+ emitWrittenArtifacts(context, written, 'Written by Moda Analyst.');
325
316
  writeHarnessAnalyzeResult(context, {
326
317
  scanPlan,
327
318
  summary: summarizeHarness(graph),
@@ -449,7 +440,7 @@ export async function runHarnessCommand(context) {
449
440
  throw new CliInputError(`Unknown harness command '${subcommand}'.`, 'Usage: moda harness analyze|validate-report|approve|scan|sync|agents|graph|explain|write-summary|status');
450
441
  }
451
442
  }
452
- function writeHarnessAnalyzeResult(context, payload) {
443
+ export function writeHarnessAnalyzeResult(context, payload) {
453
444
  if (context.outputMode === 'human') {
454
445
  if (!context.quiet)
455
446
  console.log(renderHarnessAnalyzeHumanResult(payload));
@@ -504,6 +495,27 @@ function writeHarnessAnalyzeResult(context, payload) {
504
495
  ],
505
496
  });
506
497
  }
498
+ /**
499
+ * Emit one `artifact` lifecycle event per written file, classifying report vs
500
+ * graph artifacts. Shared by the local-scan, external-analyst, and remote
501
+ * analyze paths.
502
+ */
503
+ export function emitWrittenArtifacts(context, written, description) {
504
+ for (const file of written) {
505
+ context.output.writeEvent({
506
+ event: 'artifact',
507
+ phase: 'write',
508
+ artifact: {
509
+ kind: file.includes('harness-report') || file.includes('HARNESS_REPORT')
510
+ ? 'harness_report_artifact'
511
+ : 'harness_graph_artifact',
512
+ path: file,
513
+ description,
514
+ },
515
+ elapsed_ms: Date.now() - context.startedAt,
516
+ });
517
+ }
518
+ }
507
519
  async function runExternalHarnessAnalyst(options) {
508
520
  if (options.context.dryRun) {
509
521
  throw new CliInputError('External harness analysis cannot run in dry-run mode yet.', 'Use `moda harness analyze --analyst=local-scan --dry-run` for deterministic fallback inspection.');
@@ -622,20 +634,7 @@ async function runExternalHarnessAnalyst(options) {
622
634
  }));
623
635
  }
624
636
  analystTui?.setArtifacts(written);
625
- for (const file of written) {
626
- options.context.output.writeEvent({
627
- event: 'artifact',
628
- phase: 'write',
629
- artifact: {
630
- kind: file.includes('harness-report') || file.includes('HARNESS_REPORT')
631
- ? 'harness_report_artifact'
632
- : 'harness_graph_artifact',
633
- path: file,
634
- description: 'Written by Moda Analyst.',
635
- },
636
- elapsed_ms: Date.now() - options.context.startedAt,
637
- });
638
- }
637
+ emitWrittenArtifacts(options.context, written, 'Written by Moda Analyst.');
639
638
  analystTui?.complete(validation.status === 'pass' ? 'pass' : 'fail');
640
639
  analystTui?.stop();
641
640
  writeHarnessAnalyzeResult(options.context, {
@@ -726,7 +725,7 @@ function parseHarnessReportCandidate(candidate) {
726
725
  }
727
726
  return undefined;
728
727
  }
729
- function repairExternalAnalystReport(report, rootDir) {
728
+ export function repairExternalAnalystReport(report, rootDir) {
730
729
  const normalized = normalizeHarnessReport(report);
731
730
  repairCitationLines(normalized, rootDir);
732
731
  repairReportSummaryCounts(normalized);
@@ -974,7 +973,7 @@ function externalAnalystMaxAttempts(adapter) {
974
973
  const defaultRetries = adapter === 'claude' ? 2 : 1;
975
974
  return 1 + numericEnvAllowZero('MODA_HARNESS_ANALYST_RETRIES', defaultRetries);
976
975
  }
977
- function sleep(ms) {
976
+ export function sleep(ms) {
978
977
  return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
979
978
  }
980
979
  function recordExternalAgentTextChunk(text, tui) {
@@ -1116,7 +1115,7 @@ function isRecord(value) {
1116
1115
  function stringValue(value) {
1117
1116
  return typeof value === 'string' && value.length > 0 ? value : undefined;
1118
1117
  }
1119
- function summarizeInvalidAnalystReport(report) {
1118
+ export function summarizeInvalidAnalystReport(report) {
1120
1119
  if (!report || typeof report !== 'object')
1121
1120
  return { status: 'invalid_report' };
1122
1121
  const reportRecord = report;
@@ -1554,7 +1553,7 @@ function findOnPath(command) {
1554
1553
  function splitCommandLine(commandLine) {
1555
1554
  return commandLine.trim().split(/\s+/).filter(Boolean);
1556
1555
  }
1557
- function truncateText(value, maxLength) {
1556
+ export function truncateText(value, maxLength) {
1558
1557
  const compact = value.replace(/\s+/g, ' ').trim();
1559
1558
  return compact.length > maxLength ? `${compact.slice(0, maxLength - 1)}...` : compact;
1560
1559
  }
@@ -1618,6 +1617,62 @@ export function summarizeHarness(graph) {
1618
1617
  unknownCount: graph.unknowns.length,
1619
1618
  };
1620
1619
  }
1620
+ /** Skip reasons that mean a file was dropped for exceeding a size/budget cap. */
1621
+ function isSnapshotBudgetSkip(reason) {
1622
+ return reason === 'file too large for harness scan' ||
1623
+ reason === 'scan text budget exceeded';
1624
+ }
1625
+ /**
1626
+ * Serialized-file budget for a {@link SourceSnapshot}, kept under the ingest
1627
+ * proxy's 5MB request-body cap (`MAX_ANALYZE_BODY_BYTES` in
1628
+ * apps/moda-ingest-worker) with ~500KB of headroom for the JSON envelope. The
1629
+ * raw inventory text budget (48MB) is far larger, so this is the binding limit.
1630
+ */
1631
+ const MAX_SNAPSHOT_BODY_BYTES = 4_500_000;
1632
+ /**
1633
+ * Build a safe source snapshot of `rootDir` by reusing the permissioned
1634
+ * `inventoryFiles` walk (secret-skip, symlink-skip, 512KB/file, 12k-file/48MB
1635
+ * caps) plus `detectRepo`/`getGitCommit`. Only files with text content are
1636
+ * included; `truncated` is set when the scan dropped any file for a size/budget
1637
+ * cap so the server knows the snapshot is incomplete.
1638
+ */
1639
+ export function buildSourceSnapshot(rootDir) {
1640
+ const root = resolve(rootDir);
1641
+ const skipped = [];
1642
+ const files = inventoryFiles(root, skipped);
1643
+ const repo = detectRepo(root, files);
1644
+ const includedFiles = [];
1645
+ let byteCount = 0;
1646
+ let serializedBytes = 0;
1647
+ let truncatedBySize = false;
1648
+ for (const item of files) {
1649
+ if (item.text === undefined)
1650
+ continue;
1651
+ const file = { path: item.path, text: item.text };
1652
+ // Bound the SERIALIZED upload, not just the raw inventory text: the ingest
1653
+ // proxy caps the request body at MAX_ANALYZE_BODY_BYTES (5MB) while the
1654
+ // inventory text budget (48MB) is far larger. Account for JSON encoding
1655
+ // (keys, quotes, escaping, and the array comma) so a large repo produces a
1656
+ // `truncated` snapshot that still uploads instead of failing with HTTP 413.
1657
+ const cost = Buffer.byteLength(JSON.stringify(file), 'utf8') + 1;
1658
+ if (serializedBytes + cost > MAX_SNAPSHOT_BODY_BYTES) {
1659
+ truncatedBySize = true;
1660
+ break;
1661
+ }
1662
+ includedFiles.push(file);
1663
+ serializedBytes += cost;
1664
+ byteCount += Buffer.byteLength(item.text, 'utf8');
1665
+ }
1666
+ const gitCommit = getGitCommit(root);
1667
+ return {
1668
+ repo: { name: repo.name, root: '.' },
1669
+ files: includedFiles,
1670
+ truncated: truncatedBySize || skipped.some((skip) => isSnapshotBudgetSkip(skip.reason)),
1671
+ fileCount: includedFiles.length,
1672
+ byteCount,
1673
+ ...(gitCommit ? { gitCommit } : {}),
1674
+ };
1675
+ }
1621
1676
  function buildCommandScanPlan(context, rootDir) {
1622
1677
  const explicitPath = context.flags.path !== undefined;
1623
1678
  const rel = normalizePath(relative(context.cwd, rootDir)) || '.';
@@ -1675,7 +1730,7 @@ async function approveScanIfNeeded(scanPlan, options = {}) {
1675
1730
  throw new CliInputError(options.declinedMessage ?? 'Harness scan was not approved.', options.declinedHint ?? 'Run `moda harness scan --path=<dir>` for a narrower scan, or rerun with `--yes` after reviewing the read plan.');
1676
1731
  }
1677
1732
  }
1678
- function writeHumanProgress(context, message) {
1733
+ export function writeHumanProgress(context, message) {
1679
1734
  if (context.outputMode !== 'human')
1680
1735
  return;
1681
1736
  if (context.quiet && context.flags.progress !== 'true')
@@ -1854,7 +1909,7 @@ function loadOrScanHarness(rootDir, tenant) {
1854
1909
  function readHarnessGraph(manifestPath) {
1855
1910
  return JSON.parse(readFileSync(manifestPath, 'utf8'));
1856
1911
  }
1857
- function writeHarnessArtifacts(graph, rootDir, options = {}) {
1912
+ export function writeHarnessArtifacts(graph, rootDir, options = {}) {
1858
1913
  const modaDir = join(rootDir, MODA_DIR);
1859
1914
  mkdirSync(modaDir, { recursive: true });
1860
1915
  const manifestPath = join(rootDir, HARNESS_PATH);
@@ -2168,14 +2223,14 @@ function throwHarnessScanTooBroad(rootDir, details) {
2168
2223
  'For harness-only analysis, use `moda harness analyze --path=<agent-dir> --summary`.',
2169
2224
  ].join('\n'));
2170
2225
  }
2171
- function numericEnv(name, fallback) {
2226
+ export function numericEnv(name, fallback) {
2172
2227
  const raw = process.env[name];
2173
2228
  if (!raw)
2174
2229
  return fallback;
2175
2230
  const parsed = Number(raw);
2176
2231
  return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
2177
2232
  }
2178
- function numericEnvAllowZero(name, fallback) {
2233
+ export function numericEnvAllowZero(name, fallback) {
2179
2234
  const raw = process.env[name];
2180
2235
  if (!raw)
2181
2236
  return fallback;
@@ -86,9 +86,16 @@ export async function runInit(flags, options = {}) {
86
86
  log(renderInitNote(`Active workspace: ${tenantName}`));
87
87
  emit('workspace', `Active workspace: ${tenantName}`, 'completed');
88
88
  phases.push({ id: 'workspace', title: 'Workspace & Profile', status: 'completed', details: tenantName });
89
- // Step 4: Harness analyst report.
90
- log(renderInitStep(4, 7, 'Harness Discovery', 'Moda Analyst investigates the harness and writes cited local artifacts.'));
91
- emit('harness', 'Running Moda Analyst harness discovery.');
89
+ // Step 4: Harness analyst report. When the user passes `--remote`, Moda runs
90
+ // the analyst server-side (uploading a safe source snapshot) so no local
91
+ // coding agent is required; otherwise the local analyst runs as before.
92
+ const remote = isTruthyFlag(flagValue(flags, 'remote'));
93
+ log(renderInitStep(4, 7, 'Harness Discovery', remote
94
+ ? 'Moda runs the harness analyst server-side and writes cited local artifacts.'
95
+ : 'Moda Analyst investigates the harness and writes cited local artifacts.'));
96
+ emit('harness', remote
97
+ ? 'Running Moda-hosted harness discovery.'
98
+ : 'Running Moda Analyst harness discovery.');
92
99
  const harnessResult = await runHarnessCommand(createCommandContext({
93
100
  command: 'harness',
94
101
  positional: 'analyze',
@@ -104,6 +111,7 @@ export async function runInit(flags, options = {}) {
104
111
  json: json || agent,
105
112
  agent,
106
113
  analyst: stringFlag(flags.analyst) ?? 'auto',
114
+ remote,
107
115
  quiet: true,
108
116
  progress: true,
109
117
  'no-telemetry': true,
@@ -616,7 +624,9 @@ function parseOptionalBoolean(value) {
616
624
  return undefined;
617
625
  }
618
626
  function isTruthyFlag(value) {
619
- return value === true || value === 'true' || value === '1' || value === 'yes';
627
+ // The CLI normalizes numeric-looking flag values to numbers (`--remote=1` →
628
+ // number 1), so accept the numeric `1` alongside the string spellings.
629
+ return value === true || value === 1 || value === 'true' || value === '1' || value === 'yes';
620
630
  }
621
631
  function flagValue(flags, ...keys) {
622
632
  for (const key of keys) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moda-ai/cli",
3
- "version": "1.12.0",
3
+ "version": "1.12.2",
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-06T02:25:00.333Z",
4
- "cli_version": "1.12.0",
3
+ "bundled_at": "2026-07-06T03:50:30.586Z",
4
+ "cli_version": "1.12.2",
5
5
  "skills": [
6
6
  {
7
7
  "id": "integration-node-anthropic",