@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 +5 -0
- package/dist/harness-remote.d.ts +33 -0
- package/dist/harness-remote.js +203 -0
- package/dist/harness.d.ts +75 -0
- package/dist/harness.js +279 -49
- package/dist/init/index.js +14 -4
- package/package.json +1 -1
- package/skills/integration/index.json +2 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: [/
|
|
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: [/
|
|
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: [/
|
|
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: [/
|
|
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: [/
|
|
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: [/
|
|
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: [/
|
|
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: [/
|
|
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
|
|
3014
|
+
const deps = pyprojectDependencyNames(file.text);
|
|
2832
3015
|
for (const name of packageNames) {
|
|
2833
|
-
if (
|
|
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'
|
package/dist/init/index.js
CHANGED
|
@@ -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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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