@moda-ai/cli 1.12.0 → 1.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +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 +92 -37
- 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;
|
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