@moda-ai/cli 1.12.0 → 1.12.3

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;
@@ -2426,14 +2481,19 @@ function environmentFromAnnotation(annotation) {
2426
2481
  };
2427
2482
  }
2428
2483
  function applyHeuristics(graph, files) {
2484
+ const isApplicationCode = applicationCodePredicate(files);
2429
2485
  for (const file of files) {
2430
2486
  maybeAddPromptArtifact(graph, file);
2431
2487
  maybeAddSkillArtifact(graph, file);
2488
+ if (isApplicationCode(file.path))
2489
+ maybeAddToolArtifact(graph, file);
2432
2490
  maybeAddEvalArtifact(graph, file);
2433
2491
  maybeAddDeployment(graph, file);
2434
2492
  }
2435
2493
  if (graph.runtimeAgents.length === 0) {
2436
2494
  for (const file of files) {
2495
+ if (!isApplicationCode(file.path))
2496
+ continue;
2437
2497
  const agent = inferRuntimeAgent(file, graph);
2438
2498
  if (agent)
2439
2499
  upsertRuntimeAgent(graph, agent);
@@ -2532,7 +2592,10 @@ function inferRuntimeAgent(file, graph) {
2532
2592
  const loweredText = file.text.toLowerCase();
2533
2593
  const pathLooksLikeAgent = /(^|\/)(agent|agents|agent_harness)\//.test(loweredPath) ||
2534
2594
  /(^|\/)(agent|main|index)\.(ts|tsx|js|mjs|py)$/.test(loweredPath);
2535
- const codeLooksLikeAgent = /\b(run_agent|runagent|createagent|buildprompt|system prompt|openai|anthropic|model)\b/i.test(file.text);
2595
+ // Require evidence that the file actually constructs or runs an agent, not
2596
+ // merely that it mentions a model provider. Loose signals like "openai" or
2597
+ // "model" match library internals and utilities that are not runtime agents.
2598
+ const codeLooksLikeAgent = AGENT_RUNTIME_SIGNAL.test(file.text);
2536
2599
  if (!pathLooksLikeAgent || !codeLooksLikeAgent)
2537
2600
  return null;
2538
2601
  const channelIds = inferChannels(file);
@@ -2618,16 +2681,136 @@ function inferChannels(file) {
2618
2681
  .filter(([needle]) => text.includes(needle))
2619
2682
  .map(([, id]) => id);
2620
2683
  }
2684
+ // Trailing module names an import statement references, normalized to their last
2685
+ // path segment without extension. Covers ES/TS `from '...'` / `import(...)` /
2686
+ // `require(...)` and Python `from a.b.c import ...` / `import a.b.c`. Used to link
2687
+ // split-file tools whose module is imported by extensionless or dotted name
2688
+ // (e.g. `from tools import get_weather`, `import { getWeather } from './tools'`).
2689
+ function importedModuleNames(text) {
2690
+ const names = new Set();
2691
+ for (const match of text.matchAll(/(?:from|import|require)\s*\(?\s*["']([^"']+)["']/g)) {
2692
+ const segment = match[1].split('/').pop();
2693
+ if (segment)
2694
+ names.add(segment.replace(/\.(ts|tsx|js|mjs|py)$/i, ''));
2695
+ }
2696
+ for (const match of text.matchAll(/^\s*(?:from|import)\s+([.\w]+)/gm)) {
2697
+ const segment = match[1].replace(/^\.+/, '').split('.').pop();
2698
+ if (segment)
2699
+ names.add(segment);
2700
+ }
2701
+ return names;
2702
+ }
2621
2703
  function inferArtifactsForFile(file, graph) {
2622
2704
  const text = file.text ?? '';
2705
+ const imported = importedModuleNames(text);
2623
2706
  const referenced = graph.artifacts.filter((artifact) => {
2624
2707
  if (!artifact.sourcePath)
2625
2708
  return false;
2709
+ if (artifact.sourcePath === file.path)
2710
+ return true;
2626
2711
  const base = basename(artifact.sourcePath);
2627
- return text.includes(base) || text.includes(artifact.sourcePath);
2712
+ if (text.includes(base) || text.includes(artifact.sourcePath))
2713
+ return true;
2714
+ // Split-file tools: the agent imports the tool module by its extensionless or
2715
+ // dotted name, which contains neither the basename-with-extension nor the full
2716
+ // source path (e.g. `from tools import get_weather`, `from './tools'`).
2717
+ if (artifact.type === 'tool') {
2718
+ const moduleName = base.replace(/\.(ts|tsx|js|mjs|py)$/i, '');
2719
+ if (moduleName && imported.has(moduleName))
2720
+ return true;
2721
+ }
2722
+ return false;
2628
2723
  });
2629
2724
  return referenced.map((artifact) => artifact.id);
2630
2725
  }
2726
+ // Signals that a file constructs or runs an agent (as opposed to merely
2727
+ // importing a model SDK). Kept deliberately construction/run oriented so
2728
+ // library internals that only reference a provider are not misread as agents.
2729
+ // Covers the LangChain factory/executor shapes (create_tool_calling_agent,
2730
+ // create_openai_functions_agent, AgentExecutor, ...) and the `return`/`yield
2731
+ // Agent(...)` factory forms in addition to the direct construction/run signals.
2732
+ const AGENT_RUNTIME_SIGNAL = /(run_agent|runAgent|create_[a-z_]*agent\b|createAgent|initialize_agent|AgentExecutor\s*\(|new\s+Agent\s*\(|\bSwarm\s*\(|\bCrew\s*\(|\bRunner\s*\(|Runner\.run|\.run_sync\s*\(|\.run_streamed\s*\(|[=([,]\s*Agent\s*\(|(?:return|yield)\s+Agent\s*\(|^\s*Agent\s*\()/im;
2733
+ // Hosted/first-party tool classes that count as tool definitions when called.
2734
+ const HOSTED_TOOL_CLASSES = [
2735
+ 'WebSearchTool',
2736
+ 'FileSearchTool',
2737
+ 'ComputerTool',
2738
+ 'CodeInterpreterTool',
2739
+ 'HostedMCPTool',
2740
+ 'ImageGenerationTool',
2741
+ 'LocalShellTool',
2742
+ 'ShellTool',
2743
+ 'ApplyPatchTool',
2744
+ 'FunctionTool',
2745
+ ];
2746
+ function repoOwnPackageNames(files) {
2747
+ const packageJson = files.find((file) => file.path === 'package.json')?.text;
2748
+ const pyproject = files.find((file) => file.path === 'pyproject.toml')?.text;
2749
+ const names = [
2750
+ packageJson ? safePackageName(packageJson) : undefined,
2751
+ pyproject ? safeTomlName(pyproject) : undefined,
2752
+ ];
2753
+ return names.filter((name) => Boolean(name)).map((name) => name.toLowerCase());
2754
+ }
2755
+ function repoIsAgentFrameworkSource(files) {
2756
+ const own = new Set(repoOwnPackageNames(files));
2757
+ if (own.size === 0)
2758
+ return false;
2759
+ return FRAMEWORK_CATALOG.some((framework) => framework.packageNames.some((name) => own.has(name.toLowerCase())));
2760
+ }
2761
+ function isApplicationLikePath(path) {
2762
+ return /(^|\/)(examples?|apps?|demos?|samples?|cookbook|tutorials?|quickstart|playground)\//i.test(path);
2763
+ }
2764
+ // When the repository IS an agent framework/SDK (its own package name matches a
2765
+ // framework catalog entry), its library source is the framework implementation,
2766
+ // not customer application code: runtime agents and tools should only be
2767
+ // inferred from application-like directories (examples, apps, demos, ...).
2768
+ function applicationCodePredicate(files) {
2769
+ return repoIsAgentFrameworkSource(files) ? isApplicationLikePath : () => true;
2770
+ }
2771
+ const HOSTED_TOOL_CALL = new RegExp(`\\b(${HOSTED_TOOL_CLASSES.join('|')})\\s*\\(`, 'g');
2772
+ function detectToolDefinitions(file) {
2773
+ const text = file.text;
2774
+ if (!text)
2775
+ return [];
2776
+ const found = new Map();
2777
+ const pyDecorated = /@function_tool[^\n]*\n(?:[ \t]*@[^\n]*\n)*[ \t]*(?:async[ \t]+)?def[ \t]+([A-Za-z_]\w*)/g;
2778
+ for (const match of text.matchAll(pyDecorated))
2779
+ found.set(match[1], '@function_tool-decorated function');
2780
+ const tsFactory = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:[\w.]*\.)?(?:tool|functionTool|createTool|dynamicTool)\s*\(/g;
2781
+ for (const match of text.matchAll(tsFactory))
2782
+ found.set(match[1], 'tool factory assignment');
2783
+ const tsNewTool = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*new\s+(?:Dynamic|Structured)?Tool\b/g;
2784
+ for (const match of text.matchAll(tsNewTool))
2785
+ found.set(match[1], 'tool constructor assignment');
2786
+ for (const match of text.matchAll(HOSTED_TOOL_CALL))
2787
+ found.set(match[1], `hosted tool ${match[1]}`);
2788
+ return [...found.entries()].map(([name, reason]) => ({ name, reason }));
2789
+ }
2790
+ function maybeAddToolArtifact(graph, file) {
2791
+ if (!/\.(ts|tsx|js|mjs|py)$/i.test(file.path))
2792
+ return;
2793
+ // Skip test/eval fixtures — those tool definitions are fixtures, not a
2794
+ // customer's runtime tools. (Framework library internals are already
2795
+ // excluded by applyHeuristics's application-code predicate.)
2796
+ if (isEvalLikePath(file.path))
2797
+ return;
2798
+ const tools = detectToolDefinitions(file);
2799
+ if (tools.length === 0)
2800
+ return;
2801
+ for (const tool of tools) {
2802
+ upsertArtifact(graph, {
2803
+ id: stableId('tool', `${file.path}:${tool.name}`),
2804
+ type: 'tool',
2805
+ name: titleFromId(tool.name),
2806
+ scope: 'agent',
2807
+ sourcePath: file.path,
2808
+ usedByAgentIds: [],
2809
+ confidence: 0.6,
2810
+ evidence: [{ path: file.path, reason: `Tool definition detected (${tool.reason}).` }],
2811
+ });
2812
+ }
2813
+ }
2631
2814
  function detectDeveloperAgentAdapters(files) {
2632
2815
  const adapters = [];
2633
2816
  if (files.some((file) => file.path.startsWith('.claude/'))) {
@@ -2690,7 +2873,7 @@ const FRAMEWORK_CATALOG = [
2690
2873
  name: 'LangChain',
2691
2874
  kind: 'agent_framework',
2692
2875
  packageNames: ['langchain', '@langchain/core', '@langchain/langgraph', 'langgraph'],
2693
- patterns: [/langchain/i, /langgraph/i],
2876
+ patterns: [/\blangchain\b/i, /\blanggraph\b/i],
2694
2877
  },
2695
2878
  {
2696
2879
  id: 'framework_vercel_ai_sdk',
@@ -2704,7 +2887,7 @@ const FRAMEWORK_CATALOG = [
2704
2887
  name: 'Mastra',
2705
2888
  kind: 'agent_framework',
2706
2889
  packageNames: ['mastra', '@mastra/core'],
2707
- patterns: [/mastra/i],
2890
+ patterns: [/\bmastra\b/i],
2708
2891
  },
2709
2892
  {
2710
2893
  id: 'framework_openai_agents',
@@ -2718,7 +2901,7 @@ const FRAMEWORK_CATALOG = [
2718
2901
  name: 'CrewAI',
2719
2902
  kind: 'agent_framework',
2720
2903
  packageNames: ['crewai'],
2721
- patterns: [/crewai/i],
2904
+ patterns: [/\bcrewai\b/i],
2722
2905
  },
2723
2906
  {
2724
2907
  id: 'framework_pydantic_ai',
@@ -2743,7 +2926,7 @@ const PROVIDER_CATALOG = [
2743
2926
  kind: 'model_provider',
2744
2927
  packageNames: ['@anthropic-ai/sdk', '@ai-sdk/anthropic', 'anthropic'],
2745
2928
  envVars: ['ANTHROPIC_API_KEY'],
2746
- patterns: [/anthropic/i, /\bclaude-[\w.-]+/i, /ANTHROPIC_API_KEY/],
2929
+ patterns: [/\banthropic\b/i, /\bclaude-[\w.-]+/i, /ANTHROPIC_API_KEY/],
2747
2930
  },
2748
2931
  {
2749
2932
  id: 'provider_google',
@@ -2751,7 +2934,7 @@ const PROVIDER_CATALOG = [
2751
2934
  kind: 'model_provider',
2752
2935
  packageNames: ['@google/generative-ai', '@ai-sdk/google', 'google-generativeai'],
2753
2936
  envVars: ['GOOGLE_GENERATIVE_AI_API_KEY', 'GEMINI_API_KEY'],
2754
- patterns: [/gemini/i, /google.generativeai/i],
2937
+ patterns: [/\bgemini\b/i, /google\.generativeai/i],
2755
2938
  },
2756
2939
  {
2757
2940
  id: 'provider_cohere',
@@ -2759,7 +2942,7 @@ const PROVIDER_CATALOG = [
2759
2942
  kind: 'model_provider',
2760
2943
  packageNames: ['cohere-ai', 'cohere'],
2761
2944
  envVars: ['COHERE_API_KEY'],
2762
- patterns: [/cohere/i],
2945
+ patterns: [/\bcohere\b/i],
2763
2946
  },
2764
2947
  {
2765
2948
  id: 'provider_mistral',
@@ -2767,7 +2950,7 @@ const PROVIDER_CATALOG = [
2767
2950
  kind: 'model_provider',
2768
2951
  packageNames: ['@mistralai/mistralai', 'mistralai'],
2769
2952
  envVars: ['MISTRAL_API_KEY'],
2770
- patterns: [/mistral/i],
2953
+ patterns: [/\bmistral\b/i],
2771
2954
  },
2772
2955
  {
2773
2956
  id: 'provider_ollama',
@@ -2775,7 +2958,7 @@ const PROVIDER_CATALOG = [
2775
2958
  kind: 'model_provider',
2776
2959
  packageNames: ['ollama'],
2777
2960
  envVars: ['OLLAMA_HOST'],
2778
- patterns: [/ollama/i],
2961
+ patterns: [/\bollama\b/i],
2779
2962
  },
2780
2963
  ];
2781
2964
  function detectAgentFrameworks(files) {
@@ -2828,9 +3011,9 @@ function detectionEvidence(files, packageNames, patterns) {
2828
3011
  }
2829
3012
  }
2830
3013
  if (file.path === 'pyproject.toml' && file.text) {
2831
- const lowered = file.text.toLowerCase();
3014
+ const deps = pyprojectDependencyNames(file.text);
2832
3015
  for (const name of packageNames) {
2833
- if (lowered.includes(name.toLowerCase())) {
3016
+ if (deps.has(name.toLowerCase())) {
2834
3017
  evidence.push({ path: file.path, reason: `Python dependency ${name} detected.` });
2835
3018
  }
2836
3019
  }
@@ -2860,6 +3043,53 @@ function packageDependencyNames(text) {
2860
3043
  return new Set();
2861
3044
  }
2862
3045
  }
3046
+ function pyprojectDependencyNames(text) {
3047
+ // Only extract names from actual dependency contexts, never every quoted string
3048
+ // in the file: that would re-open the "ai"-inside-`keywords = ["ai"]` /
3049
+ // `name = "openai-agents"` false positives this parser exists to avoid. Two
3050
+ // dependency shapes are covered, and membership is tested by exact name:
3051
+ // - PEP 621 / PEP 518 requirement arrays (`dependencies`/`requires = [...]`,
3052
+ // `[project.optional-dependencies]`, `[dependency-groups]`): leading
3053
+ // distribution token of each quoted PEP 508 string (e.g. "openai>=2" -> openai).
3054
+ // - Poetry tables (`[tool.poetry.dependencies]`, `dev-dependencies`,
3055
+ // `group.<name>.dependencies`): the bare TOML key (e.g. `openai = "^1.0"`).
3056
+ const names = new Set();
3057
+ const addLeadingToken = (spec) => {
3058
+ const match = spec.match(/^\s*([A-Za-z0-9][A-Za-z0-9._-]*)/);
3059
+ if (match)
3060
+ names.add(match[1].toLowerCase());
3061
+ };
3062
+ const isPoetryDepTable = (section) => /^tool\.poetry\.(dependencies|dev-dependencies|group\.[^.]+\.dependencies)$/.test(section);
3063
+ const isRequirementArrayTable = (section) => section === 'project.optional-dependencies' || section === 'dependency-groups';
3064
+ let section = '';
3065
+ let inDependencyArray = false;
3066
+ for (const raw of text.split(/\r?\n/)) {
3067
+ const line = raw.split('#')[0];
3068
+ const header = line.match(/^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*$/);
3069
+ if (header) {
3070
+ section = header[1].trim().toLowerCase();
3071
+ inDependencyArray = false;
3072
+ continue;
3073
+ }
3074
+ if (/^\s*(?:dependencies|requires)\s*=\s*\[/.test(line) ||
3075
+ (isRequirementArrayTable(section) && /=\s*\[/.test(line))) {
3076
+ inDependencyArray = true;
3077
+ }
3078
+ if (inDependencyArray) {
3079
+ for (const match of line.matchAll(/["']([^"']+)["']/g))
3080
+ addLeadingToken(match[1]);
3081
+ if (line.includes(']'))
3082
+ inDependencyArray = false;
3083
+ continue;
3084
+ }
3085
+ if (isPoetryDepTable(section)) {
3086
+ const key = line.match(/^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*=/);
3087
+ if (key && key[1].toLowerCase() !== 'python')
3088
+ names.add(key[1].toLowerCase());
3089
+ }
3090
+ }
3091
+ return names;
3092
+ }
2863
3093
  function modelHintsForProvider(providerId, files) {
2864
3094
  const hints = new Set();
2865
3095
  const pattern = providerId === 'provider_anthropic'
@@ -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.3",
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-06T05:27:59.799Z",
4
+ "cli_version": "1.12.3",
5
5
  "skills": [
6
6
  {
7
7
  "id": "integration-node-anthropic",