@moda-ai/cli 1.22.0 → 1.22.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-g8qs2tj9.js +9777 -0
- package/dist/cli-pwbvtes8.js +2312 -0
- package/dist/cli-qszcpda3.js +284 -0
- package/dist/cli.js +4775 -1159
- package/dist/index-6h0nc1r5.js +2765 -0
- package/dist/provision-j5gq8cvx.js +72 -0
- package/package.json +3 -2
- package/skills/integration/index.json +2 -2
- package/dist/agent-schemas.d.ts +0 -3
- package/dist/agent-schemas.js +0 -358
- package/dist/agent.d.ts +0 -5
- package/dist/agent.js +0 -477
- package/dist/analyst-tui.d.ts +0 -81
- package/dist/analyst-tui.js +0 -314
- package/dist/api-client.d.ts +0 -46
- package/dist/api-client.js +0 -120
- package/dist/ask-stream.d.ts +0 -146
- package/dist/ask-stream.js +0 -473
- package/dist/auth-storage.d.ts +0 -35
- package/dist/auth-storage.js +0 -159
- package/dist/auth.d.ts +0 -50
- package/dist/auth.js +0 -694
- package/dist/cli-session.d.ts +0 -26
- package/dist/cli-session.js +0 -169
- package/dist/cli.d.ts +0 -44
- package/dist/command-context.d.ts +0 -62
- package/dist/command-context.js +0 -57
- package/dist/command-registry.d.ts +0 -22
- package/dist/command-registry.js +0 -15
- package/dist/config.d.ts +0 -60
- package/dist/config.js +0 -110
- package/dist/doctor.d.ts +0 -79
- package/dist/doctor.js +0 -1182
- package/dist/errors.d.ts +0 -50
- package/dist/errors.js +0 -64
- package/dist/exit-funnel.d.ts +0 -14
- package/dist/exit-funnel.js +0 -53
- package/dist/harness-artifact-bodies.d.ts +0 -28
- package/dist/harness-artifact-bodies.js +0 -276
- package/dist/harness-candidates.d.ts +0 -77
- package/dist/harness-candidates.js +0 -482
- package/dist/harness-remote.d.ts +0 -135
- package/dist/harness-remote.js +0 -540
- package/dist/harness-report.d.ts +0 -145
- package/dist/harness-report.js +0 -1579
- package/dist/harness-sync-limits.d.ts +0 -8
- package/dist/harness-sync-limits.js +0 -21
- package/dist/harness.d.ts +0 -463
- package/dist/harness.js +0 -4009
- package/dist/init/index.d.ts +0 -78
- package/dist/init/index.js +0 -1442
- package/dist/init/mcp.d.ts +0 -2
- package/dist/init/mcp.js +0 -27
- package/dist/init/progress-board.d.ts +0 -80
- package/dist/init/progress-board.js +0 -225
- package/dist/init/rules.d.ts +0 -6
- package/dist/init/rules.js +0 -171
- package/dist/init/sdk-catalog.d.ts +0 -10
- package/dist/init/sdk-catalog.js +0 -80
- package/dist/init/sdk-guide.d.ts +0 -19
- package/dist/init/sdk-guide.js +0 -185
- package/dist/init/sdk-integration.d.ts +0 -53
- package/dist/init/sdk-integration.js +0 -835
- package/dist/init/sdk-protocol.d.ts +0 -135
- package/dist/init/sdk-protocol.js +0 -152
- package/dist/init/sdk-verification.d.ts +0 -27
- package/dist/init/sdk-verification.js +0 -123
- package/dist/init/tenant.d.ts +0 -67
- package/dist/init/tenant.js +0 -421
- package/dist/intelligence-schema.d.ts +0 -349
- package/dist/intelligence-schema.js +0 -174
- package/dist/keys.d.ts +0 -47
- package/dist/keys.js +0 -136
- package/dist/manifest.d.ts +0 -5
- package/dist/manifest.js +0 -114
- package/dist/markdown-terminal.d.ts +0 -49
- package/dist/markdown-terminal.js +0 -212
- package/dist/output.d.ts +0 -191
- package/dist/output.js +0 -624
- package/dist/production-intelligence.d.ts +0 -175
- package/dist/production-intelligence.js +0 -1430
- package/dist/profile-commands.d.ts +0 -10
- package/dist/profile-commands.js +0 -342
- package/dist/profiles.d.ts +0 -112
- package/dist/profiles.js +0 -573
- package/dist/prompts-ab.d.ts +0 -71
- package/dist/prompts-ab.js +0 -313
- package/dist/prompts-propose.d.ts +0 -15
- package/dist/prompts-propose.js +0 -212
- package/dist/prompts.d.ts +0 -58
- package/dist/prompts.js +0 -418
- package/dist/provision.d.ts +0 -28
- package/dist/provision.js +0 -104
- package/dist/redact.d.ts +0 -2
- package/dist/redact.js +0 -90
- package/dist/schemas.d.ts +0 -225
- package/dist/schemas.js +0 -126
- package/dist/skills-bundled.d.ts +0 -35
- package/dist/skills-bundled.js +0 -157
- package/dist/skills-manifest.d.ts +0 -33
- package/dist/skills-manifest.js +0 -244
- package/dist/skills.d.ts +0 -57
- package/dist/skills.js +0 -777
- package/dist/sync.d.ts +0 -5
- package/dist/sync.js +0 -36
- package/dist/telemetry.d.ts +0 -56
- package/dist/telemetry.js +0 -167
- package/dist/transforms.d.ts +0 -25
- package/dist/transforms.js +0 -102
- package/dist/tui.d.ts +0 -48
- package/dist/tui.js +0 -393
- package/dist/update-check.d.ts +0 -31
- package/dist/update-check.js +0 -141
- package/dist/workspace.d.ts +0 -12
- package/dist/workspace.js +0 -1294
package/dist/harness.js
DELETED
|
@@ -1,4009 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import { execFileSync, spawn } from 'node:child_process';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from 'node:fs';
|
|
5
|
-
import { basename, delimiter, dirname, extname, join, relative, resolve, sep } from 'node:path';
|
|
6
|
-
import { confirm, select } from '@inquirer/prompts';
|
|
7
|
-
import { callControlAPI } from './api-client.js';
|
|
8
|
-
import { AnalystTui, shouldUseAnalystTui } from './analyst-tui.js';
|
|
9
|
-
import { embeddedTenantIdFromApiKey, resolveApiKey, resolveTenantId } from './config.js';
|
|
10
|
-
import { CliInputError, CliInputRequiredError } from './errors.js';
|
|
11
|
-
import { HARNESS_REMOTE_RUN_STATE_PATH, isRemoteHarnessAnalyze, readRemoteRunState, runHarnessPull, runRemoteHarnessAnalyst, } from './harness-remote.js';
|
|
12
|
-
import { getActiveProfile } from './profiles.js';
|
|
13
|
-
import { enrichScanCaptureMetadata, annotatedSourceCandidates, extractToolBody, hydratePromptAndToolBodies, readPromptOrToolBody, resolveAnnotatedSourcePath, } from './harness-artifact-bodies.js';
|
|
14
|
-
import { approveHarnessReport, createHarnessReport, graphForReport, hashHarnessReport, hashStableJson as hashHarnessReportStableJson, HARNESS_REPORT_PATH, loadHarnessReportForSync, normalizeHarnessReport, readHarnessReport, resolveHarnessReportPath, validateHarnessReport, writeHarnessReportArtifacts, } from './harness-report.js';
|
|
15
|
-
import { detectHarnessCandidates, writeHarnessCandidatesReport } from './harness-candidates.js';
|
|
16
|
-
import { assertHarnessGraphSyncSize } from './harness-sync-limits.js';
|
|
17
|
-
import { renderHarnessAnalystChoice, renderHarnessAnalystLaunch, renderHarnessAnalystPreflight, renderHarnessAnalyzeHumanResult, renderHarnessProgress, } from './tui.js';
|
|
18
|
-
const MODA_DIR = '.moda';
|
|
19
|
-
const HARNESS_PATH = join(MODA_DIR, 'harness.json');
|
|
20
|
-
const HARNESS_SUMMARY_PATH = join(MODA_DIR, 'HARNESS.md');
|
|
21
|
-
const SYNC_STATE_PATH = join(MODA_DIR, 'sync-state.json');
|
|
22
|
-
const SCHEMA_VERSION = 'harness.v0.1';
|
|
23
|
-
const MAX_SCANNED_FILE_BYTES = 512 * 1024;
|
|
24
|
-
const DEFAULT_MAX_INVENTORY_FILES = 12_000;
|
|
25
|
-
const DEFAULT_MAX_INVENTORY_TEXT_BYTES = 48 * 1024 * 1024;
|
|
26
|
-
const MAX_RECORDED_SKIPS = 1_000;
|
|
27
|
-
const REPORT_JSON_BEGIN = 'MODA_HARNESS_REPORT_JSON_BEGIN';
|
|
28
|
-
const REPORT_JSON_END = 'MODA_HARNESS_REPORT_JSON_END';
|
|
29
|
-
const SKIPPED_DIRS = new Set([
|
|
30
|
-
'.angular',
|
|
31
|
-
'.cache',
|
|
32
|
-
'.expo',
|
|
33
|
-
'.git',
|
|
34
|
-
'.hg',
|
|
35
|
-
'.idea',
|
|
36
|
-
'.svn',
|
|
37
|
-
'.next',
|
|
38
|
-
'.nuxt',
|
|
39
|
-
'.output',
|
|
40
|
-
'.parcel-cache',
|
|
41
|
-
'.pytest_cache',
|
|
42
|
-
'.ruff_cache',
|
|
43
|
-
'.serverless',
|
|
44
|
-
'.sst',
|
|
45
|
-
'.turbo',
|
|
46
|
-
'.wrangler',
|
|
47
|
-
'.wrangler-shared',
|
|
48
|
-
'.moda',
|
|
49
|
-
'.moda-test',
|
|
50
|
-
'.moda-test-home',
|
|
51
|
-
'.moda-cli-workspaces',
|
|
52
|
-
'.moda-cli-benchmarks',
|
|
53
|
-
'node_modules',
|
|
54
|
-
'dist',
|
|
55
|
-
'build',
|
|
56
|
-
'coverage',
|
|
57
|
-
'out',
|
|
58
|
-
'target',
|
|
59
|
-
'logs',
|
|
60
|
-
'tmp',
|
|
61
|
-
'temp',
|
|
62
|
-
'__pycache__',
|
|
63
|
-
'.venv',
|
|
64
|
-
'venv',
|
|
65
|
-
]);
|
|
66
|
-
const SKIPPED_PATH_PREFIXES = [
|
|
67
|
-
'.claude/worktrees',
|
|
68
|
-
'.codex/environments',
|
|
69
|
-
];
|
|
70
|
-
const EXTERNAL_ANALYST_ADAPTERS = ['claude', 'codex', 'cursor'];
|
|
71
|
-
export async function runHarnessCommand(context) {
|
|
72
|
-
const subcommand = context.positionals[0] ?? 'scan';
|
|
73
|
-
const rootDir = resolve(context.cwd, context.flags.path ?? '.');
|
|
74
|
-
const selectedAgentId = selectedRuntimeAgentId(context.flags);
|
|
75
|
-
const tenant = resolveHarnessTenant(context.flags, context);
|
|
76
|
-
const scanPlan = buildCommandScanPlan(context, rootDir);
|
|
77
|
-
switch (subcommand) {
|
|
78
|
-
case 'scan': {
|
|
79
|
-
await approveScanIfNeeded(scanPlan, { context });
|
|
80
|
-
writeHumanProgress(context, 'Harness scan: inventorying local files...');
|
|
81
|
-
const graph = scanHarness({
|
|
82
|
-
rootDir,
|
|
83
|
-
dryRun: context.dryRun,
|
|
84
|
-
tenantId: tenant?.tenantId,
|
|
85
|
-
tenantSource: tenant?.source,
|
|
86
|
-
pathWasExplicit: context.flags.path !== undefined,
|
|
87
|
-
});
|
|
88
|
-
const written = context.dryRun ? [] : writeHarnessArtifacts(graph, rootDir, {
|
|
89
|
-
summary: context.flags.summary === 'true',
|
|
90
|
-
});
|
|
91
|
-
writeHumanProgress(context, 'Harness scan: complete.');
|
|
92
|
-
context.output.writeData({
|
|
93
|
-
scanPlan,
|
|
94
|
-
summary: summarizeHarness(graph),
|
|
95
|
-
graph,
|
|
96
|
-
written,
|
|
97
|
-
dryRun: context.dryRun,
|
|
98
|
-
});
|
|
99
|
-
return { exitCode: 0 };
|
|
100
|
-
}
|
|
101
|
-
case 'sync': {
|
|
102
|
-
if (!resolveApiKey({ profile: context.profile, env: context.env }) && !context.dryRun) {
|
|
103
|
-
throw new CliInputError('No Moda API key found for harness sync.', 'Run `moda init` first, set MODA_API_KEY, or select a profile with an API key.');
|
|
104
|
-
}
|
|
105
|
-
warnFresherSubdirHarnessReports(context, rootDir, context.flags['from-report'] ?? context.flags.report);
|
|
106
|
-
if (!context.dryRun) {
|
|
107
|
-
const identity = describeSyncIdentity({ profile: context.profile, env: context.env });
|
|
108
|
-
if (identity) {
|
|
109
|
-
// The banner's tenant comes from the winning key when it embeds
|
|
110
|
-
// one. Otherwise fall back to the flag/env/profile-resolved tenant
|
|
111
|
-
// but label it unverified — that source is precisely what a
|
|
112
|
-
// shadowing stale key disagrees with, so it must never be asserted
|
|
113
|
-
// as the destination.
|
|
114
|
-
const label = identity.tenantSource === 'api-key'
|
|
115
|
-
? describeSyncTenantLabel(identity)
|
|
116
|
-
: describeSyncTenantLabel({
|
|
117
|
-
tenantId: tenant?.tenantId ?? identity.tenantId,
|
|
118
|
-
tenantSource: 'config',
|
|
119
|
-
});
|
|
120
|
-
writeHumanProgress(context, `Syncing as ${identity.credential} → tenant ${label}`);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
if (context.flags['from-report'] || context.flags.report) {
|
|
124
|
-
const reportPath = resolveHarnessReportPath(rootDir, context.flags['from-report'] ?? context.flags.report);
|
|
125
|
-
const { report, validation, approval, approved } = loadHarnessReportForSync(rootDir, reportPath);
|
|
126
|
-
const reportHash = hashHarnessReport(report);
|
|
127
|
-
if (!approved && !context.dryRun) {
|
|
128
|
-
throw new CliInputError('Harness report is not approved; refusing to sync.', 'Run `moda harness validate-report`, review the report, then run `moda harness approve --yes` before syncing.');
|
|
129
|
-
}
|
|
130
|
-
const syncGraph = graphForReport(report.graph);
|
|
131
|
-
const sourceCommit = report.source.sourceCommit ?? getGitCommit(rootDir);
|
|
132
|
-
const localGraphHash = hashHarnessReportStableJson(syncGraph);
|
|
133
|
-
const plan = createHarnessSyncPlan({
|
|
134
|
-
context,
|
|
135
|
-
rootDir,
|
|
136
|
-
graph: report.graph,
|
|
137
|
-
source: 'approved_report',
|
|
138
|
-
sourceCommit,
|
|
139
|
-
localGraphHash,
|
|
140
|
-
localWrites: plannedHarnessArtifactWrites(context),
|
|
141
|
-
});
|
|
142
|
-
const written = context.dryRun ? [] : writeHarnessArtifacts(report.graph, rootDir, {
|
|
143
|
-
summary: context.flags.summary === 'true',
|
|
144
|
-
});
|
|
145
|
-
const syncBody = {
|
|
146
|
-
graph: syncGraph,
|
|
147
|
-
graphHash: localGraphHash,
|
|
148
|
-
sourceCommit,
|
|
149
|
-
report,
|
|
150
|
-
reportHash,
|
|
151
|
-
reportValidationStatus: validation.status,
|
|
152
|
-
reportApproval: approval ? {
|
|
153
|
-
approved: approval.approved,
|
|
154
|
-
approvedAt: approval.approvedAt,
|
|
155
|
-
approvedBy: approval.approvedBy,
|
|
156
|
-
reportHash: approval.reportHash,
|
|
157
|
-
} : undefined,
|
|
158
|
-
};
|
|
159
|
-
const response = await syncHarnessWithRemote(context, syncBody);
|
|
160
|
-
const statePath = response && !context.dryRun
|
|
161
|
-
? writeHarnessSyncState(rootDir, {
|
|
162
|
-
schemaVersion: 'sync_state.v0.1',
|
|
163
|
-
updatedAt: new Date().toISOString(),
|
|
164
|
-
resource: 'harness',
|
|
165
|
-
source: 'approved_report',
|
|
166
|
-
profile: plan.profile,
|
|
167
|
-
tenantId: response.tenantId ?? plan.tenantId,
|
|
168
|
-
rootPath: plan.rootPath,
|
|
169
|
-
sourceCommit,
|
|
170
|
-
harnessId: report.graph.harnessId,
|
|
171
|
-
localGraphHash,
|
|
172
|
-
report: {
|
|
173
|
-
reportId: report.reportId,
|
|
174
|
-
reportHash,
|
|
175
|
-
validationStatus: validation.status,
|
|
176
|
-
},
|
|
177
|
-
remote: response,
|
|
178
|
-
summary: summarizeHarness(report.graph),
|
|
179
|
-
uploads: plan.uploads,
|
|
180
|
-
privacy: plan.privacy,
|
|
181
|
-
written,
|
|
182
|
-
})
|
|
183
|
-
: undefined;
|
|
184
|
-
context.output.writeData({
|
|
185
|
-
plan,
|
|
186
|
-
report: {
|
|
187
|
-
reportId: report.reportId,
|
|
188
|
-
reportHash,
|
|
189
|
-
path: normalizePath(relative(rootDir, reportPath)) || '.moda/harness-report.json',
|
|
190
|
-
},
|
|
191
|
-
validation,
|
|
192
|
-
approval: {
|
|
193
|
-
approved,
|
|
194
|
-
approvedAt: approval?.approvedAt,
|
|
195
|
-
approvedBy: approval?.approvedBy,
|
|
196
|
-
},
|
|
197
|
-
summary: summarizeHarness(report.graph),
|
|
198
|
-
remote: response ?? plannedDryRunRemote(plan),
|
|
199
|
-
written,
|
|
200
|
-
syncState: statePath ? { path: statePath } : undefined,
|
|
201
|
-
dryRun: context.dryRun,
|
|
202
|
-
});
|
|
203
|
-
return { exitCode: 0 };
|
|
204
|
-
}
|
|
205
|
-
const graph = scanHarness({
|
|
206
|
-
rootDir,
|
|
207
|
-
dryRun: context.dryRun,
|
|
208
|
-
tenantId: tenant?.tenantId,
|
|
209
|
-
tenantSource: tenant?.source,
|
|
210
|
-
pathWasExplicit: context.flags.path !== undefined,
|
|
211
|
-
});
|
|
212
|
-
const syncGraph = graphForSync(graph);
|
|
213
|
-
const sourceCommit = getGitCommit(rootDir);
|
|
214
|
-
const localGraphHash = hashStableJson(syncGraph);
|
|
215
|
-
const plan = createHarnessSyncPlan({
|
|
216
|
-
context,
|
|
217
|
-
rootDir,
|
|
218
|
-
graph,
|
|
219
|
-
source: 'local_scan',
|
|
220
|
-
sourceCommit,
|
|
221
|
-
localGraphHash,
|
|
222
|
-
localWrites: plannedHarnessArtifactWrites(context),
|
|
223
|
-
});
|
|
224
|
-
const written = context.dryRun ? [] : writeHarnessArtifacts(graph, rootDir, {
|
|
225
|
-
summary: context.flags.summary === 'true',
|
|
226
|
-
});
|
|
227
|
-
const response = await syncHarnessWithRemote(context, {
|
|
228
|
-
graph: syncGraph,
|
|
229
|
-
graphHash: localGraphHash,
|
|
230
|
-
sourceCommit,
|
|
231
|
-
});
|
|
232
|
-
const statePath = response && !context.dryRun
|
|
233
|
-
? writeHarnessSyncState(rootDir, {
|
|
234
|
-
schemaVersion: 'sync_state.v0.1',
|
|
235
|
-
updatedAt: new Date().toISOString(),
|
|
236
|
-
resource: 'harness',
|
|
237
|
-
source: 'local_scan',
|
|
238
|
-
profile: plan.profile,
|
|
239
|
-
tenantId: response.tenantId ?? plan.tenantId,
|
|
240
|
-
rootPath: plan.rootPath,
|
|
241
|
-
sourceCommit,
|
|
242
|
-
harnessId: graph.harnessId,
|
|
243
|
-
localGraphHash,
|
|
244
|
-
remote: response,
|
|
245
|
-
summary: summarizeHarness(graph),
|
|
246
|
-
uploads: plan.uploads,
|
|
247
|
-
privacy: plan.privacy,
|
|
248
|
-
written,
|
|
249
|
-
})
|
|
250
|
-
: undefined;
|
|
251
|
-
context.output.writeData({
|
|
252
|
-
scanPlan,
|
|
253
|
-
plan,
|
|
254
|
-
summary: summarizeHarness(graph),
|
|
255
|
-
remote: response ?? plannedDryRunRemote(plan),
|
|
256
|
-
written,
|
|
257
|
-
syncState: statePath ? { path: statePath } : undefined,
|
|
258
|
-
dryRun: context.dryRun,
|
|
259
|
-
});
|
|
260
|
-
return { exitCode: 0 };
|
|
261
|
-
}
|
|
262
|
-
case 'delete': {
|
|
263
|
-
const idOrKey = context.positionals[1]
|
|
264
|
-
?? (context.flags.harness && context.flags.harness !== 'true' ? context.flags.harness : undefined);
|
|
265
|
-
if (!idOrKey) {
|
|
266
|
-
throw new CliInputError('Missing harness id or key to delete.', 'Usage: moda harness delete <harness-id-or-key> [--yes]');
|
|
267
|
-
}
|
|
268
|
-
const profileOptions = { profile: context.profile, env: context.env };
|
|
269
|
-
if (!resolveApiKey(profileOptions) && !context.dryRun) {
|
|
270
|
-
throw new CliInputError('No Moda API key found for harness delete.', 'Run `moda init` first, set MODA_API_KEY, or select a profile with an API key.');
|
|
271
|
-
}
|
|
272
|
-
// Identity banner (same pattern as sync): deletion is destructive and
|
|
273
|
-
// tenant-scoped, so the resolved credential and destination tenant must
|
|
274
|
-
// be visible BEFORE the user confirms — a shadowed or stale key would
|
|
275
|
-
// otherwise delete from the wrong tenant silently.
|
|
276
|
-
const identity = describeSyncIdentity(profileOptions);
|
|
277
|
-
if (identity) {
|
|
278
|
-
const label = identity.tenantSource === 'api-key'
|
|
279
|
-
? describeSyncTenantLabel(identity)
|
|
280
|
-
: describeSyncTenantLabel({
|
|
281
|
-
tenantId: tenant?.tenantId ?? identity.tenantId,
|
|
282
|
-
tenantSource: 'config',
|
|
283
|
-
});
|
|
284
|
-
writeHumanProgress(context, `Deleting as ${identity.credential} → tenant ${label}`);
|
|
285
|
-
}
|
|
286
|
-
if (context.dryRun) {
|
|
287
|
-
context.output.writeData({
|
|
288
|
-
endpoint: `/api/harnesses/${idOrKey}`,
|
|
289
|
-
harnessKey: idOrKey,
|
|
290
|
-
deleted: false,
|
|
291
|
-
dryRun: true,
|
|
292
|
-
});
|
|
293
|
-
return { exitCode: 0 };
|
|
294
|
-
}
|
|
295
|
-
await confirmHarnessDelete(context, idOrKey);
|
|
296
|
-
const response = await callControlAPI(`/harnesses/${encodeURIComponent(idOrKey)}`, { method: 'DELETE' }, profileOptions);
|
|
297
|
-
writeHumanProgress(context, `Deleted harness ${response.harnessKey ?? idOrKey} from tenant ${response.tenantId ?? 'unknown'} ` +
|
|
298
|
-
`(${response.deleted?.versions ?? 0} versions, ${response.deleted?.agents ?? 0} agents, ` +
|
|
299
|
-
`${response.deleted?.artifacts ?? 0} artifacts, ${response.deleted?.relationships ?? 0} relationships).`);
|
|
300
|
-
context.output.writeData({ ...response, dryRun: false });
|
|
301
|
-
return { exitCode: 0 };
|
|
302
|
-
}
|
|
303
|
-
case 'analyze': {
|
|
304
|
-
await approveScanIfNeeded(scanPlan, {
|
|
305
|
-
context,
|
|
306
|
-
message: 'Run Moda Analyst to investigate this project and produce a cited harness report?',
|
|
307
|
-
declinedMessage: 'Harness analysis was not approved.',
|
|
308
|
-
declinedHint: 'Run `moda harness analyze --path=<dir>` for a narrower investigation, or rerun with `--yes` after reviewing the read plan.',
|
|
309
|
-
});
|
|
310
|
-
context.output.writeEvent({
|
|
311
|
-
event: 'started',
|
|
312
|
-
phase: 'harness_analyze',
|
|
313
|
-
message: 'Starting Moda Analyst harness analysis.',
|
|
314
|
-
elapsed_ms: Date.now() - context.startedAt,
|
|
315
|
-
});
|
|
316
|
-
if (isRemoteHarnessAnalyze(context)) {
|
|
317
|
-
return runRemoteHarnessAnalyst({ context, rootDir, scanPlan, tenant });
|
|
318
|
-
}
|
|
319
|
-
const analyst = await resolveHarnessAnalystAdapter(context);
|
|
320
|
-
if (analyst !== 'local-scan') {
|
|
321
|
-
return runExternalHarnessAnalyst({
|
|
322
|
-
context,
|
|
323
|
-
rootDir,
|
|
324
|
-
scanPlan,
|
|
325
|
-
tenant,
|
|
326
|
-
adapter: analyst,
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
context.output.writeEvent({
|
|
330
|
-
event: 'progress',
|
|
331
|
-
phase: 'inventory',
|
|
332
|
-
message: 'Inventorying local harness files.',
|
|
333
|
-
elapsed_ms: Date.now() - context.startedAt,
|
|
334
|
-
});
|
|
335
|
-
writeHumanProgress(context, 'Moda Analyst: inventorying local harness files...');
|
|
336
|
-
const graph = scanHarness({
|
|
337
|
-
rootDir,
|
|
338
|
-
dryRun: context.dryRun,
|
|
339
|
-
tenantId: tenant?.tenantId,
|
|
340
|
-
tenantSource: tenant?.source,
|
|
341
|
-
pathWasExplicit: context.flags.path !== undefined,
|
|
342
|
-
});
|
|
343
|
-
context.output.writeEvent({
|
|
344
|
-
event: 'progress',
|
|
345
|
-
phase: 'report',
|
|
346
|
-
message: 'Producing cited structured harness report.',
|
|
347
|
-
elapsed_ms: Date.now() - context.startedAt,
|
|
348
|
-
});
|
|
349
|
-
writeHumanProgress(context, 'Moda Analyst: producing cited structured harness report...');
|
|
350
|
-
const report = createHarnessReport({
|
|
351
|
-
graph,
|
|
352
|
-
rootDir,
|
|
353
|
-
sourceCommit: getGitCommit(rootDir),
|
|
354
|
-
analystAdapter: 'local-scan',
|
|
355
|
-
});
|
|
356
|
-
const validation = validateHarnessReport(report, rootDir);
|
|
357
|
-
context.output.writeEvent({
|
|
358
|
-
event: 'progress',
|
|
359
|
-
phase: 'validation',
|
|
360
|
-
message: `Harness report validation ${validation.status}.`,
|
|
361
|
-
elapsed_ms: Date.now() - context.startedAt,
|
|
362
|
-
});
|
|
363
|
-
writeHumanProgress(context, `Moda Analyst: validation ${validation.status}.`);
|
|
364
|
-
const written = context.dryRun
|
|
365
|
-
? []
|
|
366
|
-
: [
|
|
367
|
-
...writeHarnessReportArtifacts({
|
|
368
|
-
rootDir,
|
|
369
|
-
report,
|
|
370
|
-
validation,
|
|
371
|
-
markdown: context.flags.summary === 'true' || context.outputMode === 'human',
|
|
372
|
-
}),
|
|
373
|
-
...writeHarnessArtifacts(report.graph, rootDir, {
|
|
374
|
-
summary: context.flags.summary === 'true',
|
|
375
|
-
}),
|
|
376
|
-
];
|
|
377
|
-
emitWrittenArtifacts(context, written, 'Written by Moda Analyst.');
|
|
378
|
-
writeHarnessAnalyzeResult(context, {
|
|
379
|
-
scanPlan,
|
|
380
|
-
summary: summarizeHarness(graph),
|
|
381
|
-
report,
|
|
382
|
-
validation,
|
|
383
|
-
written,
|
|
384
|
-
dryRun: context.dryRun,
|
|
385
|
-
});
|
|
386
|
-
return { exitCode: validation.status === 'pass' ? 0 : 1 };
|
|
387
|
-
}
|
|
388
|
-
case 'pull': {
|
|
389
|
-
return runHarnessPull({ context, rootDir, scanPlan });
|
|
390
|
-
}
|
|
391
|
-
case 'validate-report': {
|
|
392
|
-
const reportPath = resolveHarnessReportPath(rootDir, context.positionals[1] ?? context.flags.report);
|
|
393
|
-
const report = readHarnessReport(reportPath);
|
|
394
|
-
const validation = validateHarnessReport(report, rootDir);
|
|
395
|
-
if (!context.dryRun) {
|
|
396
|
-
writeHarnessReportArtifacts({
|
|
397
|
-
rootDir,
|
|
398
|
-
report,
|
|
399
|
-
validation,
|
|
400
|
-
markdown: context.flags.summary === 'true',
|
|
401
|
-
});
|
|
402
|
-
}
|
|
403
|
-
context.output.writeData(validation, { status: validation.status === 'pass' ? 'success' : 'error' });
|
|
404
|
-
return { exitCode: validation.status === 'pass' ? 0 : 1 };
|
|
405
|
-
}
|
|
406
|
-
case 'approve': {
|
|
407
|
-
warnFresherSubdirHarnessReports(context, rootDir, context.positionals[1] ?? context.flags.report);
|
|
408
|
-
const reportPath = resolveHarnessReportPath(rootDir, context.positionals[1] ?? context.flags.report);
|
|
409
|
-
const approval = await approveHarnessReport({
|
|
410
|
-
rootDir,
|
|
411
|
-
reportPath,
|
|
412
|
-
yes: context.yes,
|
|
413
|
-
interactive: context.interactive,
|
|
414
|
-
});
|
|
415
|
-
context.output.writeData({
|
|
416
|
-
report: {
|
|
417
|
-
reportId: approval.report.reportId,
|
|
418
|
-
reportHash: hashHarnessReport(approval.report),
|
|
419
|
-
path: normalizePath(relative(rootDir, reportPath)) || '.moda/harness-report.json',
|
|
420
|
-
},
|
|
421
|
-
validation: approval.validation,
|
|
422
|
-
approval: approval.approval,
|
|
423
|
-
});
|
|
424
|
-
return { exitCode: 0 };
|
|
425
|
-
}
|
|
426
|
-
case 'candidates': {
|
|
427
|
-
await approveScanIfNeeded(scanPlan, {
|
|
428
|
-
context,
|
|
429
|
-
message: 'Scan this project for static harness candidates?',
|
|
430
|
-
});
|
|
431
|
-
writeHumanProgress(context, 'Harness candidates: static pre-pass (no LLM, no network)...');
|
|
432
|
-
const report = detectHarnessCandidates(rootDir);
|
|
433
|
-
const written = [];
|
|
434
|
-
const outFlag = context.flags.out;
|
|
435
|
-
// A value-less `--out` is parsed as the sentinel string 'true'; reject it
|
|
436
|
-
// rather than silently writing the report to `<cwd>/true`.
|
|
437
|
-
if (outFlag === 'true') {
|
|
438
|
-
throw new CliInputError('Missing output path for --out.', 'Usage: moda harness candidates --out=<file>');
|
|
439
|
-
}
|
|
440
|
-
if (outFlag && !context.dryRun) {
|
|
441
|
-
written.push(writeHarnessCandidatesReport(report, resolve(context.cwd, outFlag)));
|
|
442
|
-
}
|
|
443
|
-
writeHumanProgress(context, `Harness candidates: ${report.stats.candidateCount} candidates across ` +
|
|
444
|
-
`${report.stats.filesWithCandidates} files (${report.stats.filesScanned} scanned, ` +
|
|
445
|
-
`${report.stats.filesPerSecond.toLocaleString()} files/sec).`);
|
|
446
|
-
context.output.writeData({ scanPlan, ...report, written, dryRun: context.dryRun });
|
|
447
|
-
return { exitCode: 0 };
|
|
448
|
-
}
|
|
449
|
-
case 'agents': {
|
|
450
|
-
const graph = loadOrScanHarness(rootDir, tenant, context);
|
|
451
|
-
context.output.writeData({
|
|
452
|
-
summary: summarizeHarness(graph),
|
|
453
|
-
agentFamilies: graph.agentFamilies,
|
|
454
|
-
runtimeAgents: graph.runtimeAgents,
|
|
455
|
-
unknowns: graph.unknowns,
|
|
456
|
-
});
|
|
457
|
-
return { exitCode: 0 };
|
|
458
|
-
}
|
|
459
|
-
case 'graph': {
|
|
460
|
-
const graph = loadOrScanHarness(rootDir, tenant, context);
|
|
461
|
-
context.output.writeData(graph);
|
|
462
|
-
return { exitCode: 0 };
|
|
463
|
-
}
|
|
464
|
-
case 'explain': {
|
|
465
|
-
const graph = loadOrScanHarness(rootDir, tenant, context);
|
|
466
|
-
if (selectedAgentId) {
|
|
467
|
-
const agent = graph.runtimeAgents.find((candidate) => candidate.id === selectedAgentId);
|
|
468
|
-
if (!agent) {
|
|
469
|
-
throw new CliInputError(`Runtime agent '${selectedAgentId}' was not found in the harness graph.`, 'Run `moda harness agents` to inspect detected runtime agents.');
|
|
470
|
-
}
|
|
471
|
-
context.output.writeData(explainAgent(graph, agent));
|
|
472
|
-
return { exitCode: 0 };
|
|
473
|
-
}
|
|
474
|
-
context.output.writeData({
|
|
475
|
-
summary: summarizeHarness(graph),
|
|
476
|
-
markdown: renderHarnessMarkdown(graph),
|
|
477
|
-
unknowns: graph.unknowns,
|
|
478
|
-
});
|
|
479
|
-
return { exitCode: 0 };
|
|
480
|
-
}
|
|
481
|
-
case 'write-summary': {
|
|
482
|
-
const graph = loadOrScanHarness(rootDir, tenant);
|
|
483
|
-
const summaryPath = writeHarnessSummary(graph, rootDir);
|
|
484
|
-
context.output.writeData({
|
|
485
|
-
summary: summarizeHarness(graph),
|
|
486
|
-
written: [summaryPath],
|
|
487
|
-
});
|
|
488
|
-
return { exitCode: 0 };
|
|
489
|
-
}
|
|
490
|
-
case 'status': {
|
|
491
|
-
const manifestPath = join(rootDir, HARNESS_PATH);
|
|
492
|
-
const graph = existsSync(manifestPath)
|
|
493
|
-
? readHarnessGraph(manifestPath)
|
|
494
|
-
: scanHarness({
|
|
495
|
-
rootDir,
|
|
496
|
-
tenantId: tenant?.tenantId,
|
|
497
|
-
tenantSource: tenant?.source,
|
|
498
|
-
});
|
|
499
|
-
const currentGraphHash = hashStableJson(graphForSync(graph));
|
|
500
|
-
const syncState = readHarnessSyncState(rootDir);
|
|
501
|
-
const remoteRun = readRemoteRunState(rootDir);
|
|
502
|
-
context.output.writeData({
|
|
503
|
-
manifest: HARNESS_PATH,
|
|
504
|
-
exists: existsSync(manifestPath),
|
|
505
|
-
summary: summarizeHarness(graph),
|
|
506
|
-
graphHash: currentGraphHash,
|
|
507
|
-
remoteRun: remoteRun
|
|
508
|
-
? {
|
|
509
|
-
statePath: HARNESS_REMOTE_RUN_STATE_PATH,
|
|
510
|
-
runId: remoteRun.run_id,
|
|
511
|
-
status: remoteRun.status,
|
|
512
|
-
startedAt: remoteRun.started_at_iso || undefined,
|
|
513
|
-
}
|
|
514
|
-
: undefined,
|
|
515
|
-
sync: syncState
|
|
516
|
-
? {
|
|
517
|
-
statePath: SYNC_STATE_PATH,
|
|
518
|
-
lastSyncedAt: syncState.updatedAt,
|
|
519
|
-
profile: syncState.profile,
|
|
520
|
-
tenantId: syncState.tenantId,
|
|
521
|
-
harnessId: syncState.harnessId,
|
|
522
|
-
remoteHarnessId: syncState.remote.harnessId,
|
|
523
|
-
remoteVersionId: syncState.remote.versionId,
|
|
524
|
-
remoteGraphHash: syncState.remote.graphHash,
|
|
525
|
-
localGraphHash: syncState.localGraphHash,
|
|
526
|
-
stale: syncState.localGraphHash !== currentGraphHash,
|
|
527
|
-
}
|
|
528
|
-
: {
|
|
529
|
-
statePath: SYNC_STATE_PATH,
|
|
530
|
-
lastSyncedAt: null,
|
|
531
|
-
stale: true,
|
|
532
|
-
},
|
|
533
|
-
stale: syncState ? syncState.localGraphHash !== currentGraphHash : true,
|
|
534
|
-
});
|
|
535
|
-
return { exitCode: 0 };
|
|
536
|
-
}
|
|
537
|
-
default:
|
|
538
|
-
throw new CliInputError(`Unknown harness command '${subcommand}'.`, 'Usage: moda harness analyze|pull|validate-report|approve|scan|candidates|sync|delete|agents|graph|explain|write-summary|status');
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
export function writeHarnessAnalyzeResult(context, payload) {
|
|
542
|
-
if (context.outputMode === 'human') {
|
|
543
|
-
if (!context.quiet)
|
|
544
|
-
console.log(renderHarnessAnalyzeHumanResult(payload));
|
|
545
|
-
return;
|
|
546
|
-
}
|
|
547
|
-
const validationPassed = payload.validation.status === 'pass';
|
|
548
|
-
context.output.writeData(payload, {
|
|
549
|
-
status: validationPassed ? 'success' : 'error',
|
|
550
|
-
summary: validationPassed
|
|
551
|
-
? 'Moda Analyst produced and validated a cited harness report.'
|
|
552
|
-
: 'Moda Analyst produced a harness report, but validation failed.',
|
|
553
|
-
findings: [
|
|
554
|
-
{
|
|
555
|
-
id: 'harness_analyze:validation',
|
|
556
|
-
title: 'Harness report validation',
|
|
557
|
-
status: payload.validation.status,
|
|
558
|
-
severity: validationPassed ? 'info' : 'error',
|
|
559
|
-
description: validationPassed
|
|
560
|
-
? 'The report is ready for human review and approval.'
|
|
561
|
-
: 'Review validation errors before syncing the harness graph.',
|
|
562
|
-
},
|
|
563
|
-
],
|
|
564
|
-
artifacts: payload.written.map((file) => ({
|
|
565
|
-
kind: file.includes('harness-report') || file.includes('HARNESS_REPORT')
|
|
566
|
-
? 'harness_report_artifact'
|
|
567
|
-
: 'harness_graph_artifact',
|
|
568
|
-
path: file,
|
|
569
|
-
description: 'Written by Moda Analyst.',
|
|
570
|
-
})),
|
|
571
|
-
nextCommands: validationPassed
|
|
572
|
-
? [
|
|
573
|
-
{
|
|
574
|
-
command: 'moda harness approve --yes',
|
|
575
|
-
purpose: 'Approve the cited report after human review.',
|
|
576
|
-
mutability: 'write',
|
|
577
|
-
requires_approval: true,
|
|
578
|
-
},
|
|
579
|
-
{
|
|
580
|
-
command: 'moda harness sync --from-report .moda/harness-report.json --summary',
|
|
581
|
-
purpose: 'Sync the approved harness graph to Moda Cloud.',
|
|
582
|
-
mutability: 'sync',
|
|
583
|
-
requires_approval: true,
|
|
584
|
-
},
|
|
585
|
-
]
|
|
586
|
-
: [
|
|
587
|
-
{
|
|
588
|
-
command: 'moda harness validate-report --summary',
|
|
589
|
-
purpose: 'Re-run citation and schema validation after fixing the report.',
|
|
590
|
-
mutability: 'read',
|
|
591
|
-
requires_approval: false,
|
|
592
|
-
},
|
|
593
|
-
],
|
|
594
|
-
});
|
|
595
|
-
}
|
|
596
|
-
/**
|
|
597
|
-
* Emit one `artifact` lifecycle event per written file, classifying report vs
|
|
598
|
-
* graph artifacts. Shared by the local-scan, external-analyst, and remote
|
|
599
|
-
* analyze paths.
|
|
600
|
-
*/
|
|
601
|
-
export function emitWrittenArtifacts(context, written, description) {
|
|
602
|
-
for (const file of written) {
|
|
603
|
-
context.output.writeEvent({
|
|
604
|
-
event: 'artifact',
|
|
605
|
-
phase: 'write',
|
|
606
|
-
artifact: {
|
|
607
|
-
kind: file.includes('harness-report') || file.includes('HARNESS_REPORT')
|
|
608
|
-
? 'harness_report_artifact'
|
|
609
|
-
: 'harness_graph_artifact',
|
|
610
|
-
path: file,
|
|
611
|
-
description,
|
|
612
|
-
},
|
|
613
|
-
elapsed_ms: Date.now() - context.startedAt,
|
|
614
|
-
});
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
async function runExternalHarnessAnalyst(options) {
|
|
618
|
-
if (options.context.dryRun) {
|
|
619
|
-
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.');
|
|
620
|
-
}
|
|
621
|
-
const prompt = renderExternalHarnessAnalystPrompt({
|
|
622
|
-
adapter: options.adapter,
|
|
623
|
-
rootDir: options.rootDir,
|
|
624
|
-
tenantId: options.tenant?.tenantId,
|
|
625
|
-
});
|
|
626
|
-
// Agent-stream mode also runs the analyst child in stream-json mode so its
|
|
627
|
-
// tool activity can be forwarded as NDJSON `progress` events (consumed by
|
|
628
|
-
// the hosted analyze runner for live run-state progress). Report extraction
|
|
629
|
-
// is unaffected: the report is read from .moda/harness-report.json.
|
|
630
|
-
const streamAnalystProgress = options.context.outputMode === 'agent-stream';
|
|
631
|
-
const invocation = resolveExternalHarnessAnalystInvocation({
|
|
632
|
-
adapter: options.adapter,
|
|
633
|
-
flags: options.context.flags,
|
|
634
|
-
prompt,
|
|
635
|
-
rootDir: options.rootDir,
|
|
636
|
-
stream: options.context.outputMode === 'human' || streamAnalystProgress,
|
|
637
|
-
});
|
|
638
|
-
options.context.output.writeEvent({
|
|
639
|
-
event: 'progress',
|
|
640
|
-
phase: 'analyst_launch',
|
|
641
|
-
message: `Launching ${analystDisplayName(options.adapter)} in read-only analyst mode.`,
|
|
642
|
-
elapsed_ms: Date.now() - options.context.startedAt,
|
|
643
|
-
});
|
|
644
|
-
const inheritedOutput = options.context.outputMode === 'human';
|
|
645
|
-
const analystTui = shouldUseAnalystTui(options.context.flags, options.context.outputMode) && invocation.streamEvents && !options.context.quiet
|
|
646
|
-
? new AnalystTui({
|
|
647
|
-
adapterName: analystDisplayName(options.adapter),
|
|
648
|
-
command: invocation.command,
|
|
649
|
-
root: options.rootDir,
|
|
650
|
-
reportPath: HARNESS_REPORT_PATH,
|
|
651
|
-
startedAt: options.context.startedAt,
|
|
652
|
-
})
|
|
653
|
-
: undefined;
|
|
654
|
-
if (analystTui) {
|
|
655
|
-
analystTui.start();
|
|
656
|
-
}
|
|
657
|
-
else {
|
|
658
|
-
writeHumanBlock(options.context, renderHarnessAnalystLaunch({
|
|
659
|
-
adapterName: analystDisplayName(options.adapter),
|
|
660
|
-
command: invocation.command,
|
|
661
|
-
root: options.rootDir,
|
|
662
|
-
reportPath: HARNESS_REPORT_PATH,
|
|
663
|
-
}));
|
|
664
|
-
}
|
|
665
|
-
const timeoutMs = numericEnv('MODA_HARNESS_ANALYST_TIMEOUT_MS', 20 * 60 * 1000);
|
|
666
|
-
try {
|
|
667
|
-
const result = await runExternalAnalystWithRetries(invocation, {
|
|
668
|
-
cwd: options.rootDir,
|
|
669
|
-
env: buildExternalAnalystEnv(options.context.env),
|
|
670
|
-
inheritedOutput,
|
|
671
|
-
timeoutMs,
|
|
672
|
-
maxBuffer: 20 * 1024 * 1024,
|
|
673
|
-
progress: (inheritedOutput && !options.context.quiet) || options.context.outputMode === 'agent-stream',
|
|
674
|
-
progressToStderr: inheritedOutput && !options.context.quiet && !analystTui,
|
|
675
|
-
streamEvents: invocation.streamEvents,
|
|
676
|
-
adapter: invocation.adapter,
|
|
677
|
-
tui: analystTui,
|
|
678
|
-
maxAttempts: externalAnalystMaxAttempts(options.adapter),
|
|
679
|
-
backoffMs: numericEnvAllowZero('MODA_HARNESS_ANALYST_RETRY_BACKOFF_MS', 6_000),
|
|
680
|
-
onProgress: (message, phase = 'analyst') => {
|
|
681
|
-
options.context.output.writeEvent({
|
|
682
|
-
event: 'progress',
|
|
683
|
-
phase,
|
|
684
|
-
message,
|
|
685
|
-
elapsed_ms: Date.now() - options.context.startedAt,
|
|
686
|
-
});
|
|
687
|
-
},
|
|
688
|
-
// Forward the analyst's parsed tool/text events as progress events so
|
|
689
|
-
// agent-stream consumers see live activity ("reading src/agent.ts"),
|
|
690
|
-
// not just the periodic still-investigating ticker.
|
|
691
|
-
onAgentEvent: streamAnalystProgress
|
|
692
|
-
? (event) => {
|
|
693
|
-
const progress = analystEventProgress(event);
|
|
694
|
-
if (!progress)
|
|
695
|
-
return;
|
|
696
|
-
options.context.output.writeEvent({
|
|
697
|
-
event: 'progress',
|
|
698
|
-
phase: progress.phase,
|
|
699
|
-
message: progress.message,
|
|
700
|
-
elapsed_ms: Date.now() - options.context.startedAt,
|
|
701
|
-
});
|
|
702
|
-
}
|
|
703
|
-
: undefined,
|
|
704
|
-
});
|
|
705
|
-
if (result.error) {
|
|
706
|
-
throw new CliInputError(`Harness analyst ${options.adapter} failed to run: ${result.error.message}`, 'Check that the selected coding agent CLI is installed and authenticated, or run `moda harness analyze --analyst=local-scan` for the deterministic fallback.');
|
|
707
|
-
}
|
|
708
|
-
if (result.timedOut) {
|
|
709
|
-
throw new CliInputError(`Harness analyst ${options.adapter} timed out after ${Math.round(timeoutMs / 1000)}s.`, 'Run from a narrower agent project root, pass --path=<agent-dir>, or retry after the analyst CLI is responsive.');
|
|
710
|
-
}
|
|
711
|
-
if (result.maxBufferExceeded) {
|
|
712
|
-
throw new CliInputError(`Harness analyst ${options.adapter} produced more than 20MiB of output.`, 'Retry with a narrower --path or ask the analyst to write the report directly instead of printing large logs.');
|
|
713
|
-
}
|
|
714
|
-
if (result.status !== 0) {
|
|
715
|
-
const output = inheritedOutput
|
|
716
|
-
? ''
|
|
717
|
-
: truncateText(`${result.stdout}\n${result.stderr}`, 1200);
|
|
718
|
-
throw new CliInputError(`Harness analyst ${options.adapter} exited with status ${result.status ?? 'unknown'}.`, output || 'Rerun with the selected coding agent CLI fixed, or choose another analyst with `--analyst=claude|codex|cursor`.');
|
|
719
|
-
}
|
|
720
|
-
const report = readExternalAnalystReport(options.rootDir, result, options.adapter);
|
|
721
|
-
analystTui?.setSummary({
|
|
722
|
-
runtimeAgentCount: report.graph.runtimeAgents.length,
|
|
723
|
-
agentFamilyCount: report.graph.agentFamilies.length,
|
|
724
|
-
artifactCount: report.graph.artifacts.length,
|
|
725
|
-
modelProviderCount: report.graph.modelProviders.length,
|
|
726
|
-
unknownCount: report.graph.unknowns.length,
|
|
727
|
-
claimCount: report.claims.length,
|
|
728
|
-
citationCount: report.citations.length,
|
|
729
|
-
relationshipCount: report.graph.relationships.length,
|
|
730
|
-
});
|
|
731
|
-
options.context.output.writeEvent({
|
|
732
|
-
event: 'progress',
|
|
733
|
-
phase: 'validation',
|
|
734
|
-
message: `Validating ${HARNESS_REPORT_PATH}.`,
|
|
735
|
-
elapsed_ms: Date.now() - options.context.startedAt,
|
|
736
|
-
});
|
|
737
|
-
analystTui?.setValidation('running');
|
|
738
|
-
const validation = validateHarnessReport(report, options.rootDir);
|
|
739
|
-
analystTui?.setValidation(validation.status === 'pass' ? 'pass' : 'fail');
|
|
740
|
-
if (!analystTui)
|
|
741
|
-
writeHumanProgress(options.context, `Moda Analyst: validation ${validation.status}.`);
|
|
742
|
-
const written = [
|
|
743
|
-
...writeHarnessReportArtifacts({
|
|
744
|
-
rootDir: options.rootDir,
|
|
745
|
-
report,
|
|
746
|
-
validation,
|
|
747
|
-
markdown: options.context.flags.summary === 'true' || options.context.outputMode === 'human',
|
|
748
|
-
}),
|
|
749
|
-
];
|
|
750
|
-
if (validation.status === 'pass') {
|
|
751
|
-
written.push(...writeHarnessArtifacts(report.graph, options.rootDir, {
|
|
752
|
-
summary: options.context.flags.summary === 'true',
|
|
753
|
-
}));
|
|
754
|
-
}
|
|
755
|
-
analystTui?.setArtifacts(written);
|
|
756
|
-
emitWrittenArtifacts(options.context, written, 'Written by Moda Analyst.');
|
|
757
|
-
analystTui?.complete(validation.status === 'pass' ? 'pass' : 'fail');
|
|
758
|
-
analystTui?.stop();
|
|
759
|
-
writeHarnessAnalyzeResult(options.context, {
|
|
760
|
-
scanPlan: options.scanPlan,
|
|
761
|
-
summary: validation.status === 'pass'
|
|
762
|
-
? summarizeHarness(report.graph)
|
|
763
|
-
: summarizeInvalidAnalystReport(report),
|
|
764
|
-
report,
|
|
765
|
-
validation,
|
|
766
|
-
written,
|
|
767
|
-
dryRun: false,
|
|
768
|
-
});
|
|
769
|
-
return { exitCode: validation.status === 'pass' ? 0 : 1 };
|
|
770
|
-
}
|
|
771
|
-
catch (error) {
|
|
772
|
-
analystTui?.complete('fail');
|
|
773
|
-
analystTui?.stop();
|
|
774
|
-
throw error;
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
function buildExternalAnalystEnv(env) {
|
|
778
|
-
const next = {
|
|
779
|
-
...env,
|
|
780
|
-
MODA_CLI_TELEMETRY: '0',
|
|
781
|
-
DO_NOT_TRACK: '1',
|
|
782
|
-
};
|
|
783
|
-
const analystHome = env.MODA_HARNESS_ANALYST_HOME;
|
|
784
|
-
if (analystHome && analystHome !== 'true') {
|
|
785
|
-
next.HOME = analystHome === '~' ? homedir() : analystHome;
|
|
786
|
-
next.USERPROFILE = next.HOME;
|
|
787
|
-
}
|
|
788
|
-
return next;
|
|
789
|
-
}
|
|
790
|
-
function readExternalAnalystReport(rootDir, result, adapter) {
|
|
791
|
-
const reportPath = resolve(rootDir, HARNESS_REPORT_PATH);
|
|
792
|
-
if (existsSync(reportPath))
|
|
793
|
-
return repairExternalAnalystReport(readHarnessReport(reportPath), rootDir);
|
|
794
|
-
const output = [
|
|
795
|
-
result.stdout,
|
|
796
|
-
result.stderr,
|
|
797
|
-
extractAgentTextFromOutput(result.stdout),
|
|
798
|
-
extractAgentTextFromOutput(result.stderr),
|
|
799
|
-
].filter(Boolean).join('\n');
|
|
800
|
-
const parsed = extractHarnessReportFromText(output, rootDir);
|
|
801
|
-
if (parsed)
|
|
802
|
-
return parsed;
|
|
803
|
-
throw new CliInputError(`Harness analyst ${adapter} did not return a harness report.`, [
|
|
804
|
-
`Expected ${HARNESS_REPORT_PATH} or final JSON between ${REPORT_JSON_BEGIN} and ${REPORT_JSON_END}.`,
|
|
805
|
-
'Retry with the selected coding agent CLI fixed, choose another analyst, or run `moda harness analyze --analyst=local-scan --summary` for deterministic fallback.',
|
|
806
|
-
].join('\n'));
|
|
807
|
-
}
|
|
808
|
-
function extractHarnessReportFromText(output, rootDir) {
|
|
809
|
-
for (const candidate of reportJsonCandidates(output)) {
|
|
810
|
-
const parsed = parseHarnessReportCandidate(candidate);
|
|
811
|
-
if (parsed)
|
|
812
|
-
return repairExternalAnalystReport(parsed, rootDir);
|
|
813
|
-
}
|
|
814
|
-
return undefined;
|
|
815
|
-
}
|
|
816
|
-
function reportJsonCandidates(output) {
|
|
817
|
-
const candidates = [];
|
|
818
|
-
const markerPattern = new RegExp(`${escapeRegExp(REPORT_JSON_BEGIN)}\\s*([\\s\\S]*?)\\s*${escapeRegExp(REPORT_JSON_END)}`, 'g');
|
|
819
|
-
for (const match of output.matchAll(markerPattern)) {
|
|
820
|
-
if (match[1])
|
|
821
|
-
candidates.push(match[1]);
|
|
822
|
-
}
|
|
823
|
-
const fencePattern = /```(?:json)?\s*([\s\S]*?)```/gi;
|
|
824
|
-
for (const match of output.matchAll(fencePattern)) {
|
|
825
|
-
if (match[1]?.includes('harness_report.v0.1'))
|
|
826
|
-
candidates.push(match[1]);
|
|
827
|
-
}
|
|
828
|
-
const firstBrace = output.indexOf('{');
|
|
829
|
-
const lastBrace = output.lastIndexOf('}');
|
|
830
|
-
if (firstBrace !== -1 && lastBrace > firstBrace) {
|
|
831
|
-
candidates.push(output.slice(firstBrace, lastBrace + 1));
|
|
832
|
-
}
|
|
833
|
-
return candidates;
|
|
834
|
-
}
|
|
835
|
-
function parseHarnessReportCandidate(candidate) {
|
|
836
|
-
try {
|
|
837
|
-
const parsed = JSON.parse(candidate.trim());
|
|
838
|
-
if (isRecord(parsed) && parsed.schemaVersion === 'harness_report.v0.1') {
|
|
839
|
-
return parsed;
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
catch {
|
|
843
|
-
return undefined;
|
|
844
|
-
}
|
|
845
|
-
return undefined;
|
|
846
|
-
}
|
|
847
|
-
export function repairExternalAnalystReport(report, rootDir) {
|
|
848
|
-
const normalized = normalizeHarnessReport(report);
|
|
849
|
-
repairCitationLines(normalized, rootDir);
|
|
850
|
-
// Agent/hosted analysts reliably enumerate prompt/tool artifacts but often omit
|
|
851
|
-
// the verbatim `body`. Backfill it from source the same way local-scan does so
|
|
852
|
-
// external reports satisfy the prompt/tool body requirement.
|
|
853
|
-
hydratePromptAndToolBodies(normalized.graph, rootDir);
|
|
854
|
-
repairReportSummaryCounts(normalized);
|
|
855
|
-
return normalized;
|
|
856
|
-
}
|
|
857
|
-
function repairReportSummaryCounts(report) {
|
|
858
|
-
const graph = report.graph;
|
|
859
|
-
report.summary = {
|
|
860
|
-
...report.summary,
|
|
861
|
-
runtimeAgentCount: graph.runtimeAgents.length,
|
|
862
|
-
agentFamilyCount: graph.agentFamilies.length,
|
|
863
|
-
artifactCount: graph.artifacts.length,
|
|
864
|
-
sharedArtifactCount: graph.artifacts.filter((artifact) => artifact.scope === 'shared').length,
|
|
865
|
-
channelAdapterCount: graph.channelAdapters.length,
|
|
866
|
-
identityNamespaceCount: graph.identityNamespaces.length,
|
|
867
|
-
environmentCount: graph.environments.length,
|
|
868
|
-
agentFrameworkCount: graph.agentFrameworks.length,
|
|
869
|
-
modelProviderCount: graph.modelProviders.length,
|
|
870
|
-
deploymentCount: graph.deployments.length,
|
|
871
|
-
relationshipCount: graph.relationships.length,
|
|
872
|
-
unknownCount: graph.unknowns.length,
|
|
873
|
-
};
|
|
874
|
-
}
|
|
875
|
-
function repairCitationLines(report, rootDir) {
|
|
876
|
-
for (const citation of report.citations) {
|
|
877
|
-
if ((citation.type !== 'file' && citation.type !== 'generated_doc') || !citation.path || !citation.excerpt)
|
|
878
|
-
continue;
|
|
879
|
-
const excerpt = citation.excerpt;
|
|
880
|
-
const candidatePath = resolve(rootDir, citation.path);
|
|
881
|
-
if (!existsSync(candidatePath))
|
|
882
|
-
continue;
|
|
883
|
-
let text;
|
|
884
|
-
try {
|
|
885
|
-
text = readFileSync(candidatePath, 'utf8');
|
|
886
|
-
}
|
|
887
|
-
catch {
|
|
888
|
-
continue;
|
|
889
|
-
}
|
|
890
|
-
const lines = text.split(/\r?\n/);
|
|
891
|
-
const currentLine = typeof citation.line === 'number' ? lines[citation.line - 1] : undefined;
|
|
892
|
-
if (currentLine && citationLineMatches(currentLine, excerpt))
|
|
893
|
-
continue;
|
|
894
|
-
const matchedIndex = lines.findIndex((line) => citationLineMatches(line, excerpt));
|
|
895
|
-
if (matchedIndex >= 0)
|
|
896
|
-
citation.line = matchedIndex + 1;
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
function citationLineMatches(line, excerpt) {
|
|
900
|
-
return normalizeComparableLine(line).includes(normalizeComparableLine(excerpt));
|
|
901
|
-
}
|
|
902
|
-
function normalizeComparableLine(value) {
|
|
903
|
-
return value.replace(/\s+/g, ' ').trim();
|
|
904
|
-
}
|
|
905
|
-
function extractAgentTextFromOutput(output) {
|
|
906
|
-
const lines = [];
|
|
907
|
-
for (const line of output.split(/\r?\n/)) {
|
|
908
|
-
const trimmed = line.trim();
|
|
909
|
-
if (!trimmed)
|
|
910
|
-
continue;
|
|
911
|
-
try {
|
|
912
|
-
const event = JSON.parse(trimmed);
|
|
913
|
-
const text = extractTextFromAgentEvent(event);
|
|
914
|
-
if (text)
|
|
915
|
-
lines.push(text);
|
|
916
|
-
}
|
|
917
|
-
catch {
|
|
918
|
-
lines.push(trimmed);
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
return lines.join('\n');
|
|
922
|
-
}
|
|
923
|
-
function extractTextFromAgentEvent(event) {
|
|
924
|
-
if (!isRecord(event))
|
|
925
|
-
return undefined;
|
|
926
|
-
const directText = stringValue(event.text) ??
|
|
927
|
-
stringValue(event.message) ??
|
|
928
|
-
stringValue(event.delta) ??
|
|
929
|
-
stringValue(event.result);
|
|
930
|
-
if (directText)
|
|
931
|
-
return directText;
|
|
932
|
-
const message = isRecord(event.message) ? event.message : undefined;
|
|
933
|
-
const messageText = message
|
|
934
|
-
? stringValue(message.text) ?? stringValue(message.content)
|
|
935
|
-
: undefined;
|
|
936
|
-
if (messageText)
|
|
937
|
-
return messageText;
|
|
938
|
-
const content = Array.isArray(message?.content)
|
|
939
|
-
? message.content
|
|
940
|
-
: Array.isArray(event.content)
|
|
941
|
-
? event.content
|
|
942
|
-
: [];
|
|
943
|
-
const textParts = content
|
|
944
|
-
.map((part) => {
|
|
945
|
-
if (!isRecord(part))
|
|
946
|
-
return undefined;
|
|
947
|
-
return stringValue(part.text) ?? stringValue(part.content);
|
|
948
|
-
})
|
|
949
|
-
.filter((part) => Boolean(part));
|
|
950
|
-
return textParts.length > 0 ? textParts.join('\n') : undefined;
|
|
951
|
-
}
|
|
952
|
-
function escapeRegExp(value) {
|
|
953
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
954
|
-
}
|
|
955
|
-
/**
|
|
956
|
-
* Tool cards must stay one line (same convention as ask-stream's oneLine):
|
|
957
|
-
* collapse whitespace and truncate so multi-line agent prose never spills
|
|
958
|
-
* into a progress line.
|
|
959
|
-
*/
|
|
960
|
-
const EVENT_CARD_MAX = 100;
|
|
961
|
-
function eventCardLine(text) {
|
|
962
|
-
const collapsed = text.replace(/\s+/g, ' ').trim();
|
|
963
|
-
return collapsed.length > EVENT_CARD_MAX
|
|
964
|
-
? `${collapsed.slice(0, EVENT_CARD_MAX - 1)}…`
|
|
965
|
-
: collapsed;
|
|
966
|
-
}
|
|
967
|
-
/**
|
|
968
|
-
* Translate a parsed coding-agent stream event into a one-line progress card
|
|
969
|
-
* ("editing src/llm/client.ts", "running bun add moda-ai", trimmed agent text).
|
|
970
|
-
* Returns undefined for low-signal events (system noise) so they never reach
|
|
971
|
-
* the user. Shared by the init progress board (via sdk-integration) and the
|
|
972
|
-
* agent-stream analyst progress events below.
|
|
973
|
-
*/
|
|
974
|
-
export function describeCodingAgentEvent(event) {
|
|
975
|
-
if (event.kind === 'tool') {
|
|
976
|
-
const name = event.toolName ?? 'tool';
|
|
977
|
-
const detail = event.detail;
|
|
978
|
-
if (/edit|write|patch|create|notebook/i.test(name)) {
|
|
979
|
-
return eventCardLine(detail ? `editing ${detail}` : `editing files (${name})`);
|
|
980
|
-
}
|
|
981
|
-
if (/bash|shell|exec|command|terminal|run/i.test(name)) {
|
|
982
|
-
return eventCardLine(detail ? `running ${detail}` : `running a command (${name})`);
|
|
983
|
-
}
|
|
984
|
-
if (/read|grep|glob|search|ls|find|cat/i.test(name)) {
|
|
985
|
-
return eventCardLine(detail ? `reading ${detail}` : `reading files (${name})`);
|
|
986
|
-
}
|
|
987
|
-
return eventCardLine(detail ? `using ${name}: ${detail}` : `using ${name}`);
|
|
988
|
-
}
|
|
989
|
-
if (event.kind === 'command') {
|
|
990
|
-
const command = event.detail ?? event.message.replace(/^command\s+/, '');
|
|
991
|
-
return eventCardLine(`running ${command}`);
|
|
992
|
-
}
|
|
993
|
-
if (event.kind === 'agent_text') {
|
|
994
|
-
const text = eventCardLine(event.message);
|
|
995
|
-
return text.length > 0 ? text : undefined;
|
|
996
|
-
}
|
|
997
|
-
if (event.kind === 'rate_limit')
|
|
998
|
-
return 'waiting on a provider rate limit';
|
|
999
|
-
return undefined;
|
|
1000
|
-
}
|
|
1001
|
-
/**
|
|
1002
|
-
* Map a parsed analyst stream event to the `progress` agent event the CLI
|
|
1003
|
-
* writes in agent-stream mode. Tool/command events get a `tool_use` phase so
|
|
1004
|
-
* downstream consumers (the hosted analyze runner's progress parser counts
|
|
1005
|
-
* phases starting with "tool") can track tool-call activity; agent text gets
|
|
1006
|
-
* the generic `analyst` phase. Low-signal events map to undefined.
|
|
1007
|
-
*/
|
|
1008
|
-
export function analystEventProgress(event) {
|
|
1009
|
-
const card = describeCodingAgentEvent(event);
|
|
1010
|
-
if (!card)
|
|
1011
|
-
return undefined;
|
|
1012
|
-
return {
|
|
1013
|
-
phase: event.kind === 'tool' || event.kind === 'command' ? 'tool_use' : 'analyst',
|
|
1014
|
-
message: card,
|
|
1015
|
-
};
|
|
1016
|
-
}
|
|
1017
|
-
function runExternalAnalystProcess(invocation, options) {
|
|
1018
|
-
return new Promise((resolveProcess) => {
|
|
1019
|
-
const child = spawn(invocation.command, invocation.args, {
|
|
1020
|
-
cwd: options.cwd,
|
|
1021
|
-
env: options.env,
|
|
1022
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1023
|
-
});
|
|
1024
|
-
const startedAt = Date.now();
|
|
1025
|
-
let lastOutputAt = startedAt;
|
|
1026
|
-
let sawOutput = false;
|
|
1027
|
-
let stdout = '';
|
|
1028
|
-
let stderr = '';
|
|
1029
|
-
let timedOut = false;
|
|
1030
|
-
let maxBufferExceeded = false;
|
|
1031
|
-
let settled = false;
|
|
1032
|
-
let eventLineBuffer = '';
|
|
1033
|
-
const progressMs = numericEnv('MODA_HARNESS_ANALYST_PROGRESS_MS', 8_000);
|
|
1034
|
-
const progressTimer = options.progress
|
|
1035
|
-
? setInterval(() => {
|
|
1036
|
-
const elapsed = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
|
|
1037
|
-
const quietFor = Math.max(0, Math.round((Date.now() - lastOutputAt) / 1000));
|
|
1038
|
-
const status = sawOutput
|
|
1039
|
-
? `${quietFor}s since analyst output`
|
|
1040
|
-
: 'waiting for first analyst output';
|
|
1041
|
-
const message = `Moda Analyst: still investigating (${elapsed}s elapsed, ${status})...`;
|
|
1042
|
-
options.onProgress?.(message, 'analyst_wait');
|
|
1043
|
-
options.tui?.heartbeat({ elapsedSeconds: elapsed, quietForSeconds: quietFor, sawOutput });
|
|
1044
|
-
if (options.progressToStderr) {
|
|
1045
|
-
process.stderr.write(`${renderHarnessProgress(message)}\n`);
|
|
1046
|
-
}
|
|
1047
|
-
}, progressMs)
|
|
1048
|
-
: undefined;
|
|
1049
|
-
const timeoutTimer = setTimeout(() => {
|
|
1050
|
-
timedOut = true;
|
|
1051
|
-
child.kill('SIGTERM');
|
|
1052
|
-
setTimeout(() => {
|
|
1053
|
-
if (!settled)
|
|
1054
|
-
child.kill('SIGKILL');
|
|
1055
|
-
}, 5_000).unref();
|
|
1056
|
-
}, options.timeoutMs);
|
|
1057
|
-
const flushEventLines = () => {
|
|
1058
|
-
if (!eventLineBuffer)
|
|
1059
|
-
return;
|
|
1060
|
-
recordExternalAgentEventLine(eventLineBuffer, options);
|
|
1061
|
-
eventLineBuffer = '';
|
|
1062
|
-
};
|
|
1063
|
-
const appendOutput = (stream, chunk) => {
|
|
1064
|
-
sawOutput = true;
|
|
1065
|
-
lastOutputAt = Date.now();
|
|
1066
|
-
const text = chunk.toString('utf8');
|
|
1067
|
-
if (stream === 'stdout')
|
|
1068
|
-
stdout += text;
|
|
1069
|
-
else
|
|
1070
|
-
stderr += text;
|
|
1071
|
-
const wantsEventLines = options.streamEvents && stream === 'stdout' &&
|
|
1072
|
-
(options.inheritedOutput || Boolean(options.onAgentEvent));
|
|
1073
|
-
if (options.inheritedOutput && options.tui && stream === 'stderr') {
|
|
1074
|
-
recordExternalAgentTextChunk(text, options.tui);
|
|
1075
|
-
}
|
|
1076
|
-
else if (wantsEventLines) {
|
|
1077
|
-
eventLineBuffer += text;
|
|
1078
|
-
const lines = eventLineBuffer.split(/\r?\n/);
|
|
1079
|
-
eventLineBuffer = lines.pop() ?? '';
|
|
1080
|
-
for (const line of lines) {
|
|
1081
|
-
recordExternalAgentEventLine(line, options);
|
|
1082
|
-
}
|
|
1083
|
-
}
|
|
1084
|
-
else if (options.inheritedOutput) {
|
|
1085
|
-
process.stderr.write(chunk);
|
|
1086
|
-
}
|
|
1087
|
-
if (stdout.length + stderr.length > options.maxBuffer && !maxBufferExceeded) {
|
|
1088
|
-
maxBufferExceeded = true;
|
|
1089
|
-
child.kill('SIGTERM');
|
|
1090
|
-
}
|
|
1091
|
-
};
|
|
1092
|
-
child.stdout.on('data', (chunk) => appendOutput('stdout', chunk));
|
|
1093
|
-
child.stderr.on('data', (chunk) => appendOutput('stderr', chunk));
|
|
1094
|
-
child.on('error', (error) => {
|
|
1095
|
-
if (settled)
|
|
1096
|
-
return;
|
|
1097
|
-
settled = true;
|
|
1098
|
-
clearTimeout(timeoutTimer);
|
|
1099
|
-
if (progressTimer)
|
|
1100
|
-
clearInterval(progressTimer);
|
|
1101
|
-
flushEventLines();
|
|
1102
|
-
resolveProcess({
|
|
1103
|
-
status: null,
|
|
1104
|
-
signal: null,
|
|
1105
|
-
stdout,
|
|
1106
|
-
stderr,
|
|
1107
|
-
error,
|
|
1108
|
-
timedOut,
|
|
1109
|
-
maxBufferExceeded,
|
|
1110
|
-
});
|
|
1111
|
-
});
|
|
1112
|
-
child.on('close', (status, signal) => {
|
|
1113
|
-
if (settled)
|
|
1114
|
-
return;
|
|
1115
|
-
settled = true;
|
|
1116
|
-
clearTimeout(timeoutTimer);
|
|
1117
|
-
if (progressTimer)
|
|
1118
|
-
clearInterval(progressTimer);
|
|
1119
|
-
flushEventLines();
|
|
1120
|
-
resolveProcess({
|
|
1121
|
-
status,
|
|
1122
|
-
signal,
|
|
1123
|
-
stdout,
|
|
1124
|
-
stderr,
|
|
1125
|
-
timedOut,
|
|
1126
|
-
maxBufferExceeded,
|
|
1127
|
-
});
|
|
1128
|
-
});
|
|
1129
|
-
});
|
|
1130
|
-
}
|
|
1131
|
-
async function runExternalAnalystWithRetries(invocation, options) {
|
|
1132
|
-
let lastResult;
|
|
1133
|
-
for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) {
|
|
1134
|
-
if (attempt > 1) {
|
|
1135
|
-
const message = `Moda Analyst: retrying ${analystDisplayName(options.adapter)} after transient provider failure (${attempt}/${options.maxAttempts}).`;
|
|
1136
|
-
options.onProgress?.(message, 'analyst_retry');
|
|
1137
|
-
options.tui?.recordEvent({ kind: 'system', message });
|
|
1138
|
-
if (options.progressToStderr)
|
|
1139
|
-
process.stderr.write(`${renderHarnessProgress(message)}\n`);
|
|
1140
|
-
if (options.backoffMs > 0)
|
|
1141
|
-
await sleep(options.backoffMs);
|
|
1142
|
-
}
|
|
1143
|
-
lastResult = await runExternalAnalystProcess(invocation, options);
|
|
1144
|
-
if (!isRetryableExternalAnalystFailure(lastResult))
|
|
1145
|
-
return lastResult;
|
|
1146
|
-
}
|
|
1147
|
-
return lastResult;
|
|
1148
|
-
}
|
|
1149
|
-
function isRetryableExternalAnalystFailure(result) {
|
|
1150
|
-
if (result.error || result.timedOut || result.maxBufferExceeded)
|
|
1151
|
-
return false;
|
|
1152
|
-
if (result.status === 0)
|
|
1153
|
-
return false;
|
|
1154
|
-
const output = `${result.stdout}\n${result.stderr}`;
|
|
1155
|
-
return /\b529\b|overloaded|server-side issue|temporar(?:y|ily)|try again|rate limit|too many requests|\b429\b|service unavailable/i.test(output);
|
|
1156
|
-
}
|
|
1157
|
-
function externalAnalystMaxAttempts(adapter) {
|
|
1158
|
-
const defaultRetries = adapter === 'claude' ? 2 : 1;
|
|
1159
|
-
return 1 + numericEnvAllowZero('MODA_HARNESS_ANALYST_RETRIES', defaultRetries);
|
|
1160
|
-
}
|
|
1161
|
-
export function sleep(ms) {
|
|
1162
|
-
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
1163
|
-
}
|
|
1164
|
-
function recordExternalAgentTextChunk(text, tui) {
|
|
1165
|
-
for (const line of text.split(/\r?\n/)) {
|
|
1166
|
-
const message = line.trim();
|
|
1167
|
-
if (!message)
|
|
1168
|
-
continue;
|
|
1169
|
-
tui.recordEvent({ kind: 'system', message: truncateText(message, 500) });
|
|
1170
|
-
}
|
|
1171
|
-
}
|
|
1172
|
-
function recordExternalAgentEventLine(line, options) {
|
|
1173
|
-
const event = parseExternalAgentEventLine(line);
|
|
1174
|
-
if (!event)
|
|
1175
|
-
return;
|
|
1176
|
-
options.onAgentEvent?.(event);
|
|
1177
|
-
if (!options.inheritedOutput)
|
|
1178
|
-
return;
|
|
1179
|
-
options.tui?.recordEvent(event);
|
|
1180
|
-
if (options.tui)
|
|
1181
|
-
return;
|
|
1182
|
-
const formatted = formatExternalAgentDisplayEvent(event, options.adapter);
|
|
1183
|
-
if (formatted)
|
|
1184
|
-
process.stderr.write(formatted);
|
|
1185
|
-
}
|
|
1186
|
-
function formatExternalAgentEventLine(line, adapter) {
|
|
1187
|
-
const event = parseExternalAgentEventLine(line);
|
|
1188
|
-
return event ? formatExternalAgentDisplayEvent(event, adapter) : undefined;
|
|
1189
|
-
}
|
|
1190
|
-
function parseExternalAgentEventLine(line) {
|
|
1191
|
-
const trimmed = line.trim();
|
|
1192
|
-
if (!trimmed)
|
|
1193
|
-
return undefined;
|
|
1194
|
-
try {
|
|
1195
|
-
const event = JSON.parse(trimmed);
|
|
1196
|
-
return parseExternalAgentEvent(event);
|
|
1197
|
-
}
|
|
1198
|
-
catch {
|
|
1199
|
-
return { kind: 'agent_text', message: truncateText(trimmed, 500) };
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
function formatExternalAgentDisplayEvent(event, adapter) {
|
|
1203
|
-
if (event.kind === 'tool')
|
|
1204
|
-
return undefined;
|
|
1205
|
-
return ` ${analystDisplayName(adapter)}: ${event.message}\n`;
|
|
1206
|
-
}
|
|
1207
|
-
function parseExternalAgentEvent(event) {
|
|
1208
|
-
if (!isRecord(event))
|
|
1209
|
-
return { kind: 'agent_text', message: truncateText(String(event), 500) };
|
|
1210
|
-
const msg = isRecord(event.msg) ? event.msg : undefined;
|
|
1211
|
-
const call = isRecord(event.call) ? event.call : undefined;
|
|
1212
|
-
const type = stringValue(event.type) ?? stringValue(event.event) ?? stringValue(msg?.type);
|
|
1213
|
-
const subtype = stringValue(event.subtype);
|
|
1214
|
-
if (type === 'rate_limit_event')
|
|
1215
|
-
return { kind: 'rate_limit', message: 'rate limit wait' };
|
|
1216
|
-
const toolName = stringValue(event.name) ??
|
|
1217
|
-
stringValue(event.tool_name) ??
|
|
1218
|
-
stringValue(event.toolName) ??
|
|
1219
|
-
stringValue(call?.name);
|
|
1220
|
-
if (toolName && /tool|call|exec|command|bash/i.test(`${type ?? ''} ${subtype ?? ''}`)) {
|
|
1221
|
-
const detail = toolInputDetail(event.input ?? event.arguments ?? call?.input ?? call?.arguments);
|
|
1222
|
-
return { kind: 'tool', toolName, message: `tool ${toolName}`, ...(detail ? { detail } : {}) };
|
|
1223
|
-
}
|
|
1224
|
-
const directText = stringValue(event.text) ??
|
|
1225
|
-
stringValue(event.message) ??
|
|
1226
|
-
stringValue(event.delta) ??
|
|
1227
|
-
stringValue(event.result);
|
|
1228
|
-
if (directText && !isLowSignalEventType(type)) {
|
|
1229
|
-
return { kind: 'agent_text', message: truncateText(firstMeaningfulLine(directText), 500) };
|
|
1230
|
-
}
|
|
1231
|
-
const message = isRecord(event.message) ? event.message : undefined;
|
|
1232
|
-
const messageText = message
|
|
1233
|
-
? stringValue(message.text) ?? stringValue(message.content)
|
|
1234
|
-
: undefined;
|
|
1235
|
-
if (messageText && !isLowSignalEventType(type)) {
|
|
1236
|
-
return { kind: 'agent_text', message: truncateText(firstMeaningfulLine(messageText), 500) };
|
|
1237
|
-
}
|
|
1238
|
-
const content = Array.isArray(message?.content)
|
|
1239
|
-
? message.content
|
|
1240
|
-
: Array.isArray(event.content)
|
|
1241
|
-
? event.content
|
|
1242
|
-
: [];
|
|
1243
|
-
for (const part of content) {
|
|
1244
|
-
const parsed = parseAgentContentPart(part);
|
|
1245
|
-
if (parsed)
|
|
1246
|
-
return parsed;
|
|
1247
|
-
}
|
|
1248
|
-
const command = stringValue(event.command) ?? stringValue(event.cmd);
|
|
1249
|
-
if (command) {
|
|
1250
|
-
return { kind: 'command', message: `command ${truncateText(command, 300)}`, detail: truncateText(command, 300) };
|
|
1251
|
-
}
|
|
1252
|
-
if (type && !isLowSignalEventType(type)) {
|
|
1253
|
-
return { kind: 'system', message: subtype ? `${type}:${subtype}` : type };
|
|
1254
|
-
}
|
|
1255
|
-
return undefined;
|
|
1256
|
-
}
|
|
1257
|
-
function parseAgentContentPart(part) {
|
|
1258
|
-
if (!isRecord(part))
|
|
1259
|
-
return undefined;
|
|
1260
|
-
const partType = stringValue(part.type);
|
|
1261
|
-
if (partType === 'tool_use' || partType === 'tool_call') {
|
|
1262
|
-
const name = stringValue(part.name) ?? stringValue(part.tool_name);
|
|
1263
|
-
if (!name)
|
|
1264
|
-
return undefined;
|
|
1265
|
-
const detail = toolInputDetail(part.input ?? part.arguments);
|
|
1266
|
-
return { kind: 'tool', toolName: name, message: `tool ${name}`, ...(detail ? { detail } : {}) };
|
|
1267
|
-
}
|
|
1268
|
-
if (partType === 'tool_result' || partType === 'tool_output')
|
|
1269
|
-
return undefined;
|
|
1270
|
-
if (partType === 'text' || partType === 'output_text') {
|
|
1271
|
-
const text = stringValue(part.text) ?? stringValue(part.content);
|
|
1272
|
-
return text ? { kind: 'agent_text', message: truncateText(firstMeaningfulLine(text), 500) } : undefined;
|
|
1273
|
-
}
|
|
1274
|
-
const name = stringValue(part.name) ?? stringValue(part.tool_name);
|
|
1275
|
-
if (name)
|
|
1276
|
-
return { kind: 'tool', toolName: name, message: `tool ${name}` };
|
|
1277
|
-
return undefined;
|
|
1278
|
-
}
|
|
1279
|
-
/** Pull the most useful one-line detail (file path or command) out of a tool input payload. */
|
|
1280
|
-
function toolInputDetail(input) {
|
|
1281
|
-
if (!isRecord(input))
|
|
1282
|
-
return undefined;
|
|
1283
|
-
const value = stringValue(input.file_path) ??
|
|
1284
|
-
stringValue(input.filePath) ??
|
|
1285
|
-
stringValue(input.path) ??
|
|
1286
|
-
stringValue(input.notebook_path) ??
|
|
1287
|
-
stringValue(input.command) ??
|
|
1288
|
-
stringValue(input.cmd) ??
|
|
1289
|
-
stringValue(input.pattern) ??
|
|
1290
|
-
stringValue(input.query);
|
|
1291
|
-
return value ? truncateText(firstMeaningfulLine(value), 300) : undefined;
|
|
1292
|
-
}
|
|
1293
|
-
function firstMeaningfulLine(value) {
|
|
1294
|
-
return value.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? value.trim();
|
|
1295
|
-
}
|
|
1296
|
-
function isLowSignalEventType(type) {
|
|
1297
|
-
return !type ||
|
|
1298
|
-
type === 'assistant' ||
|
|
1299
|
-
type === 'user' ||
|
|
1300
|
-
type === 'system' ||
|
|
1301
|
-
type === 'session' ||
|
|
1302
|
-
type === 'turn_start' ||
|
|
1303
|
-
type === 'turn_end' ||
|
|
1304
|
-
type === 'tool_use' ||
|
|
1305
|
-
type === 'tool_result' ||
|
|
1306
|
-
type === 'tool_call' ||
|
|
1307
|
-
type === 'tool_output' ||
|
|
1308
|
-
type === 'token_count' ||
|
|
1309
|
-
type === 'usage' ||
|
|
1310
|
-
type.endsWith('_delta');
|
|
1311
|
-
}
|
|
1312
|
-
function analystDisplayName(adapter) {
|
|
1313
|
-
if (adapter === 'claude')
|
|
1314
|
-
return 'Claude Code';
|
|
1315
|
-
if (adapter === 'cursor')
|
|
1316
|
-
return 'Cursor';
|
|
1317
|
-
return 'Codex';
|
|
1318
|
-
}
|
|
1319
|
-
function isRecord(value) {
|
|
1320
|
-
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
1321
|
-
}
|
|
1322
|
-
function stringValue(value) {
|
|
1323
|
-
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
1324
|
-
}
|
|
1325
|
-
export function summarizeInvalidAnalystReport(report) {
|
|
1326
|
-
if (!report || typeof report !== 'object')
|
|
1327
|
-
return { status: 'invalid_report' };
|
|
1328
|
-
const reportRecord = report;
|
|
1329
|
-
const graph = reportRecord.graph && typeof reportRecord.graph === 'object'
|
|
1330
|
-
? reportRecord.graph
|
|
1331
|
-
: {};
|
|
1332
|
-
return {
|
|
1333
|
-
status: 'validation_failed',
|
|
1334
|
-
schemaVersion: typeof graph.schemaVersion === 'string' ? graph.schemaVersion : undefined,
|
|
1335
|
-
harnessId: typeof reportRecord.harnessId === 'string'
|
|
1336
|
-
? reportRecord.harnessId
|
|
1337
|
-
: typeof graph.harnessId === 'string'
|
|
1338
|
-
? graph.harnessId
|
|
1339
|
-
: undefined,
|
|
1340
|
-
runtimeAgentCount: countArray(graph.runtimeAgents),
|
|
1341
|
-
agentFamilyCount: countArray(graph.agentFamilies),
|
|
1342
|
-
artifactCount: countArray(graph.artifacts),
|
|
1343
|
-
channelAdapterCount: countArray(graph.channelAdapters),
|
|
1344
|
-
identityNamespaceCount: countArray(graph.identityNamespaces),
|
|
1345
|
-
agentFrameworkCount: countArray(graph.agentFrameworks),
|
|
1346
|
-
modelProviderCount: countArray(graph.modelProviders),
|
|
1347
|
-
deploymentCount: countArray(graph.deployments),
|
|
1348
|
-
unknownCount: countArray(graph.unknowns),
|
|
1349
|
-
};
|
|
1350
|
-
}
|
|
1351
|
-
function countArray(value) {
|
|
1352
|
-
return Array.isArray(value) ? value.length : undefined;
|
|
1353
|
-
}
|
|
1354
|
-
async function resolveHarnessAnalystAdapter(context) {
|
|
1355
|
-
const requested = normalizeAnalystAdapter(context.flags.analyst);
|
|
1356
|
-
if (requested !== 'auto')
|
|
1357
|
-
return requested;
|
|
1358
|
-
const available = rankHarnessAnalysts(detectAvailableHarnessAnalysts());
|
|
1359
|
-
if (available.length > 0) {
|
|
1360
|
-
writeHumanBlock(context, renderHarnessAnalystChoice({
|
|
1361
|
-
detected: available.map((item) => `${analystDisplayName(item.adapter)} (${item.command})`),
|
|
1362
|
-
}));
|
|
1363
|
-
}
|
|
1364
|
-
if (available.length === 1) {
|
|
1365
|
-
writeHumanProgress(context, `Using ${analystDisplayName(available[0].adapter)} for read-only harness investigation.`);
|
|
1366
|
-
return available[0].adapter;
|
|
1367
|
-
}
|
|
1368
|
-
if (available.length > 1 && shouldPromptForHarnessAnalyst(context)) {
|
|
1369
|
-
return select({
|
|
1370
|
-
message: 'Choose which coding agent should investigate this harness',
|
|
1371
|
-
choices: available.map((item) => ({
|
|
1372
|
-
name: `${analystDisplayName(item.adapter)} (${item.command})`,
|
|
1373
|
-
value: item.adapter,
|
|
1374
|
-
})),
|
|
1375
|
-
});
|
|
1376
|
-
}
|
|
1377
|
-
if (available.length > 1) {
|
|
1378
|
-
writeHumanProgress(context, `Using ${analystDisplayName(available[0].adapter)} for read-only harness investigation. Pass --analyst=claude|codex|cursor to choose explicitly.`);
|
|
1379
|
-
return available[0].adapter;
|
|
1380
|
-
}
|
|
1381
|
-
throw new CliInputError('No external harness analyst CLI was found.', [
|
|
1382
|
-
'Install/sign into Claude Code, Codex, or Cursor, then rerun `moda harness analyze`.',
|
|
1383
|
-
'Set MODA_HARNESS_ANALYST_COMMAND to an explicit analyst command.',
|
|
1384
|
-
'For deterministic debugging only, run `moda harness analyze --analyst=local-scan`.',
|
|
1385
|
-
].join('\n'));
|
|
1386
|
-
}
|
|
1387
|
-
function detectAvailableHarnessAnalysts() {
|
|
1388
|
-
return EXTERNAL_ANALYST_ADAPTERS
|
|
1389
|
-
.map((adapter) => ({ adapter, command: resolveAnalystCommand(adapter) }))
|
|
1390
|
-
.filter((item) => Boolean(item.command));
|
|
1391
|
-
}
|
|
1392
|
-
function rankHarnessAnalysts(analysts) {
|
|
1393
|
-
return [...analysts].sort((a, b) => analystQualityScore(b.adapter) - analystQualityScore(a.adapter));
|
|
1394
|
-
}
|
|
1395
|
-
function analystQualityScore(adapter) {
|
|
1396
|
-
if (adapter === 'claude')
|
|
1397
|
-
return 3;
|
|
1398
|
-
if (adapter === 'codex')
|
|
1399
|
-
return 2;
|
|
1400
|
-
return 1;
|
|
1401
|
-
}
|
|
1402
|
-
function shouldPromptForHarnessAnalyst(context) {
|
|
1403
|
-
return context.outputMode === 'human' &&
|
|
1404
|
-
!context.yes &&
|
|
1405
|
-
process.stdin.isTTY === true &&
|
|
1406
|
-
!context.flags['analyst-command'] &&
|
|
1407
|
-
!process.env.MODA_HARNESS_ANALYST_COMMAND;
|
|
1408
|
-
}
|
|
1409
|
-
/**
|
|
1410
|
-
* The analyst spellings that select the deterministic local scanner (no LLM,
|
|
1411
|
-
* no remote analyze). Shared by `normalizeAnalystAdapter` and `moda init`'s
|
|
1412
|
-
* `resolveHarnessRemote` so the set cannot drift between the two.
|
|
1413
|
-
*/
|
|
1414
|
-
export function isDeterministicLocalAnalyst(raw) {
|
|
1415
|
-
const value = raw?.trim().toLowerCase();
|
|
1416
|
-
return value === 'local-scan' || value === 'local' || value === 'scripted' || value === 'moda';
|
|
1417
|
-
}
|
|
1418
|
-
function normalizeAnalystAdapter(raw) {
|
|
1419
|
-
const value = (raw && raw !== 'true' ? raw : 'auto').trim().toLowerCase();
|
|
1420
|
-
if (value === 'auto')
|
|
1421
|
-
return 'auto';
|
|
1422
|
-
if (value === 'claude' || value === 'claude-code' || value === 'claude_code')
|
|
1423
|
-
return 'claude';
|
|
1424
|
-
if (value === 'codex' || value === 'codex-cli' || value === 'codex_cli')
|
|
1425
|
-
return 'codex';
|
|
1426
|
-
if (value === 'cursor' || value === 'cursor-cli' || value === 'cursor_cli')
|
|
1427
|
-
return 'cursor';
|
|
1428
|
-
if (isDeterministicLocalAnalyst(value))
|
|
1429
|
-
return 'local-scan';
|
|
1430
|
-
throw new CliInputError(`Unknown harness analyst '${raw}'.`, 'Use `--analyst=claude|codex|cursor|local-scan`.');
|
|
1431
|
-
}
|
|
1432
|
-
function resolveExternalHarnessAnalystInvocation(options) {
|
|
1433
|
-
const mode = options.mode ?? 'read-only';
|
|
1434
|
-
const override = options.flags['analyst-command'] || process.env.MODA_HARNESS_ANALYST_COMMAND;
|
|
1435
|
-
if (override) {
|
|
1436
|
-
const [command, ...args] = splitCommandLine(override);
|
|
1437
|
-
if (!command) {
|
|
1438
|
-
throw new CliInputError('Analyst command override is empty.');
|
|
1439
|
-
}
|
|
1440
|
-
return { adapter: options.adapter, command, args: [...args, options.prompt], streamEvents: false };
|
|
1441
|
-
}
|
|
1442
|
-
const command = resolveAnalystCommand(options.adapter);
|
|
1443
|
-
if (!command) {
|
|
1444
|
-
throw new CliInputError(`${titleFromId(options.adapter)} analyst CLI was not found.`, `Install/sign into ${titleFromId(options.adapter)}, set MODA_HARNESS_ANALYST_COMMAND, or choose another analyst with --analyst=claude|codex|cursor.`);
|
|
1445
|
-
}
|
|
1446
|
-
if (options.adapter === 'claude') {
|
|
1447
|
-
const extraArgs = externalAnalystExtraArgs(options.adapter);
|
|
1448
|
-
// Write mode auto-accepts edits and unblocks the tools an integration needs
|
|
1449
|
-
// (Edit/Write/Bash for package installs); read-only stays in plan mode with a
|
|
1450
|
-
// safe-Bash allowlist so the harness analyst can never mutate the tree.
|
|
1451
|
-
const permission = mode === 'write'
|
|
1452
|
-
? ['--permission-mode', 'acceptEdits', '--allowedTools', 'Read,Grep,Glob,Edit,Write,Bash']
|
|
1453
|
-
: ['--permission-mode', 'plan', '--allowedTools', 'Read,Grep,Glob,Bash(pwd),Bash(ls *),Bash(find *),Bash(rg *),Bash(grep *),Bash(cat *),Bash(git status *),Bash(git ls-files *),Bash(git grep *)'];
|
|
1454
|
-
return {
|
|
1455
|
-
adapter: options.adapter,
|
|
1456
|
-
command,
|
|
1457
|
-
args: [
|
|
1458
|
-
'--print',
|
|
1459
|
-
...extraArgs,
|
|
1460
|
-
...permission,
|
|
1461
|
-
...(options.stream ? ['--verbose'] : []),
|
|
1462
|
-
'--output-format',
|
|
1463
|
-
options.stream ? 'stream-json' : 'text',
|
|
1464
|
-
'--no-session-persistence',
|
|
1465
|
-
options.prompt,
|
|
1466
|
-
],
|
|
1467
|
-
streamEvents: options.stream,
|
|
1468
|
-
};
|
|
1469
|
-
}
|
|
1470
|
-
if (options.adapter === 'codex') {
|
|
1471
|
-
return {
|
|
1472
|
-
adapter: options.adapter,
|
|
1473
|
-
command,
|
|
1474
|
-
args: [
|
|
1475
|
-
'exec',
|
|
1476
|
-
'--skip-git-repo-check',
|
|
1477
|
-
'--sandbox',
|
|
1478
|
-
mode === 'write' ? 'workspace-write' : 'read-only',
|
|
1479
|
-
...(options.stream ? ['--json'] : []),
|
|
1480
|
-
'-C',
|
|
1481
|
-
options.rootDir,
|
|
1482
|
-
options.prompt,
|
|
1483
|
-
],
|
|
1484
|
-
streamEvents: options.stream,
|
|
1485
|
-
};
|
|
1486
|
-
}
|
|
1487
|
-
return {
|
|
1488
|
-
adapter: options.adapter,
|
|
1489
|
-
command,
|
|
1490
|
-
args: cursorSubcommandArgs(command, [
|
|
1491
|
-
'--print',
|
|
1492
|
-
'--trust',
|
|
1493
|
-
...(mode === 'write' ? ['--force'] : ['--mode', 'plan', '--sandbox', 'enabled']),
|
|
1494
|
-
'--workspace',
|
|
1495
|
-
options.rootDir,
|
|
1496
|
-
'--output-format',
|
|
1497
|
-
options.stream ? 'stream-json' : 'text',
|
|
1498
|
-
options.prompt,
|
|
1499
|
-
]),
|
|
1500
|
-
streamEvents: options.stream,
|
|
1501
|
-
};
|
|
1502
|
-
}
|
|
1503
|
-
/** Coding-agent CLIs found on PATH, ranked best-first (claude > codex > cursor). */
|
|
1504
|
-
export function detectAvailableCodingAgents() {
|
|
1505
|
-
return rankHarnessAnalysts(detectAvailableHarnessAnalysts());
|
|
1506
|
-
}
|
|
1507
|
-
export function codingAgentDisplayName(adapter) {
|
|
1508
|
-
return analystDisplayName(adapter);
|
|
1509
|
-
}
|
|
1510
|
-
/** Spawn an installed coding-agent CLI with a prompt; capture its output. */
|
|
1511
|
-
export async function runCodingAgent(options) {
|
|
1512
|
-
const stream = options.stream ?? Boolean(options.onAgentEvent);
|
|
1513
|
-
const invocation = resolveExternalHarnessAnalystInvocation({
|
|
1514
|
-
adapter: options.adapter,
|
|
1515
|
-
flags: {},
|
|
1516
|
-
prompt: options.prompt,
|
|
1517
|
-
rootDir: options.rootDir,
|
|
1518
|
-
stream,
|
|
1519
|
-
mode: options.mode,
|
|
1520
|
-
});
|
|
1521
|
-
const timeoutMs = options.timeoutMs ?? numericEnv('MODA_HARNESS_ANALYST_TIMEOUT_MS', 20 * 60 * 1000);
|
|
1522
|
-
const result = await runExternalAnalystWithRetries(invocation, {
|
|
1523
|
-
cwd: options.rootDir,
|
|
1524
|
-
env: buildExternalAnalystEnv(options.env ?? process.env),
|
|
1525
|
-
inheritedOutput: false,
|
|
1526
|
-
timeoutMs,
|
|
1527
|
-
maxBuffer: 20 * 1024 * 1024,
|
|
1528
|
-
progress: Boolean(options.onProgress) || Boolean(options.progressToStderr),
|
|
1529
|
-
progressToStderr: options.progressToStderr ?? false,
|
|
1530
|
-
streamEvents: stream,
|
|
1531
|
-
adapter: invocation.adapter,
|
|
1532
|
-
maxAttempts: externalAnalystMaxAttempts(options.adapter),
|
|
1533
|
-
backoffMs: numericEnvAllowZero('MODA_HARNESS_ANALYST_RETRY_BACKOFF_MS', 6_000),
|
|
1534
|
-
onProgress: options.onProgress,
|
|
1535
|
-
onAgentEvent: options.onAgentEvent,
|
|
1536
|
-
});
|
|
1537
|
-
return {
|
|
1538
|
-
adapter: invocation.adapter,
|
|
1539
|
-
command: invocation.command,
|
|
1540
|
-
status: result.status,
|
|
1541
|
-
stdout: result.stdout,
|
|
1542
|
-
stderr: result.stderr,
|
|
1543
|
-
timedOut: result.timedOut,
|
|
1544
|
-
failedToStart: Boolean(result.error),
|
|
1545
|
-
};
|
|
1546
|
-
}
|
|
1547
|
-
function externalAnalystExtraArgs(adapter) {
|
|
1548
|
-
return splitCommandLine(process.env[`MODA_HARNESS_ANALYST_${adapter.toUpperCase()}_ARGS`] ?? '');
|
|
1549
|
-
}
|
|
1550
|
-
function renderExternalHarnessAnalystPrompt(options) {
|
|
1551
|
-
return [
|
|
1552
|
-
'You are Moda Analyst, investigating an agent harness for Moda CLI onboarding.',
|
|
1553
|
-
'',
|
|
1554
|
-
'Do real repository investigation. Do not call `moda harness scan`, `moda harness analyze`, or rely on deterministic Moda scanner output.',
|
|
1555
|
-
'Only inspect the Repository root named below and files/directories under it. Do not inspect parent directories, sibling projects, the Moda CLI implementation, or this monorepo unless the Repository root itself is that directory.',
|
|
1556
|
-
'Inspect package manifests, source files, prompt files, tool definitions, skills/rules, evals, deployment config, and README/AGENTS docs as needed.',
|
|
1557
|
-
'Do not read secret-looking files such as .env files, private keys, credential files, or token stores.',
|
|
1558
|
-
'You are running as a read-only analyst. Do not edit source files, configuration files, git state, dependency files, or project metadata.',
|
|
1559
|
-
'Prefer printing the final report JSON for Moda CLI to validate and write. If your runtime unexpectedly allows file writes, only `.moda/harness-report.json` is permitted.',
|
|
1560
|
-
'',
|
|
1561
|
-
'Your task:',
|
|
1562
|
-
renderExternalAnalystProtocol(),
|
|
1563
|
-
'1. Identify the runtime agent harnesses, agent families, channel adapters, identity mappings, model providers, frameworks, deployments, and open questions.',
|
|
1564
|
-
'2. Exhaustively enumerate EVERY prompt, tool, guardrail, and eval as its own graph artifact — the complete inventory, not a sample. This report feeds prompt optimization and tool optimization, so a missing tool or prompt is a real defect. Runtime agents may be modeled representatively on framework/example repos, but prompts and tools must be complete.',
|
|
1565
|
-
'3. Cite every narrative claim with local file paths, exact line numbers, and excerpts. File citations without line numbers are invalid for agent-authored reports. Enumerated artifacts only need evidence:[{path, reason}], so completeness of the artifact inventory is not limited by the citation cap.',
|
|
1566
|
-
`4. After completing investigation, print the report JSON between ${REPORT_JSON_BEGIN} and ${REPORT_JSON_END}. Moda CLI will validate it and write the local artifacts.`,
|
|
1567
|
-
'Your final response must contain the sentinel-wrapped JSON report. Do not end with commentary about being read-only, do not summarize without JSON, and do not omit the sentinel markers.',
|
|
1568
|
-
'If the repository is very large, keep enumerating prompts/tools/guardrails/evals as artifacts (uncapped) and only summarize runtime AGENTS representatively — do not drop tools or prompts to save space.',
|
|
1569
|
-
'',
|
|
1570
|
-
'Final output format:',
|
|
1571
|
-
REPORT_JSON_BEGIN,
|
|
1572
|
-
'{',
|
|
1573
|
-
' "...": "valid harness_report.v0.1 JSON"',
|
|
1574
|
-
'}',
|
|
1575
|
-
REPORT_JSON_END,
|
|
1576
|
-
'',
|
|
1577
|
-
'The report must be JSON with this top-level contract:',
|
|
1578
|
-
'- schemaVersion: "harness_report.v0.1"',
|
|
1579
|
-
'- reportId, harnessId, createdAt',
|
|
1580
|
-
`- analyst: { "id": "moda-${options.adapter}-analyst", "adapter": "${options.adapter}", "mode": "agent", "version": "cli" }`,
|
|
1581
|
-
'- repo: { root: ".", name, packageManagers, languages }',
|
|
1582
|
-
'- source: { graphSchemaVersion: "harness.v0.1", graphHash: "", readPlan, deterministicInventory: false }',
|
|
1583
|
-
'- summary: numeric fields named runtimeAgentCount, agentFamilyCount, artifactCount, sharedArtifactCount, channelAdapterCount, identityNamespaceCount, environmentCount, agentFrameworkCount, modelProviderCount, deploymentCount, relationshipCount, unknownCount',
|
|
1584
|
-
'- graph: an exact sanitized harness.v0.1 graph with arrays for agentFamilies, runtimeAgents, developerAgentAdapters, channelAdapters, artifacts, identityNamespaces, environments, agentFrameworks, modelProviders, deployments, relationships, unknowns',
|
|
1585
|
-
'- claims: cited claims about graph items. Each claim must include kind, subjectId, title, summary, confidence, inference, and citationIds; kind should be one of runtime_agent, agent_family, developer_agent_adapter, artifact, channel_adapter, identity_namespace, environment, agent_framework, model_provider, deployment, relationship, unknown. subjectId should be the id of the runtime agent, agent family, developer adapter, artifact, provider, adapter, deployment, relationship, or unknown being claimed.',
|
|
1586
|
-
'- citations: evidence records with id, type, reason, and excerpt. For type:"file" or type:"generated_doc", include path, line, reason, and excerpt matching that exact line. You may also use type:"command_output" or type:"production_evidence" with excerpt when evidence came from a command or production source.',
|
|
1587
|
-
`- syncPlan: { command: "moda harness sync --from-report ${HARNESS_REPORT_PATH}", dryRunCommand: "moda harness sync --from-report ${HARNESS_REPORT_PATH} --dry-run", uploads: string[], privacy: string[] }`,
|
|
1588
|
-
'- unknowns: report-level open questions with id, question, and citationIds',
|
|
1589
|
-
'',
|
|
1590
|
-
'Use exact field names and numeric confidence values from 0 to 1. Do not write string confidence values like "high". Do not use confidence >= 0.90 unless the claim is direct evidence from the cited line(s).',
|
|
1591
|
-
'Each graph item with evidence must use evidence: [{ path, reason }], not citationIds/citations.',
|
|
1592
|
-
'Runtime agents must include id, name, kind:"runtime_agent", root, entrypointIds, channelAdapterIds, artifactIds, identityNamespaceIds, deploymentIds, frameworkIds, modelProviderIds, confidence, evidence.',
|
|
1593
|
-
'Artifacts must include id, type, name, scope, usedByAgentIds, confidence, evidence, and optional sourcePath/contentHash/ownedByAgentId. Emit one artifact PER tool, PER prompt (including each inline system prompt), PER guardrail, and PER eval — enumerate them all; artifact types are entrypoint, prompt, tool, skill, eval, retrieval_index, memory, guardrail, model_config, unknown.',
|
|
1594
|
-
'Also set `body` on each artifact to its actual usable content so it can be consumed downstream WITHOUT re-reading the repo: for `prompt` the COMPLETE prompt text verbatim (full inline `instructions=`/system-prompt string or full prompt file); for `tool` the signature (name + parameters + types) and its docstring/description; for `guardrail` the rule/condition; for `eval` what it asserts; for `model_config` the configuration values. Copy the text in full — do not summarize or truncate `body` (it is the artifact we optimize). Redact any secret literals.',
|
|
1595
|
-
'For every `prompt` and `tool` artifact, at least one evidence entry MUST pin an exact source location so downstream optimization can retrieve the item text: carry a numeric `line` (the definition line, or the first line of the range) — evidence: [{ path, line, reason }]. The only exception is an artifact whose entire dedicated file is the text (e.g. a standalone prompt file); set its sourcePath and cite that same file and no line is required. When a single file holds multiple inline prompts or tool definitions, give each its own distinct line — a bare { path, reason } is a defect there.',
|
|
1596
|
-
'Agent families must include id, name, runtimeAgentIds, sharedArtifactIds, confidence, evidence.',
|
|
1597
|
-
'Frameworks/providers must include packageNames and runtimeAgentIds arrays; providers also include envVars and modelHints arrays.',
|
|
1598
|
-
'Unknowns in graph must include id, question, candidateIds, evidence. Report-level unknowns must include id, question, citationIds.',
|
|
1599
|
-
'Do not make every claim about the repo or harness id. Claims should name the specific graph item they support.',
|
|
1600
|
-
'',
|
|
1601
|
-
'Set source.graphHash to an empty string if you cannot compute Moda stable graph hashing; Moda CLI will still validate citations and schema.',
|
|
1602
|
-
options.tenantId ? `Tenant ID to include in graph.tenant: ${options.tenantId}` : 'No tenant ID was provided for this analysis.',
|
|
1603
|
-
`Repository root: ${options.rootDir}`,
|
|
1604
|
-
renderAnalystPreflightSection(options.rootDir),
|
|
1605
|
-
'',
|
|
1606
|
-
'Be conservative. If something is unclear, put it in unknowns instead of inventing an agent.',
|
|
1607
|
-
].filter(Boolean).join('\n');
|
|
1608
|
-
}
|
|
1609
|
-
function renderExternalAnalystProtocol() {
|
|
1610
|
-
const protocol = process.env.MODA_HARNESS_ANALYST_PROTOCOL?.trim().toLowerCase();
|
|
1611
|
-
if (protocol === 'classic' || protocol === 'none' || protocol === '0')
|
|
1612
|
-
return '';
|
|
1613
|
-
return [
|
|
1614
|
-
'Focused investigation protocol:',
|
|
1615
|
-
'- First build a quick repo map with read-only commands such as pwd, git ls-files, rg, and targeted manifest/doc reads.',
|
|
1616
|
-
'- EXHAUSTIVELY enumerate every prompt, tool, guardrail, eval, memory/retrieval store, and model config in the repository — one graph artifact each. This inventory feeds downstream prompt optimization and tool optimization, so completeness matters more than brevity: do NOT stop at representative examples and do NOT sample.',
|
|
1617
|
-
'- Tools: emit a `tool` artifact for EVERY tool definition — each `@function_tool` / `function_tool(...)`-decorated function, every hosted/first-party tool class (e.g. WebSearchTool, FileSearchTool, ComputerTool, CodeInterpreterTool, HostedMCPTool, ImageGenerationTool, LocalShellTool), and every `tool({...})` / `createTool(...)` / `new *Tool(...)` factory. Name each artifact after the tool/function and cite its definition line.',
|
|
1618
|
-
'- Prompts: emit a `prompt` artifact for EVERY distinct prompt — dedicated prompt files AND inline system prompts (`instructions=`, `system=`, `SYSTEM_PROMPT`, prompt-template strings), one per agent/definition, each with its exact file+line location so the prompt text can be retrieved and optimized later.',
|
|
1619
|
-
'- Also emit artifacts for guardrails (type "guardrail"), evals/tests-of-agents (type "eval"), memory/session stores (type "memory"), retrieval indexes (type "retrieval_index"), and model configuration (type "model_config").',
|
|
1620
|
-
'- Only runtime AGENTS may be modeled representatively: if the repo is a framework/SDK/examples collection, you need not treat every example as a distinct production agent — but you MUST still enumerate every distinct tool, prompt, guardrail, and eval as its own artifact.',
|
|
1621
|
-
'- Each enumerated artifact needs evidence: [{ path, reason }] (file path + short reason); a full line+excerpt citation is required only for the narrative `claims`, NOT for every artifact — so listing hundreds of tools/prompts is cheap and expected.',
|
|
1622
|
-
'- Capture the actual usable content in each artifact\'s `body`: full prompt text for prompts, signature+docstring for tools, the rule for guardrails, the assertion for evals, config values for model configs. This inventory is consumed directly for optimization, so `body` must be the complete text (verbatim, not summarized), with secret literals redacted.',
|
|
1623
|
-
'- EXCEPTION — `prompt` and `tool` artifacts MUST pin an exact location: put a numeric `line` on at least one evidence entry (evidence: [{ path, line, reason }]) at the definition line. That structured location is what lets downstream optimization retrieve the exact prompt/tool text; when one file has several inline prompts or tools, each artifact gets its own line. The only case that needs no line is a whole dedicated file whose entire content is the artifact (set sourcePath to that file and cite it). Still no excerpt required — just the line.',
|
|
1624
|
-
'- For every runtime agent, read and cite at least one concrete entrypoint or orchestration line, and link its prompts/tools via artifactIds.',
|
|
1625
|
-
'- Keep going until every prompt, tool, guardrail, and eval in scope is represented as an artifact. Use unknowns only for genuinely ambiguous items — never to skip enumeration. Respect the hard caps (<=250 claims, <=400 citations): artifacts are uncapped, so when there are many items keep enumerating them as artifacts and reserve claims/citations for the most important ones.',
|
|
1626
|
-
].join('\n');
|
|
1627
|
-
}
|
|
1628
|
-
function renderAnalystPreflightSection(rootDir) {
|
|
1629
|
-
if (process.env.MODA_HARNESS_ANALYST_PREFLIGHT !== '1')
|
|
1630
|
-
return '';
|
|
1631
|
-
const paths = listAnalystPreflightPaths(rootDir);
|
|
1632
|
-
if (paths.length === 0)
|
|
1633
|
-
return '';
|
|
1634
|
-
const groups = {
|
|
1635
|
-
manifests: paths.filter((path) => isManifestLikePath(path)).slice(0, 60),
|
|
1636
|
-
docs: paths.filter((path) => isDocLikePath(path)).slice(0, 40),
|
|
1637
|
-
source: paths.filter((path) => isAgentNavigationPath(path)).slice(0, 160),
|
|
1638
|
-
deploy: paths.filter((path) => isPreflightDeployLikePath(path)).slice(0, 50),
|
|
1639
|
-
evals: paths.filter((path) => isPreflightEvalLikePath(path)).slice(0, 50),
|
|
1640
|
-
};
|
|
1641
|
-
const lines = [
|
|
1642
|
-
'',
|
|
1643
|
-
'Navigation aid (not evidence):',
|
|
1644
|
-
'Moda generated this path-only map to help you navigate faster. Do not cite it and do not trust it for claims. Read the real files and cite exact lines before asserting anything.',
|
|
1645
|
-
...renderPreflightGroup('Manifests/config', groups.manifests),
|
|
1646
|
-
...renderPreflightGroup('Docs/rules', groups.docs),
|
|
1647
|
-
...renderPreflightGroup('Likely source/prompt/tool paths', groups.source),
|
|
1648
|
-
...renderPreflightGroup('Deploy/CI', groups.deploy),
|
|
1649
|
-
...renderPreflightGroup('Evals/tests', groups.evals),
|
|
1650
|
-
];
|
|
1651
|
-
return lines.join('\n');
|
|
1652
|
-
}
|
|
1653
|
-
function renderPreflightGroup(label, paths) {
|
|
1654
|
-
if (paths.length === 0)
|
|
1655
|
-
return [];
|
|
1656
|
-
return [`${label}:`, ...paths.map((path) => `- ${path}`)];
|
|
1657
|
-
}
|
|
1658
|
-
function listAnalystPreflightPaths(rootDir) {
|
|
1659
|
-
const gitPaths = readGitTrackedPaths(rootDir);
|
|
1660
|
-
const paths = gitPaths.length > 0 ? gitPaths : readDirectoryPreflightPaths(rootDir);
|
|
1661
|
-
return [...new Set(paths.map(normalizePath))]
|
|
1662
|
-
.filter((path) => path && !shouldSkipPreflightPath(path))
|
|
1663
|
-
.sort();
|
|
1664
|
-
}
|
|
1665
|
-
function readGitTrackedPaths(rootDir) {
|
|
1666
|
-
try {
|
|
1667
|
-
return execFileSync('git', ['ls-files', '-co', '--exclude-standard'], {
|
|
1668
|
-
cwd: rootDir,
|
|
1669
|
-
encoding: 'utf8',
|
|
1670
|
-
maxBuffer: 4 * 1024 * 1024,
|
|
1671
|
-
}).split(/\r?\n/).filter(Boolean);
|
|
1672
|
-
}
|
|
1673
|
-
catch {
|
|
1674
|
-
return [];
|
|
1675
|
-
}
|
|
1676
|
-
}
|
|
1677
|
-
function readDirectoryPreflightPaths(rootDir) {
|
|
1678
|
-
const paths = [];
|
|
1679
|
-
const stack = ['.'];
|
|
1680
|
-
while (stack.length > 0 && paths.length < 2_000) {
|
|
1681
|
-
const current = stack.pop() ?? '.';
|
|
1682
|
-
const absolute = current === '.' ? rootDir : join(rootDir, current);
|
|
1683
|
-
let entries;
|
|
1684
|
-
try {
|
|
1685
|
-
entries = readdirSync(absolute);
|
|
1686
|
-
}
|
|
1687
|
-
catch {
|
|
1688
|
-
continue;
|
|
1689
|
-
}
|
|
1690
|
-
for (const entry of entries) {
|
|
1691
|
-
const child = current === '.' ? entry : `${current}/${entry}`;
|
|
1692
|
-
if (shouldSkipPreflightPath(child))
|
|
1693
|
-
continue;
|
|
1694
|
-
const childAbsolute = join(rootDir, child);
|
|
1695
|
-
let stat;
|
|
1696
|
-
try {
|
|
1697
|
-
stat = lstatSync(childAbsolute);
|
|
1698
|
-
}
|
|
1699
|
-
catch {
|
|
1700
|
-
continue;
|
|
1701
|
-
}
|
|
1702
|
-
if (stat.isSymbolicLink())
|
|
1703
|
-
continue;
|
|
1704
|
-
if (stat.isDirectory()) {
|
|
1705
|
-
stack.push(child);
|
|
1706
|
-
}
|
|
1707
|
-
else {
|
|
1708
|
-
paths.push(child);
|
|
1709
|
-
}
|
|
1710
|
-
}
|
|
1711
|
-
}
|
|
1712
|
-
return paths;
|
|
1713
|
-
}
|
|
1714
|
-
function shouldSkipPreflightPath(path) {
|
|
1715
|
-
const normalized = normalizePath(path);
|
|
1716
|
-
if (!normalized || normalized.startsWith('.moda/'))
|
|
1717
|
-
return true;
|
|
1718
|
-
const parts = normalized.split('/');
|
|
1719
|
-
if (parts.some((part) => SKIPPED_DIRS.has(part)))
|
|
1720
|
-
return true;
|
|
1721
|
-
return isSecretishPath(normalized);
|
|
1722
|
-
}
|
|
1723
|
-
function isManifestLikePath(path) {
|
|
1724
|
-
const name = basename(path).toLowerCase();
|
|
1725
|
-
return /^(package\.json|pyproject\.toml|requirements.*\.txt|uv\.lock|poetry\.lock|pnpm-lock\.yaml|bun\.lockb?|yarn\.lock|cargo\.toml|go\.mod|deno\.json|turbo\.json|langgraph\.json|agents\.json|config\.(ts|js|json|yaml|yml|toml))$/.test(name);
|
|
1726
|
-
}
|
|
1727
|
-
function isDocLikePath(path) {
|
|
1728
|
-
const lower = path.toLowerCase();
|
|
1729
|
-
return /(^|\/)(readme|agents|claude|cursor|codex|rules|skills?)(\.[\w-]+)?$/.test(lower) ||
|
|
1730
|
-
lower.includes('/skills/') ||
|
|
1731
|
-
lower.includes('/rules/');
|
|
1732
|
-
}
|
|
1733
|
-
function isAgentNavigationPath(path) {
|
|
1734
|
-
const lower = path.toLowerCase();
|
|
1735
|
-
return /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|cs|java|kt|rb|php|md|yaml|yml|toml|json)$/.test(lower) &&
|
|
1736
|
-
/(agent|assistant|harness|prompt|tool|workflow|graph|model|provider|llm|chat|completion|planner|research|memory|eval|guardrail|router|orchestr|crew|swarm|bot)/.test(lower);
|
|
1737
|
-
}
|
|
1738
|
-
function isPreflightDeployLikePath(path) {
|
|
1739
|
-
const lower = path.toLowerCase();
|
|
1740
|
-
return lower.startsWith('.github/workflows/') ||
|
|
1741
|
-
lower.includes('/deploy') ||
|
|
1742
|
-
lower.includes('/docker') ||
|
|
1743
|
-
/(dockerfile|compose\.ya?ml|vercel\.json|netlify\.toml|wrangler\.toml|modal\.py|fly\.toml|render\.ya?ml)$/.test(lower);
|
|
1744
|
-
}
|
|
1745
|
-
function isPreflightEvalLikePath(path) {
|
|
1746
|
-
const lower = path.toLowerCase();
|
|
1747
|
-
return /(^|\/)(test|tests|eval|evals|bench|benchmarks|fixtures)(\/|$)/.test(lower) ||
|
|
1748
|
-
lower.includes('eval') ||
|
|
1749
|
-
lower.includes('benchmark');
|
|
1750
|
-
}
|
|
1751
|
-
function resolveAnalystCommand(adapter) {
|
|
1752
|
-
const envSpecific = process.env[`MODA_HARNESS_ANALYST_${adapter.toUpperCase()}_COMMAND`];
|
|
1753
|
-
if (envSpecific)
|
|
1754
|
-
return splitCommandLine(envSpecific)[0];
|
|
1755
|
-
if (adapter === 'claude')
|
|
1756
|
-
return findOnPath('claude');
|
|
1757
|
-
if (adapter === 'codex')
|
|
1758
|
-
return findOnPath('codex') || findOnPath('/Applications/Codex.app/Contents/Resources/codex');
|
|
1759
|
-
return findOnPath('cursor-agent') ||
|
|
1760
|
-
findOnPath('cursor-cli') ||
|
|
1761
|
-
findOnPath('cursor') ||
|
|
1762
|
-
findOnPath('/Applications/Cursor.app/Contents/Resources/app/bin/cursor');
|
|
1763
|
-
}
|
|
1764
|
-
function cursorSubcommandArgs(command, args) {
|
|
1765
|
-
const name = basename(command);
|
|
1766
|
-
return name === 'cursor' || name === 'cursor-cli' ? ['agent', ...args] : args;
|
|
1767
|
-
}
|
|
1768
|
-
function findOnPath(command) {
|
|
1769
|
-
if (command.includes('/') && existsSync(command))
|
|
1770
|
-
return command;
|
|
1771
|
-
for (const dir of (process.env.PATH ?? '').split(delimiter).filter(Boolean)) {
|
|
1772
|
-
const candidate = join(dir, command);
|
|
1773
|
-
if (existsSync(candidate))
|
|
1774
|
-
return candidate;
|
|
1775
|
-
}
|
|
1776
|
-
return undefined;
|
|
1777
|
-
}
|
|
1778
|
-
function splitCommandLine(commandLine) {
|
|
1779
|
-
return commandLine.trim().split(/\s+/).filter(Boolean);
|
|
1780
|
-
}
|
|
1781
|
-
export function truncateText(value, maxLength) {
|
|
1782
|
-
const compact = value.replace(/\s+/g, ' ').trim();
|
|
1783
|
-
return compact.length > maxLength ? `${compact.slice(0, maxLength - 1)}...` : compact;
|
|
1784
|
-
}
|
|
1785
|
-
export function scanHarness(options = {}) {
|
|
1786
|
-
const rootDir = resolve(options.rootDir ?? process.cwd());
|
|
1787
|
-
const skipped = [];
|
|
1788
|
-
const files = inventoryFiles(rootDir, skipped);
|
|
1789
|
-
const annotations = files.flatMap((file) => parseAnnotations(file));
|
|
1790
|
-
const repo = detectRepo(rootDir, files);
|
|
1791
|
-
const readPlan = buildHarnessReadPlan(rootDir, options.pathWasExplicit === true);
|
|
1792
|
-
const graph = {
|
|
1793
|
-
schemaVersion: SCHEMA_VERSION,
|
|
1794
|
-
harnessId: stableId('harness', repo.name || basename(rootDir) || 'repo'),
|
|
1795
|
-
tenant: options.tenantId
|
|
1796
|
-
? {
|
|
1797
|
-
tenantId: options.tenantId,
|
|
1798
|
-
source: options.tenantSource ?? 'scan_option',
|
|
1799
|
-
}
|
|
1800
|
-
: undefined,
|
|
1801
|
-
repo,
|
|
1802
|
-
scan: {
|
|
1803
|
-
createdAt: new Date().toISOString(),
|
|
1804
|
-
scannerVersion: options.scannerVersion ?? 'moda-cli',
|
|
1805
|
-
mode: options.dryRun ? 'dry-run' : 'permissioned',
|
|
1806
|
-
rootPaths: ['.'],
|
|
1807
|
-
readPlan,
|
|
1808
|
-
skipped,
|
|
1809
|
-
},
|
|
1810
|
-
agentFamilies: [],
|
|
1811
|
-
runtimeAgents: [],
|
|
1812
|
-
developerAgentAdapters: detectDeveloperAgentAdapters(files),
|
|
1813
|
-
channelAdapters: [],
|
|
1814
|
-
artifacts: [],
|
|
1815
|
-
identityNamespaces: [],
|
|
1816
|
-
environments: detectEnvironments(files),
|
|
1817
|
-
agentFrameworks: detectAgentFrameworks(files),
|
|
1818
|
-
modelProviders: detectModelProviders(files),
|
|
1819
|
-
deployments: [],
|
|
1820
|
-
relationships: [],
|
|
1821
|
-
unknowns: [],
|
|
1822
|
-
};
|
|
1823
|
-
applyAnnotations(graph, annotations);
|
|
1824
|
-
applyHeuristics(graph, files);
|
|
1825
|
-
linkFrameworksAndProviders(graph);
|
|
1826
|
-
finalizeGraph(graph);
|
|
1827
|
-
hydratePromptAndToolBodies(graph, rootDir);
|
|
1828
|
-
enrichScanCaptureMetadata(graph);
|
|
1829
|
-
return graph;
|
|
1830
|
-
}
|
|
1831
|
-
export function summarizeHarness(graph) {
|
|
1832
|
-
return {
|
|
1833
|
-
schemaVersion: graph.schemaVersion,
|
|
1834
|
-
harnessId: graph.harnessId,
|
|
1835
|
-
tenantId: graph.tenant?.tenantId,
|
|
1836
|
-
runtimeAgentCount: graph.runtimeAgents.length,
|
|
1837
|
-
agentFamilyCount: graph.agentFamilies.length,
|
|
1838
|
-
sharedArtifactCount: graph.artifacts.filter((artifact) => artifact.scope === 'shared').length,
|
|
1839
|
-
channelAdapterCount: graph.channelAdapters.length,
|
|
1840
|
-
identityNamespaceCount: graph.identityNamespaces.length,
|
|
1841
|
-
agentFrameworkCount: graph.agentFrameworks.length,
|
|
1842
|
-
modelProviderCount: graph.modelProviders.length,
|
|
1843
|
-
deploymentCount: graph.deployments.length,
|
|
1844
|
-
unknownCount: graph.unknowns.length,
|
|
1845
|
-
};
|
|
1846
|
-
}
|
|
1847
|
-
/** Skip reasons that mean a file was dropped for exceeding a size/budget cap. */
|
|
1848
|
-
function isSnapshotBudgetSkip(reason) {
|
|
1849
|
-
return reason === 'file too large for harness scan' ||
|
|
1850
|
-
reason === 'scan text budget exceeded';
|
|
1851
|
-
}
|
|
1852
|
-
/**
|
|
1853
|
-
* Serialized-file budget for a {@link SourceSnapshot}, kept under the ingest
|
|
1854
|
-
* proxy's 5MB request-body cap (`MAX_ANALYZE_BODY_BYTES` in
|
|
1855
|
-
* apps/moda-ingest-worker) with ~500KB of headroom for the JSON envelope. The
|
|
1856
|
-
* raw inventory text budget (48MB) is far larger, so this is the binding limit.
|
|
1857
|
-
*/
|
|
1858
|
-
const MAX_SNAPSHOT_BODY_BYTES = 4_500_000;
|
|
1859
|
-
/**
|
|
1860
|
-
* Build a safe source snapshot of `rootDir` by reusing the permissioned
|
|
1861
|
-
* `inventoryFiles` walk (secret-skip, symlink-skip, 512KB/file, 12k-file/48MB
|
|
1862
|
-
* caps) plus `detectRepo`/`getGitCommit`. Only files with text content are
|
|
1863
|
-
* included; `truncated` is set when the scan dropped any file for a size/budget
|
|
1864
|
-
* cap so the server knows the snapshot is incomplete.
|
|
1865
|
-
*/
|
|
1866
|
-
export function buildSourceSnapshot(rootDir) {
|
|
1867
|
-
const root = resolve(rootDir);
|
|
1868
|
-
const skipped = [];
|
|
1869
|
-
const files = inventoryFiles(root, skipped);
|
|
1870
|
-
const repo = detectRepo(root, files);
|
|
1871
|
-
const includedFiles = [];
|
|
1872
|
-
let byteCount = 0;
|
|
1873
|
-
let serializedBytes = 0;
|
|
1874
|
-
let truncatedBySize = false;
|
|
1875
|
-
for (const item of files) {
|
|
1876
|
-
if (item.text === undefined)
|
|
1877
|
-
continue;
|
|
1878
|
-
const file = { path: item.path, text: item.text };
|
|
1879
|
-
// Bound the SERIALIZED upload, not just the raw inventory text: the ingest
|
|
1880
|
-
// proxy caps the request body at MAX_ANALYZE_BODY_BYTES (5MB) while the
|
|
1881
|
-
// inventory text budget (48MB) is far larger. Account for JSON encoding
|
|
1882
|
-
// (keys, quotes, escaping, and the array comma) so a large repo produces a
|
|
1883
|
-
// `truncated` snapshot that still uploads instead of failing with HTTP 413.
|
|
1884
|
-
const cost = Buffer.byteLength(JSON.stringify(file), 'utf8') + 1;
|
|
1885
|
-
if (serializedBytes + cost > MAX_SNAPSHOT_BODY_BYTES) {
|
|
1886
|
-
truncatedBySize = true;
|
|
1887
|
-
break;
|
|
1888
|
-
}
|
|
1889
|
-
includedFiles.push(file);
|
|
1890
|
-
serializedBytes += cost;
|
|
1891
|
-
byteCount += Buffer.byteLength(item.text, 'utf8');
|
|
1892
|
-
}
|
|
1893
|
-
const gitCommit = getGitCommit(root);
|
|
1894
|
-
return {
|
|
1895
|
-
repo: { name: repo.name, root: '.' },
|
|
1896
|
-
files: includedFiles,
|
|
1897
|
-
truncated: truncatedBySize || skipped.some((skip) => isSnapshotBudgetSkip(skip.reason)),
|
|
1898
|
-
fileCount: includedFiles.length,
|
|
1899
|
-
byteCount,
|
|
1900
|
-
...(gitCommit ? { gitCommit } : {}),
|
|
1901
|
-
};
|
|
1902
|
-
}
|
|
1903
|
-
function buildCommandScanPlan(context, rootDir) {
|
|
1904
|
-
const explicitPath = context.flags.path !== undefined;
|
|
1905
|
-
const rel = normalizePath(relative(context.cwd, rootDir)) || '.';
|
|
1906
|
-
const scope = explicitPath && rel !== '.' ? 'narrow' : 'broad';
|
|
1907
|
-
const machineReadable = context.outputMode !== 'human';
|
|
1908
|
-
const approvalRequired = context.positional === 'analyze' && scope === 'broad' && !context.yes;
|
|
1909
|
-
return {
|
|
1910
|
-
scope,
|
|
1911
|
-
root: rel,
|
|
1912
|
-
approval: {
|
|
1913
|
-
required: approvalRequired,
|
|
1914
|
-
granted: !approvalRequired,
|
|
1915
|
-
reason: approvalRequired
|
|
1916
|
-
? 'broad scan requires confirmation'
|
|
1917
|
-
: context.yes
|
|
1918
|
-
? '--yes'
|
|
1919
|
-
: machineReadable
|
|
1920
|
-
? 'machine-readable output'
|
|
1921
|
-
: scope === 'narrow'
|
|
1922
|
-
? 'narrow path'
|
|
1923
|
-
: 'non-interactive terminal',
|
|
1924
|
-
},
|
|
1925
|
-
readPlan: buildHarnessReadPlan(rootDir, explicitPath),
|
|
1926
|
-
};
|
|
1927
|
-
}
|
|
1928
|
-
async function approveScanIfNeeded(scanPlan, options = {}) {
|
|
1929
|
-
if (!scanPlan.approval.required)
|
|
1930
|
-
return;
|
|
1931
|
-
if (options.context && (options.context.outputMode !== 'human' || process.stdin.isTTY !== true)) {
|
|
1932
|
-
throw new CliInputRequiredError(options.message ?? 'Scan this project to build a Moda harness manifest?', {
|
|
1933
|
-
id: 'harness_scan_approval',
|
|
1934
|
-
question: options.message ?? 'Scan this project to build a Moda harness manifest?',
|
|
1935
|
-
kind: 'confirm',
|
|
1936
|
-
choices: [
|
|
1937
|
-
{ value: 'yes', label: 'Approve broad scan', resume_flags: ['--yes'] },
|
|
1938
|
-
{ value: 'no', label: 'Decline broad scan', resume_flags: ['--path', scanPlan.root === '.' ? './src' : scanPlan.root] },
|
|
1939
|
-
],
|
|
1940
|
-
resume_command: ['moda', 'harness', 'analyze', '--yes'],
|
|
1941
|
-
}, 'Re-run with `--yes` to approve the broad local read plan, or pass --path=<dir> for a narrower scan.');
|
|
1942
|
-
}
|
|
1943
|
-
if (options.context?.outputMode === 'human') {
|
|
1944
|
-
writeHumanBlock(options.context, renderHarnessAnalystPreflight({
|
|
1945
|
-
root: scanPlan.root,
|
|
1946
|
-
willRead: scanPlan.readPlan.willRead,
|
|
1947
|
-
willSkip: scanPlan.readPlan.willSkip,
|
|
1948
|
-
}));
|
|
1949
|
-
}
|
|
1950
|
-
const approved = await confirm({
|
|
1951
|
-
message: options.message ?? 'Scan this project to build a Moda harness manifest?',
|
|
1952
|
-
default: true,
|
|
1953
|
-
});
|
|
1954
|
-
scanPlan.approval.granted = approved;
|
|
1955
|
-
scanPlan.approval.reason = approved ? 'confirmed by user' : 'declined by user';
|
|
1956
|
-
if (!approved) {
|
|
1957
|
-
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.');
|
|
1958
|
-
}
|
|
1959
|
-
}
|
|
1960
|
-
/**
|
|
1961
|
-
* Interactive confirmation gate for `harness delete`. `--yes` skips it; in
|
|
1962
|
-
* machine-readable modes or without a TTY the command refuses with a
|
|
1963
|
-
* resumable input-required error instead of prompting (same posture as the
|
|
1964
|
-
* broad-scan approval above) so agents/CI never hang on stdin.
|
|
1965
|
-
*/
|
|
1966
|
-
async function confirmHarnessDelete(context, idOrKey) {
|
|
1967
|
-
if (context.yes)
|
|
1968
|
-
return;
|
|
1969
|
-
const message = `Permanently delete harness '${idOrKey}' and all of its synced versions from Moda Cloud?`;
|
|
1970
|
-
if (context.outputMode !== 'human' || process.stdin.isTTY !== true) {
|
|
1971
|
-
throw new CliInputRequiredError(message, {
|
|
1972
|
-
id: 'harness_delete_approval',
|
|
1973
|
-
question: message,
|
|
1974
|
-
kind: 'confirm',
|
|
1975
|
-
choices: [
|
|
1976
|
-
{ value: 'yes', label: 'Delete harness', resume_flags: ['--yes'] },
|
|
1977
|
-
{ value: 'no', label: 'Keep harness', resume_flags: [] },
|
|
1978
|
-
],
|
|
1979
|
-
resume_command: ['moda', 'harness', 'delete', idOrKey, '--yes'],
|
|
1980
|
-
}, 'Re-run with `--yes` to confirm the deletion.');
|
|
1981
|
-
}
|
|
1982
|
-
const approved = await confirm({ message, default: false });
|
|
1983
|
-
if (!approved) {
|
|
1984
|
-
throw new CliInputError('Harness delete was not approved.', 'Re-run with `--yes` to skip the prompt, or leave the harness in place.');
|
|
1985
|
-
}
|
|
1986
|
-
}
|
|
1987
|
-
export function writeHumanProgress(context, message) {
|
|
1988
|
-
if (context.outputMode !== 'human')
|
|
1989
|
-
return;
|
|
1990
|
-
if (context.quiet && context.flags.progress !== 'true')
|
|
1991
|
-
return;
|
|
1992
|
-
console.error(renderHarnessProgress(message));
|
|
1993
|
-
}
|
|
1994
|
-
function writeHumanBlock(context, message) {
|
|
1995
|
-
if (context.outputMode !== 'human')
|
|
1996
|
-
return;
|
|
1997
|
-
if (context.quiet && context.flags.progress !== 'true')
|
|
1998
|
-
return;
|
|
1999
|
-
console.error(message);
|
|
2000
|
-
}
|
|
2001
|
-
function buildHarnessReadPlan(rootDir, pathWasExplicit) {
|
|
2002
|
-
return {
|
|
2003
|
-
scope: pathWasExplicit ? 'narrow' : 'broad',
|
|
2004
|
-
rootPaths: [normalizePath(relative(rootDir, rootDir)) || '.'],
|
|
2005
|
-
willRead: [
|
|
2006
|
-
'package manifests',
|
|
2007
|
-
'source files under the selected path',
|
|
2008
|
-
'prompt files',
|
|
2009
|
-
'tool definitions',
|
|
2010
|
-
'skill/rule files',
|
|
2011
|
-
'eval/test files',
|
|
2012
|
-
'deployment and CI config',
|
|
2013
|
-
],
|
|
2014
|
-
willSkip: [
|
|
2015
|
-
'secret-looking files',
|
|
2016
|
-
'symlinks',
|
|
2017
|
-
'node_modules and build output',
|
|
2018
|
-
'.moda runtime files',
|
|
2019
|
-
'large files over 512KB',
|
|
2020
|
-
],
|
|
2021
|
-
skipsSecretFiles: true,
|
|
2022
|
-
followsSymlinks: false,
|
|
2023
|
-
uploadsSourceByDefault: false,
|
|
2024
|
-
};
|
|
2025
|
-
}
|
|
2026
|
-
function resolveHarnessTenant(flags, context) {
|
|
2027
|
-
const flagTenant = flags['tenant-id'] || flags.tenant;
|
|
2028
|
-
if (flagTenant && flagTenant !== 'true') {
|
|
2029
|
-
return {
|
|
2030
|
-
tenantId: flagTenant,
|
|
2031
|
-
source: 'flag',
|
|
2032
|
-
};
|
|
2033
|
-
}
|
|
2034
|
-
const tenantId = resolveTenantId({ profile: context?.profile, env: context?.env });
|
|
2035
|
-
if (!tenantId)
|
|
2036
|
-
return undefined;
|
|
2037
|
-
return {
|
|
2038
|
-
tenantId,
|
|
2039
|
-
source: 'env_or_config',
|
|
2040
|
-
};
|
|
2041
|
-
}
|
|
2042
|
-
function createHarnessSyncPlan(options) {
|
|
2043
|
-
const profile = getActiveProfile({
|
|
2044
|
-
profile: options.context.profile,
|
|
2045
|
-
env: options.context.env,
|
|
2046
|
-
});
|
|
2047
|
-
const tenantId = options.graph.tenant?.tenantId ??
|
|
2048
|
-
resolveTenantId({ profile: options.context.profile, env: options.context.env });
|
|
2049
|
-
return {
|
|
2050
|
-
endpoint: '/api/harnesses/sync',
|
|
2051
|
-
resource: 'harness',
|
|
2052
|
-
source: options.source,
|
|
2053
|
-
profile: profile.name,
|
|
2054
|
-
tenantId,
|
|
2055
|
-
rootPath: options.rootDir,
|
|
2056
|
-
sourceCommit: options.sourceCommit,
|
|
2057
|
-
harnessId: options.graph.harnessId,
|
|
2058
|
-
localGraphHash: options.localGraphHash,
|
|
2059
|
-
counts: syncCounts(options.graph),
|
|
2060
|
-
uploads: options.source === 'approved_report'
|
|
2061
|
-
? [
|
|
2062
|
-
'approved harness graph metadata',
|
|
2063
|
-
'runtime agents, families, artifacts, relationships, citations-derived graph shape',
|
|
2064
|
-
'graph hash and source commit',
|
|
2065
|
-
]
|
|
2066
|
-
: [
|
|
2067
|
-
'harness graph metadata',
|
|
2068
|
-
'runtime agents, families, artifacts, relationships',
|
|
2069
|
-
'graph hash and source commit',
|
|
2070
|
-
],
|
|
2071
|
-
localWrites: options.localWrites,
|
|
2072
|
-
privacy: [
|
|
2073
|
-
'No secret-looking files are read by the harness scanner.',
|
|
2074
|
-
'Source file contents are not uploaded by harness sync by default.',
|
|
2075
|
-
'Prompt/tool/skill artifacts sync as metadata, ids, paths, hashes, and relationships unless a separate resource command uploads content.',
|
|
2076
|
-
],
|
|
2077
|
-
};
|
|
2078
|
-
}
|
|
2079
|
-
function plannedHarnessArtifactWrites(context) {
|
|
2080
|
-
return context.flags.summary === 'true'
|
|
2081
|
-
? [HARNESS_PATH, HARNESS_SUMMARY_PATH, SYNC_STATE_PATH]
|
|
2082
|
-
: [HARNESS_PATH, SYNC_STATE_PATH];
|
|
2083
|
-
}
|
|
2084
|
-
function syncCounts(graph) {
|
|
2085
|
-
return {
|
|
2086
|
-
runtimeAgents: graph.runtimeAgents.length,
|
|
2087
|
-
agentFamilies: graph.agentFamilies.length,
|
|
2088
|
-
developerAgentAdapters: graph.developerAgentAdapters.length,
|
|
2089
|
-
channelAdapters: graph.channelAdapters.length,
|
|
2090
|
-
artifacts: graph.artifacts.length,
|
|
2091
|
-
identityNamespaces: graph.identityNamespaces.length,
|
|
2092
|
-
environments: graph.environments.length,
|
|
2093
|
-
agentFrameworks: graph.agentFrameworks.length,
|
|
2094
|
-
modelProviders: graph.modelProviders.length,
|
|
2095
|
-
deployments: graph.deployments.length,
|
|
2096
|
-
relationships: graph.relationships.length,
|
|
2097
|
-
unknowns: graph.unknowns.length,
|
|
2098
|
-
};
|
|
2099
|
-
}
|
|
2100
|
-
function plannedDryRunRemote(plan) {
|
|
2101
|
-
return {
|
|
2102
|
-
tenantId: plan.tenantId ?? 'unresolved',
|
|
2103
|
-
harnessId: plan.harnessId,
|
|
2104
|
-
harnessKey: plan.harnessId,
|
|
2105
|
-
versionId: '',
|
|
2106
|
-
graphHash: plan.localGraphHash,
|
|
2107
|
-
changed: false,
|
|
2108
|
-
dryRun: true,
|
|
2109
|
-
counts: plan.counts,
|
|
2110
|
-
};
|
|
2111
|
-
}
|
|
2112
|
-
async function syncHarnessWithRemote(context, body) {
|
|
2113
|
-
assertHarnessGraphSyncSize(body.graph);
|
|
2114
|
-
const profileOptions = { profile: context.profile, env: context.env };
|
|
2115
|
-
const hasApiKey = Boolean(resolveApiKey(profileOptions));
|
|
2116
|
-
if (context.dryRun && !hasApiKey)
|
|
2117
|
-
return undefined;
|
|
2118
|
-
return await callControlAPI('/harnesses/sync', {
|
|
2119
|
-
method: 'POST',
|
|
2120
|
-
body: JSON.stringify({
|
|
2121
|
-
...body,
|
|
2122
|
-
dryRun: context.dryRun,
|
|
2123
|
-
}),
|
|
2124
|
-
}, profileOptions);
|
|
2125
|
-
}
|
|
2126
|
-
export function readHarnessSyncState(rootDir) {
|
|
2127
|
-
const path = join(rootDir, SYNC_STATE_PATH);
|
|
2128
|
-
if (!existsSync(path))
|
|
2129
|
-
return undefined;
|
|
2130
|
-
try {
|
|
2131
|
-
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
2132
|
-
if (!isRecord(parsed) || parsed.schemaVersion !== 'sync_state.v0.1') {
|
|
2133
|
-
return undefined;
|
|
2134
|
-
}
|
|
2135
|
-
return parsed;
|
|
2136
|
-
}
|
|
2137
|
-
catch {
|
|
2138
|
-
return undefined;
|
|
2139
|
-
}
|
|
2140
|
-
}
|
|
2141
|
-
function writeHarnessSyncState(rootDir, state) {
|
|
2142
|
-
const path = join(rootDir, SYNC_STATE_PATH);
|
|
2143
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
2144
|
-
writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`);
|
|
2145
|
-
return SYNC_STATE_PATH;
|
|
2146
|
-
}
|
|
2147
|
-
function selectedRuntimeAgentId(flags) {
|
|
2148
|
-
if (flags['agent-id'])
|
|
2149
|
-
return flags['agent-id'];
|
|
2150
|
-
if (flags.agent && flags.agent !== 'true')
|
|
2151
|
-
return flags.agent;
|
|
2152
|
-
return undefined;
|
|
2153
|
-
}
|
|
2154
|
-
function loadOrScanHarness(rootDir, tenant, context) {
|
|
2155
|
-
const manifestPath = join(rootDir, HARNESS_PATH);
|
|
2156
|
-
if (existsSync(manifestPath))
|
|
2157
|
-
return readHarnessGraph(manifestPath);
|
|
2158
|
-
// Provenance banner: without a persisted manifest this command output comes
|
|
2159
|
-
// from a live scan of the current directory, which is easy to mistake for
|
|
2160
|
-
// synced/account data (especially right after switching repos or profiles).
|
|
2161
|
-
if (context) {
|
|
2162
|
-
writeHarnessNotice(context, `No ${HARNESS_PATH} found; showing a live scan of ${rootDir}. ` +
|
|
2163
|
-
'This is local scan output, NOT synced or account data. ' +
|
|
2164
|
-
'Run `moda harness scan` to persist it and `moda harness sync` to upload it.');
|
|
2165
|
-
}
|
|
2166
|
-
return scanHarness({
|
|
2167
|
-
rootDir,
|
|
2168
|
-
tenantId: tenant?.tenantId,
|
|
2169
|
-
tenantSource: tenant?.source,
|
|
2170
|
-
});
|
|
2171
|
-
}
|
|
2172
|
-
/**
|
|
2173
|
-
* Warning line that must be visible in every output mode: styled progress on
|
|
2174
|
-
* stderr for humans, a plain `Warning:` stderr line for machine modes (stdout
|
|
2175
|
-
* stays machine-readable JSON either way). Quiet suppresses it like the rest
|
|
2176
|
-
* of the harness progress surface.
|
|
2177
|
-
*/
|
|
2178
|
-
function writeHarnessNotice(context, message) {
|
|
2179
|
-
if (context.quiet && context.flags.progress !== 'true')
|
|
2180
|
-
return;
|
|
2181
|
-
if (context.outputMode === 'human') {
|
|
2182
|
-
console.error(renderHarnessProgress(message, 'warning'));
|
|
2183
|
-
return;
|
|
2184
|
-
}
|
|
2185
|
-
console.error(`Warning: ${message}`);
|
|
2186
|
-
}
|
|
2187
|
-
/**
|
|
2188
|
-
* The identity a sync-style upload will actually authenticate as, resolved
|
|
2189
|
-
* with the same precedence as the API call itself (MODA_API_KEY env wins over
|
|
2190
|
-
* the active profile / legacy config). Used for the pre-upload
|
|
2191
|
-
* "Syncing as <credential> → tenant <id>" banner so a shadowed or stale key
|
|
2192
|
-
* is visible BEFORE data lands in the wrong tenant.
|
|
2193
|
-
*
|
|
2194
|
-
* The destination tenant is derived from the WINNING KEY itself (signed CLI
|
|
2195
|
-
* keys embed their tenant, and the server resolves the destination from the
|
|
2196
|
-
* key alone) — never from flag/env/profile config, which is exactly what a
|
|
2197
|
-
* shadowing stale key disagrees with. When the key does not embed a tenant,
|
|
2198
|
-
* the config-resolved id is returned with `tenantSource: 'config'` so
|
|
2199
|
-
* callers must label it unverified. Best-effort: never throws (a broken
|
|
2200
|
-
* MODA_PROFILE must not abort onboarding).
|
|
2201
|
-
*/
|
|
2202
|
-
export function describeSyncIdentity(options = {}) {
|
|
2203
|
-
const apiKey = resolveApiKey(options);
|
|
2204
|
-
if (!apiKey)
|
|
2205
|
-
return undefined;
|
|
2206
|
-
const redacted = apiKey.length > 12
|
|
2207
|
-
? `${apiKey.slice(0, 8)}…${apiKey.slice(-4)}`
|
|
2208
|
-
: `…${apiKey.slice(-4)}`;
|
|
2209
|
-
const env = options.env ?? process.env;
|
|
2210
|
-
// getActiveProfile throws when the named profile is missing (e.g. a stale
|
|
2211
|
-
// MODA_PROFILE) even though the key already resolved via legacy config —
|
|
2212
|
-
// the banner is advisory and must never abort the command around it.
|
|
2213
|
-
let profileName;
|
|
2214
|
-
try {
|
|
2215
|
-
profileName = getActiveProfile(options).name;
|
|
2216
|
-
}
|
|
2217
|
-
catch {
|
|
2218
|
-
profileName = undefined;
|
|
2219
|
-
}
|
|
2220
|
-
const credential = env.MODA_API_KEY === apiKey
|
|
2221
|
-
? `MODA_API_KEY env key (${redacted})`
|
|
2222
|
-
: profileName
|
|
2223
|
-
? `profile '${profileName}' key (${redacted})`
|
|
2224
|
-
: `stored key (${redacted})`;
|
|
2225
|
-
const keyTenantId = embeddedTenantIdFromApiKey(apiKey);
|
|
2226
|
-
if (keyTenantId) {
|
|
2227
|
-
return { credential, tenantId: keyTenantId, tenantSource: 'api-key' };
|
|
2228
|
-
}
|
|
2229
|
-
const configTenantId = resolveTenantId(options);
|
|
2230
|
-
return {
|
|
2231
|
-
credential,
|
|
2232
|
-
tenantId: configTenantId,
|
|
2233
|
-
tenantSource: configTenantId ? 'config' : undefined,
|
|
2234
|
-
};
|
|
2235
|
-
}
|
|
2236
|
-
/**
|
|
2237
|
-
* Human tenant label for the identity banner: key-derived ids are printed
|
|
2238
|
-
* bare; config-derived ids are explicitly marked unverified so the banner
|
|
2239
|
-
* never asserts a destination the server might not use.
|
|
2240
|
-
*/
|
|
2241
|
-
export function describeSyncTenantLabel(identity) {
|
|
2242
|
-
if (!identity.tenantId)
|
|
2243
|
-
return 'unresolved';
|
|
2244
|
-
if (identity.tenantSource === 'api-key')
|
|
2245
|
-
return identity.tenantId;
|
|
2246
|
-
return `${identity.tenantId} (unverified — from local config, not the API key; the server derives the destination tenant from the key)`;
|
|
2247
|
-
}
|
|
2248
|
-
/** Bounded walk looking for `<subdir>/.moda/harness-report.json` files. */
|
|
2249
|
-
const FRESHER_REPORT_MAX_DEPTH = 4;
|
|
2250
|
-
const FRESHER_REPORT_MAX_DIRS = 4_000;
|
|
2251
|
-
/**
|
|
2252
|
-
* Reports in subdirectories that are FRESHER than the root's
|
|
2253
|
-
* `.moda/harness-report.json` (a missing root report counts as infinitely
|
|
2254
|
-
* old). Catches the `--path` trap: `harness analyze --path=<subdir>` writes
|
|
2255
|
-
* `<subdir>/.moda/harness-report.json`, and a later root-level
|
|
2256
|
-
* `harness approve`/`sync` silently targets the stale (or absent) root report.
|
|
2257
|
-
* Newest first.
|
|
2258
|
-
*/
|
|
2259
|
-
export function findFresherHarnessReports(rootDir) {
|
|
2260
|
-
let rootMtimeMs = 0;
|
|
2261
|
-
try {
|
|
2262
|
-
rootMtimeMs = lstatSync(join(rootDir, HARNESS_REPORT_PATH)).mtimeMs;
|
|
2263
|
-
}
|
|
2264
|
-
catch {
|
|
2265
|
-
rootMtimeMs = 0;
|
|
2266
|
-
}
|
|
2267
|
-
const fresher = [];
|
|
2268
|
-
let visited = 0;
|
|
2269
|
-
const walk = (dir, depth) => {
|
|
2270
|
-
if (depth > FRESHER_REPORT_MAX_DEPTH || visited >= FRESHER_REPORT_MAX_DIRS)
|
|
2271
|
-
return;
|
|
2272
|
-
visited += 1;
|
|
2273
|
-
let entries;
|
|
2274
|
-
try {
|
|
2275
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
2276
|
-
}
|
|
2277
|
-
catch {
|
|
2278
|
-
return;
|
|
2279
|
-
}
|
|
2280
|
-
for (const entry of entries) {
|
|
2281
|
-
// Dot-dirs (including each dir's own `.moda`, checked explicitly below)
|
|
2282
|
-
// and scan-skipped dirs never host a user project subtree.
|
|
2283
|
-
if (!entry.isDirectory() || entry.name.startsWith('.') || SKIPPED_DIRS.has(entry.name))
|
|
2284
|
-
continue;
|
|
2285
|
-
const child = join(dir, entry.name);
|
|
2286
|
-
try {
|
|
2287
|
-
const candidate = lstatSync(join(child, HARNESS_REPORT_PATH));
|
|
2288
|
-
if (candidate.isFile() && candidate.mtimeMs > rootMtimeMs) {
|
|
2289
|
-
fresher.push({
|
|
2290
|
-
path: normalizePath(relative(rootDir, join(child, HARNESS_REPORT_PATH))),
|
|
2291
|
-
mtimeMs: candidate.mtimeMs,
|
|
2292
|
-
});
|
|
2293
|
-
}
|
|
2294
|
-
}
|
|
2295
|
-
catch {
|
|
2296
|
-
// No report in this subdir; keep walking.
|
|
2297
|
-
}
|
|
2298
|
-
walk(child, depth + 1);
|
|
2299
|
-
}
|
|
2300
|
-
};
|
|
2301
|
-
walk(rootDir, 1);
|
|
2302
|
-
return fresher.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
2303
|
-
}
|
|
2304
|
-
/**
|
|
2305
|
-
* `--path` trap guard for `harness approve` / `harness sync`: when the
|
|
2306
|
-
* report the command is about to use is older than a fresher
|
|
2307
|
-
* `<subdir>/.moda/harness-report.json`, point at the fresher one. Skipped
|
|
2308
|
-
* when the user explicitly named a report file (they already chose).
|
|
2309
|
-
*/
|
|
2310
|
-
function warnFresherSubdirHarnessReports(context, rootDir, explicitReport) {
|
|
2311
|
-
// Only treat an explicit report as an opt-out when it points somewhere other
|
|
2312
|
-
// than the default root report. `--from-report=.moda/harness-report.json`
|
|
2313
|
-
// still targets the default report, so the fresher-subdir warning must fire.
|
|
2314
|
-
const explicitReportPath = explicitReport && explicitReport !== 'true'
|
|
2315
|
-
? normalizePath(relative(rootDir, resolve(rootDir, explicitReport)))
|
|
2316
|
-
: undefined;
|
|
2317
|
-
if (explicitReportPath && explicitReportPath !== normalizePath(HARNESS_REPORT_PATH))
|
|
2318
|
-
return;
|
|
2319
|
-
let fresher;
|
|
2320
|
-
try {
|
|
2321
|
-
fresher = findFresherHarnessReports(rootDir);
|
|
2322
|
-
}
|
|
2323
|
-
catch {
|
|
2324
|
-
return;
|
|
2325
|
-
}
|
|
2326
|
-
if (fresher.length === 0)
|
|
2327
|
-
return;
|
|
2328
|
-
const newest = fresher[0];
|
|
2329
|
-
const subdir = normalizePath(dirname(dirname(newest.path)));
|
|
2330
|
-
const rootState = existsSync(join(rootDir, HARNESS_REPORT_PATH))
|
|
2331
|
-
? `older ${HARNESS_REPORT_PATH}`
|
|
2332
|
-
: `no ${HARNESS_REPORT_PATH}`;
|
|
2333
|
-
writeHarnessNotice(context, `A fresher harness report exists at ${newest.path} (this directory has ${rootState}). ` +
|
|
2334
|
-
`If you meant that report, re-run with --path=${subdir}.`);
|
|
2335
|
-
}
|
|
2336
|
-
function readHarnessGraph(manifestPath) {
|
|
2337
|
-
return JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
2338
|
-
}
|
|
2339
|
-
export function writeHarnessArtifacts(graph, rootDir, options = {}) {
|
|
2340
|
-
const modaDir = join(rootDir, MODA_DIR);
|
|
2341
|
-
mkdirSync(modaDir, { recursive: true });
|
|
2342
|
-
const manifestPath = join(rootDir, HARNESS_PATH);
|
|
2343
|
-
writeFileSync(manifestPath, `${JSON.stringify(graph, null, 2)}\n`);
|
|
2344
|
-
const written = [HARNESS_PATH];
|
|
2345
|
-
if (options.summary) {
|
|
2346
|
-
written.push(writeHarnessSummary(graph, rootDir));
|
|
2347
|
-
}
|
|
2348
|
-
return written;
|
|
2349
|
-
}
|
|
2350
|
-
function writeHarnessSummary(graph, rootDir) {
|
|
2351
|
-
const summaryPath = join(rootDir, HARNESS_SUMMARY_PATH);
|
|
2352
|
-
mkdirSync(dirname(summaryPath), { recursive: true });
|
|
2353
|
-
writeFileSync(summaryPath, renderHarnessMarkdown(graph));
|
|
2354
|
-
return HARNESS_SUMMARY_PATH;
|
|
2355
|
-
}
|
|
2356
|
-
function renderHarnessMarkdown(graph) {
|
|
2357
|
-
const summary = summarizeHarness(graph);
|
|
2358
|
-
const lines = [
|
|
2359
|
-
'# Moda Harness',
|
|
2360
|
-
'',
|
|
2361
|
-
`Harness ID: ${graph.harnessId}`,
|
|
2362
|
-
`Tenant ID: ${graph.tenant?.tenantId ?? 'none'}`,
|
|
2363
|
-
`Runtime agents: ${summary.runtimeAgentCount}`,
|
|
2364
|
-
`Agent families: ${summary.agentFamilyCount}`,
|
|
2365
|
-
`Shared artifacts: ${summary.sharedArtifactCount}`,
|
|
2366
|
-
`Channel adapters: ${summary.channelAdapterCount}`,
|
|
2367
|
-
`Identity namespaces: ${summary.identityNamespaceCount}`,
|
|
2368
|
-
`Agent frameworks: ${summary.agentFrameworkCount}`,
|
|
2369
|
-
`Model providers: ${summary.modelProviderCount}`,
|
|
2370
|
-
'',
|
|
2371
|
-
'## What This Harness Does',
|
|
2372
|
-
'',
|
|
2373
|
-
...renderHarnessPurposeLines(graph),
|
|
2374
|
-
'',
|
|
2375
|
-
'## Frameworks And Models',
|
|
2376
|
-
'',
|
|
2377
|
-
...renderFrameworkAndProviderLines(graph),
|
|
2378
|
-
'',
|
|
2379
|
-
'## Moda Data Flow',
|
|
2380
|
-
'',
|
|
2381
|
-
'- `moda harness scan` reads local harness structure and writes `.moda/harness.json`.',
|
|
2382
|
-
'- `moda harness sync` uploads the graph metadata, hashes, and relationships to Moda Cloud.',
|
|
2383
|
-
'- Prompt, skill, eval, deployment, framework, and provider detections become the local context Moda uses for future recommendations.',
|
|
2384
|
-
'- Source content and secret-bearing files are not uploaded by default during scan.',
|
|
2385
|
-
'',
|
|
2386
|
-
'## Runtime Agents',
|
|
2387
|
-
'',
|
|
2388
|
-
];
|
|
2389
|
-
for (const agent of graph.runtimeAgents) {
|
|
2390
|
-
const artifacts = graph.artifacts.filter((artifact) => agent.artifactIds.includes(artifact.id));
|
|
2391
|
-
lines.push(`### ${agent.name}`, '', `- ID: ${agent.id}`, `- Root: ${agent.root}`, `- Purpose: ${agent.purpose ?? 'unknown'}`, `- Family: ${agent.familyId ?? 'none'}`, `- Channels: ${agent.channelAdapterIds.join(', ') || 'none'}`, `- Frameworks: ${(agent.frameworkIds ?? []).join(', ') || 'unknown'}`, `- Model providers: ${(agent.modelProviderIds ?? []).join(', ') || 'unknown'}`, `- Artifacts: ${artifacts.map((artifact) => artifact.name).join(', ') || 'none'}`, `- Confidence: ${agent.confidence.toFixed(2)}`, '');
|
|
2392
|
-
}
|
|
2393
|
-
if (graph.agentFamilies.length > 0) {
|
|
2394
|
-
lines.push('## Agent Families', '');
|
|
2395
|
-
for (const family of graph.agentFamilies) {
|
|
2396
|
-
lines.push(`### ${family.name}`, '', `- ID: ${family.id}`, `- Agents: ${family.runtimeAgentIds.join(', ') || 'none'}`, `- Shared artifacts: ${family.sharedArtifactIds.join(', ') || 'none'}`, '');
|
|
2397
|
-
}
|
|
2398
|
-
}
|
|
2399
|
-
if (graph.unknowns.length > 0) {
|
|
2400
|
-
lines.push('## Open Questions', '');
|
|
2401
|
-
for (const unknown of graph.unknowns) {
|
|
2402
|
-
lines.push(`- ${unknown.question}`);
|
|
2403
|
-
}
|
|
2404
|
-
lines.push('');
|
|
2405
|
-
}
|
|
2406
|
-
return `${lines.join('\n')}\n`;
|
|
2407
|
-
}
|
|
2408
|
-
function renderHarnessPurposeLines(graph) {
|
|
2409
|
-
if (graph.runtimeAgents.length === 0) {
|
|
2410
|
-
return ['- No runtime agents were confidently detected yet.'];
|
|
2411
|
-
}
|
|
2412
|
-
return graph.runtimeAgents.map((agent) => {
|
|
2413
|
-
const channels = agent.channelAdapterIds
|
|
2414
|
-
.map((id) => graph.channelAdapters.find((channel) => channel.id === id)?.name ?? titleFromId(id))
|
|
2415
|
-
.join(', ');
|
|
2416
|
-
const purpose = agent.purpose ?? inferPurposeFromAgentShape(graph, agent);
|
|
2417
|
-
return `- ${agent.name}: ${purpose}${channels ? ` (${channels})` : ''}.`;
|
|
2418
|
-
});
|
|
2419
|
-
}
|
|
2420
|
-
function inferPurposeFromAgentShape(graph, agent) {
|
|
2421
|
-
const prompts = graph.artifacts
|
|
2422
|
-
.filter((artifact) => agent.artifactIds.includes(artifact.id) && artifact.type === 'prompt')
|
|
2423
|
-
.map((artifact) => artifact.name);
|
|
2424
|
-
if (prompts.length > 0)
|
|
2425
|
-
return `Uses ${prompts.join(', ')}`;
|
|
2426
|
-
if (agent.channelAdapterIds.length > 0) {
|
|
2427
|
-
return `Handles ${agent.channelAdapterIds.map((id) => titleFromId(id)).join(', ')} traffic`;
|
|
2428
|
-
}
|
|
2429
|
-
return 'Runtime agent detected from local code';
|
|
2430
|
-
}
|
|
2431
|
-
function renderFrameworkAndProviderLines(graph) {
|
|
2432
|
-
const lines = [];
|
|
2433
|
-
if (graph.agentFrameworks.length > 0) {
|
|
2434
|
-
lines.push(`- Frameworks: ${graph.agentFrameworks.map((framework) => framework.name).join(', ')}`);
|
|
2435
|
-
}
|
|
2436
|
-
else {
|
|
2437
|
-
lines.push('- Frameworks: unknown');
|
|
2438
|
-
}
|
|
2439
|
-
if (graph.modelProviders.length > 0) {
|
|
2440
|
-
lines.push(`- Model providers: ${graph.modelProviders.map((provider) => provider.name).join(', ')}`);
|
|
2441
|
-
for (const provider of graph.modelProviders) {
|
|
2442
|
-
if (provider.modelHints.length > 0) {
|
|
2443
|
-
lines.push(`- ${provider.name} model hints: ${provider.modelHints.join(', ')}`);
|
|
2444
|
-
}
|
|
2445
|
-
}
|
|
2446
|
-
}
|
|
2447
|
-
else {
|
|
2448
|
-
lines.push('- Model providers: unknown');
|
|
2449
|
-
}
|
|
2450
|
-
return lines;
|
|
2451
|
-
}
|
|
2452
|
-
export function graphForSync(graph) {
|
|
2453
|
-
return sortJson({
|
|
2454
|
-
...graph,
|
|
2455
|
-
scan: {
|
|
2456
|
-
...graph.scan,
|
|
2457
|
-
createdAt: undefined,
|
|
2458
|
-
scannerVersion: undefined,
|
|
2459
|
-
skipped: undefined,
|
|
2460
|
-
},
|
|
2461
|
-
});
|
|
2462
|
-
}
|
|
2463
|
-
function hashStableJson(value) {
|
|
2464
|
-
return hashText(JSON.stringify(sortJson(value)));
|
|
2465
|
-
}
|
|
2466
|
-
function sortJson(value) {
|
|
2467
|
-
if (Array.isArray(value))
|
|
2468
|
-
return value.map((item) => sortJson(item));
|
|
2469
|
-
if (value && typeof value === 'object') {
|
|
2470
|
-
return Object.fromEntries(Object.entries(value)
|
|
2471
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
2472
|
-
.map(([key, item]) => [key, sortJson(item)]));
|
|
2473
|
-
}
|
|
2474
|
-
return value;
|
|
2475
|
-
}
|
|
2476
|
-
function getGitCommit(rootDir) {
|
|
2477
|
-
try {
|
|
2478
|
-
return execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
2479
|
-
cwd: rootDir,
|
|
2480
|
-
encoding: 'utf8',
|
|
2481
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
2482
|
-
}).trim() || undefined;
|
|
2483
|
-
}
|
|
2484
|
-
catch {
|
|
2485
|
-
return undefined;
|
|
2486
|
-
}
|
|
2487
|
-
}
|
|
2488
|
-
function explainAgent(graph, agent) {
|
|
2489
|
-
return {
|
|
2490
|
-
agent,
|
|
2491
|
-
family: agent.familyId
|
|
2492
|
-
? graph.agentFamilies.find((family) => family.id === agent.familyId) ?? null
|
|
2493
|
-
: null,
|
|
2494
|
-
channels: graph.channelAdapters.filter((channel) => agent.channelAdapterIds.includes(channel.id)),
|
|
2495
|
-
artifacts: graph.artifacts.filter((artifact) => agent.artifactIds.includes(artifact.id)),
|
|
2496
|
-
identities: graph.identityNamespaces.filter((identity) => agent.identityNamespaceIds.includes(identity.id)),
|
|
2497
|
-
deployments: graph.deployments.filter((deployment) => agent.deploymentIds.includes(deployment.id)),
|
|
2498
|
-
relationships: graph.relationships.filter((relationship) => relationship.from === agent.id || relationship.to === agent.id),
|
|
2499
|
-
};
|
|
2500
|
-
}
|
|
2501
|
-
/**
|
|
2502
|
-
* Bounded repo walk shared by `harness scan`, snapshot upload, and the static
|
|
2503
|
-
* candidates pre-pass: skips ignored/secret-bearing paths and symlinks,
|
|
2504
|
-
* enforces the file-count and text-byte budgets, and reads text for known
|
|
2505
|
-
* text extensions plus any file admitted by `options.includeFile`.
|
|
2506
|
-
* `options.includeFile` widens (ORs with) the default inventory predicate and
|
|
2507
|
-
* also opts its files into text reading — it can never re-admit secret-bearing
|
|
2508
|
-
* or oversized files, which are rejected before the predicate runs.
|
|
2509
|
-
*/
|
|
2510
|
-
export function inventoryFiles(rootDir, skipped, options = {}) {
|
|
2511
|
-
const files = [];
|
|
2512
|
-
const rootReal = resolve(rootDir);
|
|
2513
|
-
const maxFiles = numericEnv('MODA_HARNESS_MAX_FILES', DEFAULT_MAX_INVENTORY_FILES);
|
|
2514
|
-
const maxTextBytes = numericEnv('MODA_HARNESS_MAX_TEXT_BYTES', DEFAULT_MAX_INVENTORY_TEXT_BYTES);
|
|
2515
|
-
let textBytes = 0;
|
|
2516
|
-
function walk(current) {
|
|
2517
|
-
const rel = normalizePath(relative(rootReal, current)) || '.';
|
|
2518
|
-
let stat;
|
|
2519
|
-
try {
|
|
2520
|
-
stat = lstatSync(current);
|
|
2521
|
-
}
|
|
2522
|
-
catch {
|
|
2523
|
-
recordSkipped(skipped, { path: rel, reason: 'stat failed' });
|
|
2524
|
-
return;
|
|
2525
|
-
}
|
|
2526
|
-
if (stat.isSymbolicLink()) {
|
|
2527
|
-
recordSkipped(skipped, { path: rel, reason: 'symlink skipped for safety' });
|
|
2528
|
-
return;
|
|
2529
|
-
}
|
|
2530
|
-
if (stat.isDirectory()) {
|
|
2531
|
-
if (rel !== '.' && shouldSkipDirectory(rel, current)) {
|
|
2532
|
-
recordSkipped(skipped, { path: rel, reason: 'ignored directory' });
|
|
2533
|
-
return;
|
|
2534
|
-
}
|
|
2535
|
-
for (const entry of readdirSync(current).sort()) {
|
|
2536
|
-
walk(join(current, entry));
|
|
2537
|
-
}
|
|
2538
|
-
return;
|
|
2539
|
-
}
|
|
2540
|
-
if (!stat.isFile())
|
|
2541
|
-
return;
|
|
2542
|
-
if (stat.size > MAX_SCANNED_FILE_BYTES) {
|
|
2543
|
-
recordSkipped(skipped, { path: rel, reason: 'file too large for harness scan' });
|
|
2544
|
-
return;
|
|
2545
|
-
}
|
|
2546
|
-
if (isSecretishPath(rel)) {
|
|
2547
|
-
recordSkipped(skipped, { path: rel, reason: 'secret-bearing file skipped' });
|
|
2548
|
-
return;
|
|
2549
|
-
}
|
|
2550
|
-
if (!shouldInventoryFile(rel) && !(options.includeFile?.(rel) ?? false))
|
|
2551
|
-
return;
|
|
2552
|
-
if (files.length >= maxFiles) {
|
|
2553
|
-
throwHarnessScanTooBroad(rootReal, {
|
|
2554
|
-
files: files.length,
|
|
2555
|
-
textBytes,
|
|
2556
|
-
lastPath: rel,
|
|
2557
|
-
maxFiles,
|
|
2558
|
-
maxTextBytes,
|
|
2559
|
-
});
|
|
2560
|
-
}
|
|
2561
|
-
const item = { path: rel, absPath: current, rootDir: rootReal };
|
|
2562
|
-
// Read text for known text extensions, but also for any file the caller
|
|
2563
|
-
// widened in via `includeFile` (e.g. the candidates pass admits template
|
|
2564
|
-
// prompt files like `.jinja`/`.hbs`/`.tpl`/`.prompt` whose extensions are
|
|
2565
|
-
// not in `isTextFile`) — otherwise a widened-in file is counted as scanned
|
|
2566
|
-
// but never gets its text read, so downstream detectors silently skip it.
|
|
2567
|
-
if (isTextFile(rel) || (options.includeFile?.(rel) ?? false)) {
|
|
2568
|
-
if (textBytes + stat.size > maxTextBytes) {
|
|
2569
|
-
recordSkipped(skipped, { path: rel, reason: 'scan text budget exceeded' });
|
|
2570
|
-
}
|
|
2571
|
-
else {
|
|
2572
|
-
try {
|
|
2573
|
-
item.text = readFileSync(current, 'utf8');
|
|
2574
|
-
textBytes += Buffer.byteLength(item.text, 'utf8');
|
|
2575
|
-
}
|
|
2576
|
-
catch {
|
|
2577
|
-
recordSkipped(skipped, { path: rel, reason: 'text read failed' });
|
|
2578
|
-
}
|
|
2579
|
-
}
|
|
2580
|
-
}
|
|
2581
|
-
files.push(item);
|
|
2582
|
-
}
|
|
2583
|
-
walk(rootReal);
|
|
2584
|
-
return files;
|
|
2585
|
-
}
|
|
2586
|
-
function shouldSkipDirectory(rel, absPath) {
|
|
2587
|
-
if (SKIPPED_DIRS.has(basename(absPath)))
|
|
2588
|
-
return true;
|
|
2589
|
-
return SKIPPED_PATH_PREFIXES.some((prefix) => rel === prefix || rel.startsWith(`${prefix}/`));
|
|
2590
|
-
}
|
|
2591
|
-
function shouldInventoryFile(path) {
|
|
2592
|
-
const normalized = normalizePath(path);
|
|
2593
|
-
const base = basename(normalized);
|
|
2594
|
-
const ext = extname(normalized).toLowerCase();
|
|
2595
|
-
if (isSourceFile(normalized))
|
|
2596
|
-
return true;
|
|
2597
|
-
if (isPackageOrLockfile(normalized))
|
|
2598
|
-
return true;
|
|
2599
|
-
if (isPromptLikePath(normalized))
|
|
2600
|
-
return true;
|
|
2601
|
-
if (isSkillLikePath(normalized))
|
|
2602
|
-
return true;
|
|
2603
|
-
if (isEvalLikePath(normalized))
|
|
2604
|
-
return true;
|
|
2605
|
-
if (isDeploymentLikePath(normalized))
|
|
2606
|
-
return true;
|
|
2607
|
-
if (isDeveloperAgentConfigPath(normalized))
|
|
2608
|
-
return true;
|
|
2609
|
-
if (base === 'README.md' || base === 'AGENTS.md')
|
|
2610
|
-
return true;
|
|
2611
|
-
return ext === '.toml' && (base === 'pyproject.toml' || base === 'wrangler.toml');
|
|
2612
|
-
}
|
|
2613
|
-
function isSourceFile(path) {
|
|
2614
|
-
return /\.(ts|tsx|js|jsx|mjs|cjs|py)$/i.test(path);
|
|
2615
|
-
}
|
|
2616
|
-
function isPackageOrLockfile(path) {
|
|
2617
|
-
return path === 'package.json' ||
|
|
2618
|
-
path === 'pyproject.toml' ||
|
|
2619
|
-
path === 'bun.lock' ||
|
|
2620
|
-
path === 'bun.lockb' ||
|
|
2621
|
-
path === 'pnpm-lock.yaml' ||
|
|
2622
|
-
path === 'yarn.lock';
|
|
2623
|
-
}
|
|
2624
|
-
function isPromptLikePath(path) {
|
|
2625
|
-
return /(^|\/)prompts?\//i.test(path) &&
|
|
2626
|
-
/\.(prompt\.)?(md|json|ya?ml)$/i.test(path);
|
|
2627
|
-
}
|
|
2628
|
-
function isSkillLikePath(path) {
|
|
2629
|
-
return /(^|\/)(\.claude\/skills|\.cursor\/rules|skills?)\//i.test(path) &&
|
|
2630
|
-
/\.(md|mdc)$/i.test(path);
|
|
2631
|
-
}
|
|
2632
|
-
function isEvalLikePath(path) {
|
|
2633
|
-
return /(^|\/)(evals?|tests?|__tests__)\//i.test(path) &&
|
|
2634
|
-
/\.(ts|tsx|js|mjs|py|json|ya?ml)$/i.test(path);
|
|
2635
|
-
}
|
|
2636
|
-
function isDeploymentLikePath(path) {
|
|
2637
|
-
return /(^|\/)(wrangler\.toml|vercel\.json|render\.ya?ml|railway\.json|Dockerfile|docker-compose\.ya?ml)$/i.test(path) ||
|
|
2638
|
-
/^\.github\/workflows\/[^/]+\.ya?ml$/i.test(path);
|
|
2639
|
-
}
|
|
2640
|
-
function isDeveloperAgentConfigPath(path) {
|
|
2641
|
-
return path === '.claude/settings.json' ||
|
|
2642
|
-
path === '.codex/hooks.json' ||
|
|
2643
|
-
path === '.cursor/hooks.json';
|
|
2644
|
-
}
|
|
2645
|
-
function recordSkipped(skipped, item) {
|
|
2646
|
-
if (skipped.length < MAX_RECORDED_SKIPS) {
|
|
2647
|
-
skipped.push(item);
|
|
2648
|
-
return;
|
|
2649
|
-
}
|
|
2650
|
-
if (skipped.length === MAX_RECORDED_SKIPS) {
|
|
2651
|
-
skipped.push({
|
|
2652
|
-
path: '...',
|
|
2653
|
-
reason: 'additional skipped entries omitted',
|
|
2654
|
-
});
|
|
2655
|
-
}
|
|
2656
|
-
}
|
|
2657
|
-
function throwHarnessScanTooBroad(rootDir, details) {
|
|
2658
|
-
const mib = (details.textBytes / 1024 / 1024).toFixed(1);
|
|
2659
|
-
const maxMib = (details.maxTextBytes / 1024 / 1024).toFixed(0);
|
|
2660
|
-
throw new CliInputError('Harness scan is too broad for safe local analysis.', [
|
|
2661
|
-
`Moda reached ${details.files.toLocaleString()} files while scanning ${rootDir}.`,
|
|
2662
|
-
`Text read budget: ${mib}MiB of ${maxMib}MiB. Last path: ${details.lastPath}.`,
|
|
2663
|
-
'Run `moda init` from the actual agent project root, not a parent folder like ~/Projects.',
|
|
2664
|
-
'For harness-only analysis, use `moda harness analyze --path=<agent-dir> --summary`.',
|
|
2665
|
-
].join('\n'));
|
|
2666
|
-
}
|
|
2667
|
-
export function numericEnv(name, fallback) {
|
|
2668
|
-
const raw = process.env[name];
|
|
2669
|
-
if (!raw)
|
|
2670
|
-
return fallback;
|
|
2671
|
-
const parsed = Number(raw);
|
|
2672
|
-
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
|
|
2673
|
-
}
|
|
2674
|
-
export function numericEnvAllowZero(name, fallback) {
|
|
2675
|
-
const raw = process.env[name];
|
|
2676
|
-
if (!raw)
|
|
2677
|
-
return fallback;
|
|
2678
|
-
const parsed = Number(raw);
|
|
2679
|
-
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : fallback;
|
|
2680
|
-
}
|
|
2681
|
-
function isTextFile(path) {
|
|
2682
|
-
const ext = extname(path).toLowerCase();
|
|
2683
|
-
return [
|
|
2684
|
-
'',
|
|
2685
|
-
'.cjs',
|
|
2686
|
-
'.js',
|
|
2687
|
-
'.json',
|
|
2688
|
-
'.jsx',
|
|
2689
|
-
'.md',
|
|
2690
|
-
'.mdc',
|
|
2691
|
-
'.mjs',
|
|
2692
|
-
'.py',
|
|
2693
|
-
'.toml',
|
|
2694
|
-
'.ts',
|
|
2695
|
-
'.tsx',
|
|
2696
|
-
'.txt',
|
|
2697
|
-
'.yaml',
|
|
2698
|
-
'.yml',
|
|
2699
|
-
].includes(ext);
|
|
2700
|
-
}
|
|
2701
|
-
function isSecretishPath(path) {
|
|
2702
|
-
const name = basename(path).toLowerCase();
|
|
2703
|
-
return name === '.env' ||
|
|
2704
|
-
name.startsWith('.env.') ||
|
|
2705
|
-
name.includes('secret') ||
|
|
2706
|
-
name.includes('private-key') ||
|
|
2707
|
-
name.endsWith('.pem');
|
|
2708
|
-
}
|
|
2709
|
-
function detectRepo(rootDir, files) {
|
|
2710
|
-
const packageJson = files.find((file) => file.path === 'package.json');
|
|
2711
|
-
const pyproject = files.find((file) => file.path === 'pyproject.toml');
|
|
2712
|
-
const name = packageJson?.text
|
|
2713
|
-
? safePackageName(packageJson.text) ?? basename(rootDir)
|
|
2714
|
-
: pyproject?.text
|
|
2715
|
-
? safeTomlName(pyproject.text) ?? basename(rootDir)
|
|
2716
|
-
: basename(rootDir);
|
|
2717
|
-
const packageManagers = [
|
|
2718
|
-
files.some((file) => file.path === 'bun.lock' || file.path === 'bun.lockb') ? 'bun' : null,
|
|
2719
|
-
files.some((file) => file.path === 'pnpm-lock.yaml') ? 'pnpm' : null,
|
|
2720
|
-
files.some((file) => file.path === 'yarn.lock') ? 'yarn' : null,
|
|
2721
|
-
packageJson ? 'npm' : null,
|
|
2722
|
-
pyproject ? 'python' : null,
|
|
2723
|
-
].filter((value) => Boolean(value));
|
|
2724
|
-
const languages = [
|
|
2725
|
-
files.some((file) => file.path.endsWith('.ts') || file.path.endsWith('.tsx')) ? 'typescript' : null,
|
|
2726
|
-
files.some((file) => file.path.endsWith('.js') || file.path.endsWith('.jsx') || file.path.endsWith('.mjs')) ? 'javascript' : null,
|
|
2727
|
-
files.some((file) => file.path.endsWith('.py')) ? 'python' : null,
|
|
2728
|
-
].filter((value) => Boolean(value));
|
|
2729
|
-
return {
|
|
2730
|
-
root: '.',
|
|
2731
|
-
name,
|
|
2732
|
-
packageManagers: [...new Set(packageManagers)],
|
|
2733
|
-
languages: [...new Set(languages)],
|
|
2734
|
-
};
|
|
2735
|
-
}
|
|
2736
|
-
function safePackageName(text) {
|
|
2737
|
-
try {
|
|
2738
|
-
const parsed = JSON.parse(text);
|
|
2739
|
-
return typeof parsed.name === 'string' ? parsed.name : undefined;
|
|
2740
|
-
}
|
|
2741
|
-
catch {
|
|
2742
|
-
return undefined;
|
|
2743
|
-
}
|
|
2744
|
-
}
|
|
2745
|
-
function safeTomlName(text) {
|
|
2746
|
-
const match = text.match(/^\s*name\s*=\s*["']([^"']+)["']/m);
|
|
2747
|
-
return match?.[1];
|
|
2748
|
-
}
|
|
2749
|
-
function parseAnnotations(file) {
|
|
2750
|
-
if (!file.text?.includes('moda:'))
|
|
2751
|
-
return [];
|
|
2752
|
-
const annotations = [];
|
|
2753
|
-
const lines = file.text.split(/\r?\n/);
|
|
2754
|
-
lines.forEach((line, index) => {
|
|
2755
|
-
const match = line.match(/\bmoda:\s*([a-z-]+)\s*(.*)$/i);
|
|
2756
|
-
if (!match)
|
|
2757
|
-
return;
|
|
2758
|
-
annotations.push({
|
|
2759
|
-
type: match[1],
|
|
2760
|
-
values: parseAnnotationValues(match[2] ?? ''),
|
|
2761
|
-
evidence: {
|
|
2762
|
-
path: file.path,
|
|
2763
|
-
reason: `Explicit moda ${match[1]} annotation on line ${index + 1}.`,
|
|
2764
|
-
},
|
|
2765
|
-
sourceAbsPath: file.absPath,
|
|
2766
|
-
rootDir: file.rootDir,
|
|
2767
|
-
});
|
|
2768
|
-
});
|
|
2769
|
-
return annotations;
|
|
2770
|
-
}
|
|
2771
|
-
function parseAnnotationValues(raw) {
|
|
2772
|
-
const values = {};
|
|
2773
|
-
const regex = /([A-Za-z0-9_-]+)=("([^"]*)"|'([^']*)'|[^\s]+)/g;
|
|
2774
|
-
let match;
|
|
2775
|
-
while ((match = regex.exec(raw))) {
|
|
2776
|
-
const key = match[1].replace(/-/g, '_');
|
|
2777
|
-
const value = match[3] ?? match[4] ?? match[2];
|
|
2778
|
-
if (!values[key])
|
|
2779
|
-
values[key] = [];
|
|
2780
|
-
values[key].push(value);
|
|
2781
|
-
}
|
|
2782
|
-
return values;
|
|
2783
|
-
}
|
|
2784
|
-
function applyAnnotations(graph, annotations) {
|
|
2785
|
-
for (const annotation of annotations) {
|
|
2786
|
-
switch (annotation.type) {
|
|
2787
|
-
case 'runtime-agent':
|
|
2788
|
-
case 'agent':
|
|
2789
|
-
upsertRuntimeAgent(graph, runtimeAgentFromAnnotation(annotation));
|
|
2790
|
-
break;
|
|
2791
|
-
case 'agent-family':
|
|
2792
|
-
case 'family':
|
|
2793
|
-
upsertAgentFamily(graph, agentFamilyFromAnnotation(annotation));
|
|
2794
|
-
break;
|
|
2795
|
-
case 'channel':
|
|
2796
|
-
upsertChannel(graph, channelFromAnnotation(annotation));
|
|
2797
|
-
break;
|
|
2798
|
-
case 'artifact':
|
|
2799
|
-
upsertArtifact(graph, artifactFromAnnotation(annotation));
|
|
2800
|
-
break;
|
|
2801
|
-
case 'identity':
|
|
2802
|
-
upsertIdentity(graph, identityFromAnnotation(annotation));
|
|
2803
|
-
break;
|
|
2804
|
-
case 'deployment':
|
|
2805
|
-
upsertDeployment(graph, deploymentFromAnnotation(annotation));
|
|
2806
|
-
break;
|
|
2807
|
-
case 'environment':
|
|
2808
|
-
upsertEnvironment(graph, environmentFromAnnotation(annotation));
|
|
2809
|
-
break;
|
|
2810
|
-
default:
|
|
2811
|
-
graph.unknowns.push({
|
|
2812
|
-
id: stableId('unknown', `${annotation.type}-${annotation.evidence.path}`),
|
|
2813
|
-
question: `Unknown moda annotation type '${annotation.type}' in ${annotation.evidence.path}.`,
|
|
2814
|
-
candidateIds: [],
|
|
2815
|
-
evidence: [annotation.evidence],
|
|
2816
|
-
});
|
|
2817
|
-
}
|
|
2818
|
-
}
|
|
2819
|
-
}
|
|
2820
|
-
function runtimeAgentFromAnnotation(annotation) {
|
|
2821
|
-
const values = annotation.values;
|
|
2822
|
-
const id = first(values.id) ?? stableId('agent', first(values.name) ?? annotation.evidence.path);
|
|
2823
|
-
return {
|
|
2824
|
-
id,
|
|
2825
|
-
name: first(values.name) ?? titleFromId(id),
|
|
2826
|
-
kind: 'runtime_agent',
|
|
2827
|
-
purpose: first(values.purpose) ?? first(values.description),
|
|
2828
|
-
familyId: first(values.family),
|
|
2829
|
-
root: normalizePath(first(values.root) ?? dirname(annotation.evidence.path)),
|
|
2830
|
-
entrypointIds: values.entrypoint ?? [stableId('entry', annotation.evidence.path)],
|
|
2831
|
-
channelAdapterIds: values.channel ?? [],
|
|
2832
|
-
artifactIds: [...(values.artifact ?? []), ...(values.prompt ?? []), ...(values.tool ?? [])],
|
|
2833
|
-
identityNamespaceIds: values.identity ?? [],
|
|
2834
|
-
deploymentIds: values.deployment ?? [],
|
|
2835
|
-
frameworkIds: values.framework ?? [],
|
|
2836
|
-
modelProviderIds: [...(values.provider ?? []), ...(values.model_provider ?? [])],
|
|
2837
|
-
confidence: numberValue(first(values.confidence), 0.9),
|
|
2838
|
-
evidence: [annotation.evidence],
|
|
2839
|
-
};
|
|
2840
|
-
}
|
|
2841
|
-
function agentFamilyFromAnnotation(annotation) {
|
|
2842
|
-
const values = annotation.values;
|
|
2843
|
-
const id = first(values.id) ?? stableId('family', first(values.name) ?? annotation.evidence.path);
|
|
2844
|
-
return {
|
|
2845
|
-
id,
|
|
2846
|
-
name: first(values.name) ?? titleFromId(id),
|
|
2847
|
-
description: first(values.description),
|
|
2848
|
-
runtimeAgentIds: values.agent ?? [],
|
|
2849
|
-
sharedArtifactIds: [...(values.artifact ?? []), ...(values.prompt ?? []), ...(values.tool ?? [])],
|
|
2850
|
-
confidence: numberValue(first(values.confidence), 0.86),
|
|
2851
|
-
evidence: [annotation.evidence],
|
|
2852
|
-
};
|
|
2853
|
-
}
|
|
2854
|
-
function channelFromAnnotation(annotation) {
|
|
2855
|
-
const values = annotation.values;
|
|
2856
|
-
const kind = first(values.kind) ?? first(values.type) ?? 'channel';
|
|
2857
|
-
const id = first(values.id) ?? stableId('channel', first(values.name) ?? kind);
|
|
2858
|
-
return {
|
|
2859
|
-
id,
|
|
2860
|
-
name: first(values.name) ?? titleFromId(id),
|
|
2861
|
-
kind,
|
|
2862
|
-
sourcePath: annotation.evidence.path,
|
|
2863
|
-
confidence: numberValue(first(values.confidence), 0.85),
|
|
2864
|
-
evidence: [annotation.evidence],
|
|
2865
|
-
};
|
|
2866
|
-
}
|
|
2867
|
-
function artifactFromAnnotation(annotation) {
|
|
2868
|
-
const values = annotation.values;
|
|
2869
|
-
const type = artifactType(first(values.kind) ?? first(values.type));
|
|
2870
|
-
const annotatedPath = first(values.path) ?? annotation.evidence.path;
|
|
2871
|
-
// Canonicalize nested relative path= annotations to a repo-relative location
|
|
2872
|
-
// so later whole-file body hydration and hashing find the real prompt/tool file.
|
|
2873
|
-
const sourcePath = resolveAnnotatedSourcePath(annotatedPath, annotation);
|
|
2874
|
-
const id = first(values.id) ?? stableId(type, first(values.name) ?? sourcePath);
|
|
2875
|
-
// Body reads from the RAW annotated path on purpose: readPromptOrToolBody walks
|
|
2876
|
-
// the annotation's ancestor dirs itself to locate nested-package files, so it
|
|
2877
|
-
// must not be handed the already-canonicalized sourcePath. sourcePath and
|
|
2878
|
-
// contentHash use the canonical path so downstream whole-file hydration and
|
|
2879
|
-
// hashing resolve the same file the body came from.
|
|
2880
|
-
const body = readPromptOrToolBody(type, annotatedPath, annotation);
|
|
2881
|
-
return {
|
|
2882
|
-
id,
|
|
2883
|
-
type,
|
|
2884
|
-
name: first(values.name) ?? titleFromId(id),
|
|
2885
|
-
scope: scopeValue(first(values.scope)),
|
|
2886
|
-
sourcePath,
|
|
2887
|
-
contentHash: contentHashForAnnotatedSource(sourcePath, annotation),
|
|
2888
|
-
body,
|
|
2889
|
-
ownedByAgentId: first(values.owner),
|
|
2890
|
-
usedByAgentIds: values.agent ?? [],
|
|
2891
|
-
confidence: numberValue(first(values.confidence), 0.85),
|
|
2892
|
-
evidence: [annotation.evidence],
|
|
2893
|
-
};
|
|
2894
|
-
}
|
|
2895
|
-
function identityFromAnnotation(annotation) {
|
|
2896
|
-
const values = annotation.values;
|
|
2897
|
-
const kind = first(values.kind) ?? first(values.type) ?? 'identity';
|
|
2898
|
-
const id = first(values.id) ?? stableId('identity', first(values.name) ?? kind);
|
|
2899
|
-
return {
|
|
2900
|
-
id,
|
|
2901
|
-
name: first(values.name) ?? titleFromId(id),
|
|
2902
|
-
kind,
|
|
2903
|
-
canonicalId: first(values.canonical),
|
|
2904
|
-
sourcePath: annotation.evidence.path,
|
|
2905
|
-
confidence: numberValue(first(values.confidence), 0.8),
|
|
2906
|
-
evidence: [annotation.evidence],
|
|
2907
|
-
};
|
|
2908
|
-
}
|
|
2909
|
-
function deploymentFromAnnotation(annotation) {
|
|
2910
|
-
const values = annotation.values;
|
|
2911
|
-
const id = first(values.id) ?? stableId('deploy', first(values.name) ?? annotation.evidence.path);
|
|
2912
|
-
return {
|
|
2913
|
-
id,
|
|
2914
|
-
name: first(values.name) ?? titleFromId(id),
|
|
2915
|
-
environmentId: first(values.environment),
|
|
2916
|
-
sourcePath: first(values.path) ?? annotation.evidence.path,
|
|
2917
|
-
confidence: numberValue(first(values.confidence), 0.78),
|
|
2918
|
-
evidence: [annotation.evidence],
|
|
2919
|
-
};
|
|
2920
|
-
}
|
|
2921
|
-
function environmentFromAnnotation(annotation) {
|
|
2922
|
-
const values = annotation.values;
|
|
2923
|
-
const kind = first(values.kind) ?? first(values.type) ?? 'runtime';
|
|
2924
|
-
const id = first(values.id) ?? stableId('env', first(values.name) ?? kind);
|
|
2925
|
-
return {
|
|
2926
|
-
id,
|
|
2927
|
-
name: first(values.name) ?? titleFromId(id),
|
|
2928
|
-
kind,
|
|
2929
|
-
sourcePath: annotation.evidence.path,
|
|
2930
|
-
confidence: numberValue(first(values.confidence), 0.75),
|
|
2931
|
-
evidence: [annotation.evidence],
|
|
2932
|
-
};
|
|
2933
|
-
}
|
|
2934
|
-
function applyHeuristics(graph, files) {
|
|
2935
|
-
const isApplicationCode = applicationCodePredicate(files);
|
|
2936
|
-
for (const file of files) {
|
|
2937
|
-
maybeAddPromptArtifact(graph, file);
|
|
2938
|
-
maybeAddSkillArtifact(graph, file);
|
|
2939
|
-
if (isApplicationCode(file.path))
|
|
2940
|
-
maybeAddToolArtifact(graph, file);
|
|
2941
|
-
maybeAddEvalArtifact(graph, file);
|
|
2942
|
-
maybeAddDeployment(graph, file);
|
|
2943
|
-
}
|
|
2944
|
-
if (graph.runtimeAgents.length === 0) {
|
|
2945
|
-
for (const file of files) {
|
|
2946
|
-
if (!isApplicationCode(file.path))
|
|
2947
|
-
continue;
|
|
2948
|
-
const agent = inferRuntimeAgent(file, graph);
|
|
2949
|
-
if (agent)
|
|
2950
|
-
upsertRuntimeAgent(graph, agent);
|
|
2951
|
-
}
|
|
2952
|
-
}
|
|
2953
|
-
for (const agent of graph.runtimeAgents) {
|
|
2954
|
-
if (agent.entrypointIds.length === 0) {
|
|
2955
|
-
const entryId = stableId('entry', agent.root);
|
|
2956
|
-
agent.entrypointIds.push(entryId);
|
|
2957
|
-
upsertArtifact(graph, {
|
|
2958
|
-
id: entryId,
|
|
2959
|
-
type: 'entrypoint',
|
|
2960
|
-
name: `${agent.name} Entrypoint`,
|
|
2961
|
-
scope: 'agent',
|
|
2962
|
-
sourcePath: agent.root,
|
|
2963
|
-
ownedByAgentId: agent.id,
|
|
2964
|
-
usedByAgentIds: [agent.id],
|
|
2965
|
-
confidence: 0.7,
|
|
2966
|
-
evidence: agent.evidence,
|
|
2967
|
-
});
|
|
2968
|
-
}
|
|
2969
|
-
}
|
|
2970
|
-
}
|
|
2971
|
-
function maybeAddPromptArtifact(graph, file) {
|
|
2972
|
-
const isPrompt = /(^|\/)prompts?\//i.test(file.path) &&
|
|
2973
|
-
/\.(prompt\.)?(md|json|ya?ml)$/i.test(file.path);
|
|
2974
|
-
if (!isPrompt)
|
|
2975
|
-
return;
|
|
2976
|
-
const id = stableId('prompt', file.path);
|
|
2977
|
-
const text = file.text ?? readFileSync(file.absPath, 'utf8');
|
|
2978
|
-
upsertArtifact(graph, {
|
|
2979
|
-
id,
|
|
2980
|
-
type: 'prompt',
|
|
2981
|
-
name: promptNameFromPath(file.path),
|
|
2982
|
-
scope: 'agent',
|
|
2983
|
-
sourcePath: file.path,
|
|
2984
|
-
contentHash: hashText(text),
|
|
2985
|
-
// Capture the full prompt text so downstream prompt optimization can use it
|
|
2986
|
-
// directly without re-reading the repo.
|
|
2987
|
-
body: text,
|
|
2988
|
-
usedByAgentIds: [],
|
|
2989
|
-
confidence: 0.72,
|
|
2990
|
-
evidence: [{ path: file.path, reason: 'Prompt-like file found under prompts directory.' }],
|
|
2991
|
-
});
|
|
2992
|
-
}
|
|
2993
|
-
function maybeAddSkillArtifact(graph, file) {
|
|
2994
|
-
if (!/(^|\/)(\.claude\/skills|\.cursor\/rules|skills?)\//i.test(file.path))
|
|
2995
|
-
return;
|
|
2996
|
-
if (!/\.(md|mdc)$/i.test(file.path))
|
|
2997
|
-
return;
|
|
2998
|
-
upsertArtifact(graph, {
|
|
2999
|
-
id: stableId('skill', file.path),
|
|
3000
|
-
type: 'skill',
|
|
3001
|
-
name: titleFromId(basename(file.path).replace(/\.(md|mdc)$/i, '')),
|
|
3002
|
-
scope: 'global',
|
|
3003
|
-
sourcePath: file.path,
|
|
3004
|
-
contentHash: hashText(file.text ?? ''),
|
|
3005
|
-
usedByAgentIds: [],
|
|
3006
|
-
confidence: 0.7,
|
|
3007
|
-
evidence: [{ path: file.path, reason: 'Skill or rule file detected.' }],
|
|
3008
|
-
});
|
|
3009
|
-
}
|
|
3010
|
-
function maybeAddEvalArtifact(graph, file) {
|
|
3011
|
-
if (!/(eval|test|spec)/i.test(file.path))
|
|
3012
|
-
return;
|
|
3013
|
-
if (!/\.(ts|tsx|js|mjs|py|json|ya?ml)$/i.test(file.path))
|
|
3014
|
-
return;
|
|
3015
|
-
upsertArtifact(graph, {
|
|
3016
|
-
id: stableId('eval', file.path),
|
|
3017
|
-
type: 'eval',
|
|
3018
|
-
name: titleFromId(file.path),
|
|
3019
|
-
scope: 'global',
|
|
3020
|
-
sourcePath: file.path,
|
|
3021
|
-
contentHash: file.text ? hashText(file.text) : undefined,
|
|
3022
|
-
usedByAgentIds: [],
|
|
3023
|
-
confidence: 0.52,
|
|
3024
|
-
evidence: [{ path: file.path, reason: 'Eval/test-like file detected.' }],
|
|
3025
|
-
});
|
|
3026
|
-
}
|
|
3027
|
-
function maybeAddDeployment(graph, file) {
|
|
3028
|
-
if (!/(wrangler\.toml|vercel\.json|render\.yaml|railway\.json|Dockerfile|docker-compose\.ya?ml|\.github\/workflows\/)/i.test(file.path)) {
|
|
3029
|
-
return;
|
|
3030
|
-
}
|
|
3031
|
-
upsertDeployment(graph, {
|
|
3032
|
-
id: stableId('deploy', file.path),
|
|
3033
|
-
name: titleFromId(file.path),
|
|
3034
|
-
sourcePath: file.path,
|
|
3035
|
-
confidence: 0.6,
|
|
3036
|
-
evidence: [{ path: file.path, reason: 'Deployment or CI configuration detected.' }],
|
|
3037
|
-
});
|
|
3038
|
-
}
|
|
3039
|
-
function inferRuntimeAgent(file, graph) {
|
|
3040
|
-
if (!file.text)
|
|
3041
|
-
return null;
|
|
3042
|
-
if (!/\.(ts|tsx|js|mjs|py)$/i.test(file.path))
|
|
3043
|
-
return null;
|
|
3044
|
-
if (basename(file.path) === '__init__.py')
|
|
3045
|
-
return null;
|
|
3046
|
-
const loweredPath = file.path.toLowerCase();
|
|
3047
|
-
const loweredText = file.text.toLowerCase();
|
|
3048
|
-
const pathLooksLikeAgent = /(^|\/)(agent|agents|agent_harness)\//.test(loweredPath) ||
|
|
3049
|
-
/(^|\/)(agent|main|index)\.(ts|tsx|js|mjs|py)$/.test(loweredPath);
|
|
3050
|
-
// Require evidence that the file actually constructs or runs an agent, not
|
|
3051
|
-
// merely that it mentions a model provider. Loose signals like "openai" or
|
|
3052
|
-
// "model" match library internals and utilities that are not runtime agents.
|
|
3053
|
-
const codeLooksLikeAgent = AGENT_RUNTIME_SIGNAL.test(file.text);
|
|
3054
|
-
if (!pathLooksLikeAgent || !codeLooksLikeAgent)
|
|
3055
|
-
return null;
|
|
3056
|
-
const channelIds = inferChannels(file);
|
|
3057
|
-
const artifactIds = inferArtifactsForFile(file, graph);
|
|
3058
|
-
const frameworkIds = frameworkIdsForFile(file);
|
|
3059
|
-
const modelProviderIds = providerIdsForFile(file);
|
|
3060
|
-
const agentId = stableId('agent', file.path.replace(/\.(ts|tsx|js|mjs|py)$/i, ''));
|
|
3061
|
-
const entryId = stableId('entry', file.path);
|
|
3062
|
-
upsertArtifact(graph, {
|
|
3063
|
-
id: entryId,
|
|
3064
|
-
type: 'entrypoint',
|
|
3065
|
-
name: `${titleFromId(agentId)} Entrypoint`,
|
|
3066
|
-
scope: 'agent',
|
|
3067
|
-
sourcePath: file.path,
|
|
3068
|
-
contentHash: hashText(file.text),
|
|
3069
|
-
ownedByAgentId: agentId,
|
|
3070
|
-
usedByAgentIds: [agentId],
|
|
3071
|
-
confidence: 0.7,
|
|
3072
|
-
evidence: [{ path: file.path, reason: 'Agent-like entrypoint detected from path and code.' }],
|
|
3073
|
-
});
|
|
3074
|
-
return {
|
|
3075
|
-
id: agentId,
|
|
3076
|
-
name: titleFromId(file.path.replace(/\.(ts|tsx|js|mjs|py)$/i, '')),
|
|
3077
|
-
kind: 'runtime_agent',
|
|
3078
|
-
purpose: inferAgentPurpose(file),
|
|
3079
|
-
root: normalizePath(dirname(file.path)),
|
|
3080
|
-
entrypointIds: [entryId],
|
|
3081
|
-
channelAdapterIds: channelIds,
|
|
3082
|
-
artifactIds,
|
|
3083
|
-
identityNamespaceIds: [],
|
|
3084
|
-
deploymentIds: [],
|
|
3085
|
-
frameworkIds,
|
|
3086
|
-
modelProviderIds,
|
|
3087
|
-
confidence: loweredText.includes('run_agent') || loweredText.includes('runagent') ? 0.68 : 0.56,
|
|
3088
|
-
evidence: [{ path: file.path, reason: 'Agent-like entrypoint detected from path and code.' }],
|
|
3089
|
-
};
|
|
3090
|
-
}
|
|
3091
|
-
function inferAgentPurpose(file) {
|
|
3092
|
-
const text = file.text ?? '';
|
|
3093
|
-
const promptMatch = text.match(/(?:systemPrompt|system_prompt|SYSTEM_PROMPT)\s*[:=]\s*["'`]([^"'`]{12,180})/);
|
|
3094
|
-
const quoted = promptMatch?.[1];
|
|
3095
|
-
if (quoted)
|
|
3096
|
-
return titleCaseSentence(quoted.split(/[.!?]/)[0]);
|
|
3097
|
-
const commentMatch = text.match(/(?:purpose|description)\s*[:=]\s*["'`]([^"'`]{12,180})/i);
|
|
3098
|
-
if (commentMatch?.[1])
|
|
3099
|
-
return titleCaseSentence(commentMatch[1].split(/[.!?]/)[0]);
|
|
3100
|
-
const path = file.path.toLowerCase();
|
|
3101
|
-
if (path.includes('support'))
|
|
3102
|
-
return 'Support Agent';
|
|
3103
|
-
if (path.includes('triage'))
|
|
3104
|
-
return 'Triage Agent';
|
|
3105
|
-
if (path.includes('coding'))
|
|
3106
|
-
return 'Coding Agent';
|
|
3107
|
-
return undefined;
|
|
3108
|
-
}
|
|
3109
|
-
function titleCaseSentence(value) {
|
|
3110
|
-
return value
|
|
3111
|
-
.replace(/\s+/g, ' ')
|
|
3112
|
-
.trim()
|
|
3113
|
-
.split(' ')
|
|
3114
|
-
.map((word) => {
|
|
3115
|
-
if (!word)
|
|
3116
|
-
return word;
|
|
3117
|
-
if (/^[A-Z0-9_]+$/.test(word))
|
|
3118
|
-
return word;
|
|
3119
|
-
return word.charAt(0).toUpperCase() + word.slice(1);
|
|
3120
|
-
})
|
|
3121
|
-
.join(' ');
|
|
3122
|
-
}
|
|
3123
|
-
function inferChannels(file) {
|
|
3124
|
-
const text = `${file.path}\n${file.text ?? ''}`.toLowerCase();
|
|
3125
|
-
const channels = [
|
|
3126
|
-
['whatsapp', 'channel_whatsapp'],
|
|
3127
|
-
['sms', 'channel_sms'],
|
|
3128
|
-
['voice', 'channel_voice'],
|
|
3129
|
-
['slack', 'channel_slack'],
|
|
3130
|
-
['email', 'channel_email'],
|
|
3131
|
-
['web', 'channel_web'],
|
|
3132
|
-
['cron', 'channel_cron'],
|
|
3133
|
-
['api', 'channel_api'],
|
|
3134
|
-
];
|
|
3135
|
-
return channels
|
|
3136
|
-
.filter(([needle]) => text.includes(needle))
|
|
3137
|
-
.map(([, id]) => id);
|
|
3138
|
-
}
|
|
3139
|
-
// Trailing module names an import statement references, normalized to their last
|
|
3140
|
-
// path segment without extension. Covers ES/TS `from '...'` / `import(...)` /
|
|
3141
|
-
// `require(...)` and Python `from a.b.c import ...` / `import a.b.c`. Used to link
|
|
3142
|
-
// split-file tools whose module is imported by extensionless or dotted name
|
|
3143
|
-
// (e.g. `from tools import get_weather`, `import { getWeather } from './tools'`).
|
|
3144
|
-
function importedModuleNames(text) {
|
|
3145
|
-
const names = new Set();
|
|
3146
|
-
for (const match of text.matchAll(/(?:from|import|require)\s*\(?\s*["']([^"']+)["']/g)) {
|
|
3147
|
-
const segment = match[1].split('/').pop();
|
|
3148
|
-
if (segment)
|
|
3149
|
-
names.add(segment.replace(/\.(ts|tsx|js|mjs|py)$/i, ''));
|
|
3150
|
-
}
|
|
3151
|
-
for (const match of text.matchAll(/^\s*(?:from|import)\s+([.\w]+)/gm)) {
|
|
3152
|
-
const segment = match[1].replace(/^\.+/, '').split('.').pop();
|
|
3153
|
-
if (segment)
|
|
3154
|
-
names.add(segment);
|
|
3155
|
-
}
|
|
3156
|
-
return names;
|
|
3157
|
-
}
|
|
3158
|
-
function inferArtifactsForFile(file, graph) {
|
|
3159
|
-
const text = file.text ?? '';
|
|
3160
|
-
const imported = importedModuleNames(text);
|
|
3161
|
-
const referenced = graph.artifacts.filter((artifact) => {
|
|
3162
|
-
if (!artifact.sourcePath)
|
|
3163
|
-
return false;
|
|
3164
|
-
if (artifact.sourcePath === file.path)
|
|
3165
|
-
return true;
|
|
3166
|
-
const base = basename(artifact.sourcePath);
|
|
3167
|
-
if (text.includes(base) || text.includes(artifact.sourcePath))
|
|
3168
|
-
return true;
|
|
3169
|
-
// Split-file tools: the agent imports the tool module by its extensionless or
|
|
3170
|
-
// dotted name, which contains neither the basename-with-extension nor the full
|
|
3171
|
-
// source path (e.g. `from tools import get_weather`, `from './tools'`).
|
|
3172
|
-
if (artifact.type === 'tool') {
|
|
3173
|
-
const moduleName = base.replace(/\.(ts|tsx|js|mjs|py)$/i, '');
|
|
3174
|
-
if (moduleName && imported.has(moduleName))
|
|
3175
|
-
return true;
|
|
3176
|
-
}
|
|
3177
|
-
return false;
|
|
3178
|
-
});
|
|
3179
|
-
return referenced.map((artifact) => artifact.id);
|
|
3180
|
-
}
|
|
3181
|
-
// Signals that a file constructs or runs an agent (as opposed to merely
|
|
3182
|
-
// importing a model SDK). Kept deliberately construction/run oriented so
|
|
3183
|
-
// library internals that only reference a provider are not misread as agents.
|
|
3184
|
-
// Covers the LangChain factory/executor shapes (create_tool_calling_agent,
|
|
3185
|
-
// create_openai_functions_agent, AgentExecutor, ...) and the `return`/`yield
|
|
3186
|
-
// Agent(...)` factory forms in addition to the direct construction/run signals.
|
|
3187
|
-
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;
|
|
3188
|
-
// Hosted/first-party tool classes that count as tool definitions when called.
|
|
3189
|
-
const HOSTED_TOOL_CLASSES = [
|
|
3190
|
-
'WebSearchTool',
|
|
3191
|
-
'FileSearchTool',
|
|
3192
|
-
'ComputerTool',
|
|
3193
|
-
'CodeInterpreterTool',
|
|
3194
|
-
'HostedMCPTool',
|
|
3195
|
-
'ImageGenerationTool',
|
|
3196
|
-
'LocalShellTool',
|
|
3197
|
-
'ShellTool',
|
|
3198
|
-
'ApplyPatchTool',
|
|
3199
|
-
'FunctionTool',
|
|
3200
|
-
];
|
|
3201
|
-
function repoOwnPackageNames(files) {
|
|
3202
|
-
const packageJson = files.find((file) => file.path === 'package.json')?.text;
|
|
3203
|
-
const pyproject = files.find((file) => file.path === 'pyproject.toml')?.text;
|
|
3204
|
-
const names = [
|
|
3205
|
-
packageJson ? safePackageName(packageJson) : undefined,
|
|
3206
|
-
pyproject ? safeTomlName(pyproject) : undefined,
|
|
3207
|
-
];
|
|
3208
|
-
return names.filter((name) => Boolean(name)).map((name) => name.toLowerCase());
|
|
3209
|
-
}
|
|
3210
|
-
function repoIsAgentFrameworkSource(files) {
|
|
3211
|
-
const own = new Set(repoOwnPackageNames(files));
|
|
3212
|
-
if (own.size === 0)
|
|
3213
|
-
return false;
|
|
3214
|
-
return FRAMEWORK_CATALOG.some((framework) => framework.packageNames.some((name) => own.has(name.toLowerCase())));
|
|
3215
|
-
}
|
|
3216
|
-
function isApplicationLikePath(path) {
|
|
3217
|
-
return /(^|\/)(examples?|apps?|demos?|samples?|cookbook|tutorials?|quickstart|playground)\//i.test(path);
|
|
3218
|
-
}
|
|
3219
|
-
// When the repository IS an agent framework/SDK (its own package name matches a
|
|
3220
|
-
// framework catalog entry), its library source is the framework implementation,
|
|
3221
|
-
// not customer application code: runtime agents and tools should only be
|
|
3222
|
-
// inferred from application-like directories (examples, apps, demos, ...).
|
|
3223
|
-
function applicationCodePredicate(files) {
|
|
3224
|
-
return repoIsAgentFrameworkSource(files) ? isApplicationLikePath : () => true;
|
|
3225
|
-
}
|
|
3226
|
-
const HOSTED_TOOL_CALL = new RegExp(`\\b(${HOSTED_TOOL_CLASSES.join('|')})\\s*\\(`, 'g');
|
|
3227
|
-
function detectToolDefinitions(file) {
|
|
3228
|
-
const text = file.text;
|
|
3229
|
-
if (!text)
|
|
3230
|
-
return [];
|
|
3231
|
-
const found = new Map();
|
|
3232
|
-
const pyDecorated = /@function_tool[^\n]*\n(?:[ \t]*@[^\n]*\n)*[ \t]*(?:async[ \t]+)?def[ \t]+([A-Za-z_]\w*)/g;
|
|
3233
|
-
for (const match of text.matchAll(pyDecorated))
|
|
3234
|
-
found.set(match[1], '@function_tool-decorated function');
|
|
3235
|
-
const tsFactory = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:[\w.]*\.)?(?:tool|functionTool|createTool|dynamicTool)\s*\(/g;
|
|
3236
|
-
for (const match of text.matchAll(tsFactory))
|
|
3237
|
-
found.set(match[1], 'tool factory assignment');
|
|
3238
|
-
const tsNewTool = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*new\s+(?:Dynamic|Structured)?Tool\b/g;
|
|
3239
|
-
for (const match of text.matchAll(tsNewTool))
|
|
3240
|
-
found.set(match[1], 'tool constructor assignment');
|
|
3241
|
-
for (const match of text.matchAll(HOSTED_TOOL_CALL))
|
|
3242
|
-
found.set(match[1], `hosted tool ${match[1]}`);
|
|
3243
|
-
return [...found.entries()].map(([name, reason]) => ({ name, reason }));
|
|
3244
|
-
}
|
|
3245
|
-
function maybeAddToolArtifact(graph, file) {
|
|
3246
|
-
if (!/\.(ts|tsx|js|mjs|py)$/i.test(file.path))
|
|
3247
|
-
return;
|
|
3248
|
-
// Skip test/eval fixtures — those tool definitions are fixtures, not a
|
|
3249
|
-
// customer's runtime tools. (Framework library internals are already
|
|
3250
|
-
// excluded by applyHeuristics's application-code predicate.)
|
|
3251
|
-
if (isEvalLikePath(file.path))
|
|
3252
|
-
return;
|
|
3253
|
-
const tools = detectToolDefinitions(file);
|
|
3254
|
-
if (tools.length === 0)
|
|
3255
|
-
return;
|
|
3256
|
-
for (const tool of tools) {
|
|
3257
|
-
const body = extractToolBody(file, tool);
|
|
3258
|
-
upsertArtifact(graph, {
|
|
3259
|
-
id: stableId('tool', `${file.path}:${tool.name}`),
|
|
3260
|
-
type: 'tool',
|
|
3261
|
-
name: titleFromId(tool.name),
|
|
3262
|
-
scope: 'agent',
|
|
3263
|
-
sourcePath: file.path,
|
|
3264
|
-
body,
|
|
3265
|
-
usedByAgentIds: [],
|
|
3266
|
-
confidence: 0.6,
|
|
3267
|
-
evidence: [{ path: file.path, reason: `Tool definition detected (${tool.reason}).` }],
|
|
3268
|
-
});
|
|
3269
|
-
}
|
|
3270
|
-
}
|
|
3271
|
-
function detectDeveloperAgentAdapters(files) {
|
|
3272
|
-
const adapters = [];
|
|
3273
|
-
if (files.some((file) => file.path.startsWith('.claude/'))) {
|
|
3274
|
-
adapters.push({
|
|
3275
|
-
id: 'developer_adapter_claude_code',
|
|
3276
|
-
name: 'Claude Code',
|
|
3277
|
-
kind: 'developer_agent_adapter',
|
|
3278
|
-
sourcePath: '.claude',
|
|
3279
|
-
confidence: 0.9,
|
|
3280
|
-
evidence: [{ path: '.claude', reason: 'Claude Code project markers detected.' }],
|
|
3281
|
-
});
|
|
3282
|
-
}
|
|
3283
|
-
if (files.some((file) => file.path.startsWith('.cursor/'))) {
|
|
3284
|
-
adapters.push({
|
|
3285
|
-
id: 'developer_adapter_cursor',
|
|
3286
|
-
name: 'Cursor',
|
|
3287
|
-
kind: 'developer_agent_adapter',
|
|
3288
|
-
sourcePath: '.cursor',
|
|
3289
|
-
confidence: 0.9,
|
|
3290
|
-
evidence: [{ path: '.cursor', reason: 'Cursor project markers detected.' }],
|
|
3291
|
-
});
|
|
3292
|
-
}
|
|
3293
|
-
if (files.some((file) => file.path.startsWith('.codex/'))) {
|
|
3294
|
-
adapters.push({
|
|
3295
|
-
id: 'developer_adapter_codex',
|
|
3296
|
-
name: 'Codex',
|
|
3297
|
-
kind: 'developer_agent_adapter',
|
|
3298
|
-
sourcePath: '.codex',
|
|
3299
|
-
confidence: 0.9,
|
|
3300
|
-
evidence: [{ path: '.codex', reason: 'Codex project markers detected.' }],
|
|
3301
|
-
});
|
|
3302
|
-
}
|
|
3303
|
-
return adapters;
|
|
3304
|
-
}
|
|
3305
|
-
function detectEnvironments(files) {
|
|
3306
|
-
const envs = [];
|
|
3307
|
-
for (const id of ['local', 'staging', 'prod']) {
|
|
3308
|
-
const pathMatch = files.find((file) => file.path.includes(`.${id}.`) ||
|
|
3309
|
-
file.path.includes(`-${id}.`) ||
|
|
3310
|
-
file.path.includes(`/${id}/`));
|
|
3311
|
-
if (pathMatch || id === 'local') {
|
|
3312
|
-
envs.push({
|
|
3313
|
-
id: `env_${id}`,
|
|
3314
|
-
name: titleFromId(id),
|
|
3315
|
-
kind: id,
|
|
3316
|
-
sourcePath: pathMatch?.path,
|
|
3317
|
-
confidence: pathMatch ? 0.65 : 0.45,
|
|
3318
|
-
evidence: [{
|
|
3319
|
-
path: pathMatch?.path ?? '.',
|
|
3320
|
-
reason: pathMatch ? `Environment marker for ${id} detected.` : 'Default local environment assumed.',
|
|
3321
|
-
}],
|
|
3322
|
-
});
|
|
3323
|
-
}
|
|
3324
|
-
}
|
|
3325
|
-
return envs;
|
|
3326
|
-
}
|
|
3327
|
-
const FRAMEWORK_CATALOG = [
|
|
3328
|
-
{
|
|
3329
|
-
id: 'framework_langchain',
|
|
3330
|
-
name: 'LangChain',
|
|
3331
|
-
kind: 'agent_framework',
|
|
3332
|
-
packageNames: ['langchain', '@langchain/core', '@langchain/langgraph', 'langgraph'],
|
|
3333
|
-
patterns: [/\blangchain\b/i, /\blanggraph\b/i],
|
|
3334
|
-
},
|
|
3335
|
-
{
|
|
3336
|
-
id: 'framework_vercel_ai_sdk',
|
|
3337
|
-
name: 'Vercel AI SDK',
|
|
3338
|
-
kind: 'agent_framework',
|
|
3339
|
-
packageNames: ['ai', '@ai-sdk/openai', '@ai-sdk/anthropic'],
|
|
3340
|
-
patterns: [/\bfrom\s+['"]ai['"]/, /\bgenerateText\s*\(/, /\bstreamText\s*\(/],
|
|
3341
|
-
},
|
|
3342
|
-
{
|
|
3343
|
-
id: 'framework_mastra',
|
|
3344
|
-
name: 'Mastra',
|
|
3345
|
-
kind: 'agent_framework',
|
|
3346
|
-
packageNames: ['mastra', '@mastra/core'],
|
|
3347
|
-
patterns: [/\bmastra\b/i],
|
|
3348
|
-
},
|
|
3349
|
-
{
|
|
3350
|
-
id: 'framework_openai_agents',
|
|
3351
|
-
name: 'OpenAI Agents SDK',
|
|
3352
|
-
kind: 'agent_framework',
|
|
3353
|
-
packageNames: ['@openai/agents', 'openai-agents', 'openai-agents-python'],
|
|
3354
|
-
patterns: [/openai_agents/i, /@openai\/agents/i, /\bAgent\s*\(/],
|
|
3355
|
-
},
|
|
3356
|
-
{
|
|
3357
|
-
id: 'framework_crewai',
|
|
3358
|
-
name: 'CrewAI',
|
|
3359
|
-
kind: 'agent_framework',
|
|
3360
|
-
packageNames: ['crewai'],
|
|
3361
|
-
patterns: [/\bcrewai\b/i],
|
|
3362
|
-
},
|
|
3363
|
-
{
|
|
3364
|
-
id: 'framework_pydantic_ai',
|
|
3365
|
-
name: 'Pydantic AI',
|
|
3366
|
-
kind: 'agent_framework',
|
|
3367
|
-
packageNames: ['pydantic-ai', 'pydantic_ai'],
|
|
3368
|
-
patterns: [/pydantic_ai/i],
|
|
3369
|
-
},
|
|
3370
|
-
];
|
|
3371
|
-
const PROVIDER_CATALOG = [
|
|
3372
|
-
{
|
|
3373
|
-
id: 'provider_openai',
|
|
3374
|
-
name: 'OpenAI',
|
|
3375
|
-
kind: 'model_provider',
|
|
3376
|
-
packageNames: ['openai', '@ai-sdk/openai'],
|
|
3377
|
-
envVars: ['OPENAI_API_KEY'],
|
|
3378
|
-
patterns: [/from\s+['"]openai['"]/, /\bnew\s+OpenAI\b/, /\bgpt-[\w.-]+/i, /OPENAI_API_KEY/],
|
|
3379
|
-
},
|
|
3380
|
-
{
|
|
3381
|
-
id: 'provider_anthropic',
|
|
3382
|
-
name: 'Anthropic',
|
|
3383
|
-
kind: 'model_provider',
|
|
3384
|
-
packageNames: ['@anthropic-ai/sdk', '@ai-sdk/anthropic', 'anthropic'],
|
|
3385
|
-
envVars: ['ANTHROPIC_API_KEY'],
|
|
3386
|
-
patterns: [/\banthropic\b/i, /\bclaude-[\w.-]+/i, /ANTHROPIC_API_KEY/],
|
|
3387
|
-
},
|
|
3388
|
-
{
|
|
3389
|
-
id: 'provider_google',
|
|
3390
|
-
name: 'Google Gemini',
|
|
3391
|
-
kind: 'model_provider',
|
|
3392
|
-
packageNames: ['@google/generative-ai', '@ai-sdk/google', 'google-generativeai'],
|
|
3393
|
-
envVars: ['GOOGLE_GENERATIVE_AI_API_KEY', 'GEMINI_API_KEY'],
|
|
3394
|
-
patterns: [/\bgemini\b/i, /google\.generativeai/i],
|
|
3395
|
-
},
|
|
3396
|
-
{
|
|
3397
|
-
id: 'provider_cohere',
|
|
3398
|
-
name: 'Cohere',
|
|
3399
|
-
kind: 'model_provider',
|
|
3400
|
-
packageNames: ['cohere-ai', 'cohere'],
|
|
3401
|
-
envVars: ['COHERE_API_KEY'],
|
|
3402
|
-
patterns: [/\bcohere\b/i],
|
|
3403
|
-
},
|
|
3404
|
-
{
|
|
3405
|
-
id: 'provider_mistral',
|
|
3406
|
-
name: 'Mistral',
|
|
3407
|
-
kind: 'model_provider',
|
|
3408
|
-
packageNames: ['@mistralai/mistralai', 'mistralai'],
|
|
3409
|
-
envVars: ['MISTRAL_API_KEY'],
|
|
3410
|
-
patterns: [/\bmistral\b/i],
|
|
3411
|
-
},
|
|
3412
|
-
{
|
|
3413
|
-
id: 'provider_ollama',
|
|
3414
|
-
name: 'Ollama',
|
|
3415
|
-
kind: 'model_provider',
|
|
3416
|
-
packageNames: ['ollama'],
|
|
3417
|
-
envVars: ['OLLAMA_HOST'],
|
|
3418
|
-
patterns: [/\bollama\b/i],
|
|
3419
|
-
},
|
|
3420
|
-
];
|
|
3421
|
-
function detectAgentFrameworks(files) {
|
|
3422
|
-
const frameworks = [];
|
|
3423
|
-
for (const candidate of FRAMEWORK_CATALOG) {
|
|
3424
|
-
const evidence = detectionEvidence(files, candidate.packageNames, candidate.patterns);
|
|
3425
|
-
if (evidence.length === 0)
|
|
3426
|
-
continue;
|
|
3427
|
-
frameworks.push({
|
|
3428
|
-
id: candidate.id,
|
|
3429
|
-
name: candidate.name,
|
|
3430
|
-
kind: candidate.kind,
|
|
3431
|
-
packageNames: [...candidate.packageNames],
|
|
3432
|
-
runtimeAgentIds: [],
|
|
3433
|
-
confidence: evidence.some((item) => item.path === 'package.json' || item.path === 'pyproject.toml') ? 0.82 : 0.66,
|
|
3434
|
-
evidence,
|
|
3435
|
-
});
|
|
3436
|
-
}
|
|
3437
|
-
return frameworks.sort((a, b) => a.id.localeCompare(b.id));
|
|
3438
|
-
}
|
|
3439
|
-
function detectModelProviders(files) {
|
|
3440
|
-
const providers = [];
|
|
3441
|
-
for (const candidate of PROVIDER_CATALOG) {
|
|
3442
|
-
const evidence = detectionEvidence(files, candidate.packageNames, candidate.patterns);
|
|
3443
|
-
if (evidence.length === 0)
|
|
3444
|
-
continue;
|
|
3445
|
-
const modelHints = modelHintsForProvider(candidate.id, files);
|
|
3446
|
-
providers.push({
|
|
3447
|
-
id: candidate.id,
|
|
3448
|
-
name: candidate.name,
|
|
3449
|
-
kind: candidate.kind,
|
|
3450
|
-
packageNames: [...candidate.packageNames],
|
|
3451
|
-
runtimeAgentIds: [],
|
|
3452
|
-
envVars: [...candidate.envVars],
|
|
3453
|
-
modelHints,
|
|
3454
|
-
confidence: evidence.some((item) => item.path === 'package.json' || item.path === 'pyproject.toml') ? 0.82 : 0.62,
|
|
3455
|
-
evidence,
|
|
3456
|
-
});
|
|
3457
|
-
}
|
|
3458
|
-
return providers.sort((a, b) => a.id.localeCompare(b.id));
|
|
3459
|
-
}
|
|
3460
|
-
function detectionEvidence(files, packageNames, patterns) {
|
|
3461
|
-
const evidence = [];
|
|
3462
|
-
for (const file of files) {
|
|
3463
|
-
if (file.path === 'package.json' && file.text) {
|
|
3464
|
-
const deps = packageDependencyNames(file.text);
|
|
3465
|
-
const matched = packageNames.filter((name) => deps.has(name));
|
|
3466
|
-
for (const name of matched) {
|
|
3467
|
-
evidence.push({ path: file.path, reason: `Dependency ${name} detected.` });
|
|
3468
|
-
}
|
|
3469
|
-
}
|
|
3470
|
-
if (file.path === 'pyproject.toml' && file.text) {
|
|
3471
|
-
const deps = pyprojectDependencyNames(file.text);
|
|
3472
|
-
for (const name of packageNames) {
|
|
3473
|
-
if (deps.has(name.toLowerCase())) {
|
|
3474
|
-
evidence.push({ path: file.path, reason: `Python dependency ${name} detected.` });
|
|
3475
|
-
}
|
|
3476
|
-
}
|
|
3477
|
-
}
|
|
3478
|
-
if (!file.text || !/\.(ts|tsx|js|mjs|py|json|toml)$/i.test(file.path))
|
|
3479
|
-
continue;
|
|
3480
|
-
for (const pattern of patterns) {
|
|
3481
|
-
if (pattern.test(file.text)) {
|
|
3482
|
-
evidence.push({ path: file.path, reason: `Source references ${pattern.source}.` });
|
|
3483
|
-
break;
|
|
3484
|
-
}
|
|
3485
|
-
}
|
|
3486
|
-
}
|
|
3487
|
-
return mergeEvidence([], evidence).slice(0, 12);
|
|
3488
|
-
}
|
|
3489
|
-
function packageDependencyNames(text) {
|
|
3490
|
-
try {
|
|
3491
|
-
const parsed = JSON.parse(text);
|
|
3492
|
-
return new Set([
|
|
3493
|
-
...Object.keys(parsed.dependencies ?? {}),
|
|
3494
|
-
...Object.keys(parsed.devDependencies ?? {}),
|
|
3495
|
-
...Object.keys(parsed.optionalDependencies ?? {}),
|
|
3496
|
-
...Object.keys(parsed.peerDependencies ?? {}),
|
|
3497
|
-
]);
|
|
3498
|
-
}
|
|
3499
|
-
catch {
|
|
3500
|
-
return new Set();
|
|
3501
|
-
}
|
|
3502
|
-
}
|
|
3503
|
-
function pyprojectDependencyNames(text) {
|
|
3504
|
-
// Only extract names from actual dependency contexts, never every quoted string
|
|
3505
|
-
// in the file: that would re-open the "ai"-inside-`keywords = ["ai"]` /
|
|
3506
|
-
// `name = "openai-agents"` false positives this parser exists to avoid. Two
|
|
3507
|
-
// dependency shapes are covered, and membership is tested by exact name:
|
|
3508
|
-
// - PEP 621 / PEP 518 requirement arrays (`dependencies`/`requires = [...]`,
|
|
3509
|
-
// `[project.optional-dependencies]`, `[dependency-groups]`): leading
|
|
3510
|
-
// distribution token of each quoted PEP 508 string (e.g. "openai>=2" -> openai).
|
|
3511
|
-
// - Poetry tables (`[tool.poetry.dependencies]`, `dev-dependencies`,
|
|
3512
|
-
// `group.<name>.dependencies`): the bare TOML key (e.g. `openai = "^1.0"`).
|
|
3513
|
-
const names = new Set();
|
|
3514
|
-
const addLeadingToken = (spec) => {
|
|
3515
|
-
const match = spec.match(/^\s*([A-Za-z0-9][A-Za-z0-9._-]*)/);
|
|
3516
|
-
if (match)
|
|
3517
|
-
names.add(match[1].toLowerCase());
|
|
3518
|
-
};
|
|
3519
|
-
const isPoetryDepTable = (section) => /^tool\.poetry\.(dependencies|dev-dependencies|group\.[^.]+\.dependencies)$/.test(section);
|
|
3520
|
-
const isRequirementArrayTable = (section) => section === 'project.optional-dependencies' || section === 'dependency-groups';
|
|
3521
|
-
let section = '';
|
|
3522
|
-
let inDependencyArray = false;
|
|
3523
|
-
for (const raw of text.split(/\r?\n/)) {
|
|
3524
|
-
const line = raw.split('#')[0];
|
|
3525
|
-
const header = line.match(/^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*$/);
|
|
3526
|
-
if (header) {
|
|
3527
|
-
section = header[1].trim().toLowerCase();
|
|
3528
|
-
inDependencyArray = false;
|
|
3529
|
-
continue;
|
|
3530
|
-
}
|
|
3531
|
-
if (/^\s*(?:dependencies|requires)\s*=\s*\[/.test(line) ||
|
|
3532
|
-
(isRequirementArrayTable(section) && /=\s*\[/.test(line))) {
|
|
3533
|
-
inDependencyArray = true;
|
|
3534
|
-
}
|
|
3535
|
-
if (inDependencyArray) {
|
|
3536
|
-
for (const match of line.matchAll(/["']([^"']+)["']/g))
|
|
3537
|
-
addLeadingToken(match[1]);
|
|
3538
|
-
if (line.includes(']'))
|
|
3539
|
-
inDependencyArray = false;
|
|
3540
|
-
continue;
|
|
3541
|
-
}
|
|
3542
|
-
if (isPoetryDepTable(section)) {
|
|
3543
|
-
const key = line.match(/^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*=/);
|
|
3544
|
-
if (key && key[1].toLowerCase() !== 'python')
|
|
3545
|
-
names.add(key[1].toLowerCase());
|
|
3546
|
-
}
|
|
3547
|
-
}
|
|
3548
|
-
return names;
|
|
3549
|
-
}
|
|
3550
|
-
function modelHintsForProvider(providerId, files) {
|
|
3551
|
-
const hints = new Set();
|
|
3552
|
-
const pattern = providerId === 'provider_anthropic'
|
|
3553
|
-
? /\bclaude-[A-Za-z0-9_.-]+/g
|
|
3554
|
-
: providerId === 'provider_openai'
|
|
3555
|
-
? /\bgpt-[A-Za-z0-9_.-]+/g
|
|
3556
|
-
: providerId === 'provider_google'
|
|
3557
|
-
? /\bgemini-[A-Za-z0-9_.-]+/g
|
|
3558
|
-
: null;
|
|
3559
|
-
if (!pattern)
|
|
3560
|
-
return [];
|
|
3561
|
-
for (const file of files) {
|
|
3562
|
-
if (!file.text)
|
|
3563
|
-
continue;
|
|
3564
|
-
for (const match of file.text.matchAll(pattern)) {
|
|
3565
|
-
hints.add(match[0]);
|
|
3566
|
-
}
|
|
3567
|
-
}
|
|
3568
|
-
return [...hints].sort();
|
|
3569
|
-
}
|
|
3570
|
-
function frameworkIdsForFile(file) {
|
|
3571
|
-
return FRAMEWORK_CATALOG
|
|
3572
|
-
.filter((candidate) => file.text && candidate.patterns.some((pattern) => pattern.test(file.text ?? '')))
|
|
3573
|
-
.map((candidate) => candidate.id);
|
|
3574
|
-
}
|
|
3575
|
-
function providerIdsForFile(file) {
|
|
3576
|
-
return PROVIDER_CATALOG
|
|
3577
|
-
.filter((candidate) => file.text && candidate.patterns.some((pattern) => pattern.test(file.text ?? '')))
|
|
3578
|
-
.map((candidate) => candidate.id);
|
|
3579
|
-
}
|
|
3580
|
-
function linkFrameworksAndProviders(graph) {
|
|
3581
|
-
for (const agent of graph.runtimeAgents) {
|
|
3582
|
-
const agentPaths = new Set([
|
|
3583
|
-
agent.root,
|
|
3584
|
-
...agent.evidence.map((item) => item.path),
|
|
3585
|
-
...graph.artifacts
|
|
3586
|
-
.filter((artifact) => agent.entrypointIds.includes(artifact.id) && artifact.sourcePath)
|
|
3587
|
-
.map((artifact) => artifact.sourcePath),
|
|
3588
|
-
]);
|
|
3589
|
-
for (const framework of graph.agentFrameworks) {
|
|
3590
|
-
if (detectionAppliesToAgent(framework.evidence, agent, agentPaths)) {
|
|
3591
|
-
agent.frameworkIds = unique([...agent.frameworkIds, framework.id]).sort();
|
|
3592
|
-
framework.runtimeAgentIds = unique([...framework.runtimeAgentIds, agent.id]).sort();
|
|
3593
|
-
}
|
|
3594
|
-
}
|
|
3595
|
-
for (const provider of graph.modelProviders) {
|
|
3596
|
-
if (detectionAppliesToAgent(provider.evidence, agent, agentPaths)) {
|
|
3597
|
-
agent.modelProviderIds = unique([...agent.modelProviderIds, provider.id]).sort();
|
|
3598
|
-
provider.runtimeAgentIds = unique([...provider.runtimeAgentIds, agent.id]).sort();
|
|
3599
|
-
}
|
|
3600
|
-
}
|
|
3601
|
-
}
|
|
3602
|
-
}
|
|
3603
|
-
function detectionAppliesToAgent(evidence, agent, agentPaths) {
|
|
3604
|
-
return evidence.some((item) => item.path === 'package.json' ||
|
|
3605
|
-
item.path === 'pyproject.toml' ||
|
|
3606
|
-
agentPaths.has(item.path) ||
|
|
3607
|
-
item.path.startsWith(`${agent.root}/`));
|
|
3608
|
-
}
|
|
3609
|
-
function finalizeGraph(graph) {
|
|
3610
|
-
const artifactMap = new Map(graph.artifacts.map((artifact) => [artifact.id, artifact]));
|
|
3611
|
-
const channelMap = new Map(graph.channelAdapters.map((channel) => [channel.id, channel]));
|
|
3612
|
-
const identityMap = new Map(graph.identityNamespaces.map((identity) => [identity.id, identity]));
|
|
3613
|
-
const deploymentMap = new Map(graph.deployments.map((deployment) => [deployment.id, deployment]));
|
|
3614
|
-
for (const agent of graph.runtimeAgents) {
|
|
3615
|
-
for (const entrypointId of agent.entrypointIds) {
|
|
3616
|
-
if (!artifactMap.has(entrypointId)) {
|
|
3617
|
-
const sourcePath = agent.evidence[0]?.path ?? agent.root;
|
|
3618
|
-
const entrypoint = {
|
|
3619
|
-
id: entrypointId,
|
|
3620
|
-
type: 'entrypoint',
|
|
3621
|
-
name: `${agent.name} Entrypoint`,
|
|
3622
|
-
scope: 'agent',
|
|
3623
|
-
sourcePath,
|
|
3624
|
-
ownedByAgentId: agent.id,
|
|
3625
|
-
usedByAgentIds: [agent.id],
|
|
3626
|
-
confidence: Math.min(0.82, agent.confidence),
|
|
3627
|
-
evidence: agent.evidence,
|
|
3628
|
-
};
|
|
3629
|
-
upsertArtifact(graph, entrypoint);
|
|
3630
|
-
artifactMap.set(entrypointId, entrypoint);
|
|
3631
|
-
}
|
|
3632
|
-
graph.relationships.push({
|
|
3633
|
-
type: 'owns_artifact',
|
|
3634
|
-
from: agent.id,
|
|
3635
|
-
to: entrypointId,
|
|
3636
|
-
artifactId: entrypointId,
|
|
3637
|
-
evidence: agent.evidence,
|
|
3638
|
-
});
|
|
3639
|
-
}
|
|
3640
|
-
for (const channelId of agent.channelAdapterIds) {
|
|
3641
|
-
if (!channelMap.has(channelId)) {
|
|
3642
|
-
upsertChannel(graph, {
|
|
3643
|
-
id: channelId,
|
|
3644
|
-
name: titleFromId(channelId),
|
|
3645
|
-
kind: channelId.replace(/^channel_/, ''),
|
|
3646
|
-
confidence: 0.55,
|
|
3647
|
-
evidence: agent.evidence,
|
|
3648
|
-
});
|
|
3649
|
-
}
|
|
3650
|
-
graph.relationships.push({
|
|
3651
|
-
type: 'invoked_by_channel',
|
|
3652
|
-
from: agent.id,
|
|
3653
|
-
to: channelId,
|
|
3654
|
-
evidence: agent.evidence,
|
|
3655
|
-
});
|
|
3656
|
-
}
|
|
3657
|
-
for (const artifactId of agent.artifactIds) {
|
|
3658
|
-
if (!artifactMap.has(artifactId)) {
|
|
3659
|
-
const artifact = {
|
|
3660
|
-
id: artifactId,
|
|
3661
|
-
type: artifactTypeFromId(artifactId),
|
|
3662
|
-
name: titleFromId(artifactId),
|
|
3663
|
-
scope: 'agent',
|
|
3664
|
-
sourcePath: agent.evidence[0]?.path,
|
|
3665
|
-
usedByAgentIds: [agent.id],
|
|
3666
|
-
confidence: 0.45,
|
|
3667
|
-
evidence: agent.evidence,
|
|
3668
|
-
};
|
|
3669
|
-
upsertArtifact(graph, artifact);
|
|
3670
|
-
artifactMap.set(artifactId, artifact);
|
|
3671
|
-
}
|
|
3672
|
-
const artifact = artifactMap.get(artifactId);
|
|
3673
|
-
if (artifact && !artifact.usedByAgentIds.includes(agent.id)) {
|
|
3674
|
-
artifact.usedByAgentIds.push(agent.id);
|
|
3675
|
-
}
|
|
3676
|
-
graph.relationships.push({
|
|
3677
|
-
type: 'uses_artifact',
|
|
3678
|
-
from: agent.id,
|
|
3679
|
-
to: artifactId,
|
|
3680
|
-
artifactId,
|
|
3681
|
-
evidence: artifact?.evidence ?? agent.evidence,
|
|
3682
|
-
});
|
|
3683
|
-
}
|
|
3684
|
-
for (const identityId of agent.identityNamespaceIds) {
|
|
3685
|
-
if (!identityMap.has(identityId)) {
|
|
3686
|
-
upsertIdentity(graph, {
|
|
3687
|
-
id: identityId,
|
|
3688
|
-
name: titleFromId(identityId),
|
|
3689
|
-
kind: identityId.replace(/^identity_/, ''),
|
|
3690
|
-
confidence: 0.5,
|
|
3691
|
-
evidence: agent.evidence,
|
|
3692
|
-
});
|
|
3693
|
-
}
|
|
3694
|
-
}
|
|
3695
|
-
for (const deploymentId of agent.deploymentIds) {
|
|
3696
|
-
if (!deploymentMap.has(deploymentId)) {
|
|
3697
|
-
upsertDeployment(graph, {
|
|
3698
|
-
id: deploymentId,
|
|
3699
|
-
name: titleFromId(deploymentId),
|
|
3700
|
-
confidence: 0.5,
|
|
3701
|
-
evidence: agent.evidence,
|
|
3702
|
-
});
|
|
3703
|
-
}
|
|
3704
|
-
graph.relationships.push({
|
|
3705
|
-
type: 'deployed_as',
|
|
3706
|
-
from: agent.id,
|
|
3707
|
-
to: deploymentId,
|
|
3708
|
-
evidence: agent.evidence,
|
|
3709
|
-
});
|
|
3710
|
-
}
|
|
3711
|
-
}
|
|
3712
|
-
const familyMap = new Map(graph.agentFamilies.map((family) => [family.id, family]));
|
|
3713
|
-
for (const agent of graph.runtimeAgents) {
|
|
3714
|
-
if (!agent.familyId)
|
|
3715
|
-
continue;
|
|
3716
|
-
const family = familyMap.get(agent.familyId);
|
|
3717
|
-
if (family && !family.runtimeAgentIds.includes(agent.id)) {
|
|
3718
|
-
family.runtimeAgentIds.push(agent.id);
|
|
3719
|
-
}
|
|
3720
|
-
}
|
|
3721
|
-
for (const family of graph.agentFamilies) {
|
|
3722
|
-
const shared = graph.artifacts
|
|
3723
|
-
.filter((artifact) => artifact.usedByAgentIds.filter((agentId) => family.runtimeAgentIds.includes(agentId)).length > 1)
|
|
3724
|
-
.map((artifact) => artifact.id);
|
|
3725
|
-
family.sharedArtifactIds = unique([...family.sharedArtifactIds, ...shared]);
|
|
3726
|
-
}
|
|
3727
|
-
for (const artifact of graph.artifacts) {
|
|
3728
|
-
artifact.usedByAgentIds = unique(artifact.usedByAgentIds).sort();
|
|
3729
|
-
if (artifact.usedByAgentIds.length > 1) {
|
|
3730
|
-
artifact.scope = 'shared';
|
|
3731
|
-
for (const from of artifact.usedByAgentIds) {
|
|
3732
|
-
for (const to of artifact.usedByAgentIds) {
|
|
3733
|
-
if (from === to)
|
|
3734
|
-
continue;
|
|
3735
|
-
graph.relationships.push({
|
|
3736
|
-
type: 'shares_artifact_with',
|
|
3737
|
-
from,
|
|
3738
|
-
to,
|
|
3739
|
-
artifactId: artifact.id,
|
|
3740
|
-
evidence: artifact.evidence,
|
|
3741
|
-
});
|
|
3742
|
-
}
|
|
3743
|
-
}
|
|
3744
|
-
}
|
|
3745
|
-
}
|
|
3746
|
-
for (const identity of graph.identityNamespaces) {
|
|
3747
|
-
if (identity.canonicalId && identity.canonicalId !== identity.id) {
|
|
3748
|
-
graph.relationships.push({
|
|
3749
|
-
type: 'resolves_identity_to',
|
|
3750
|
-
from: identity.id,
|
|
3751
|
-
to: identity.canonicalId,
|
|
3752
|
-
evidence: identity.evidence,
|
|
3753
|
-
});
|
|
3754
|
-
}
|
|
3755
|
-
}
|
|
3756
|
-
if (graph.runtimeAgents.length > 1 && graph.agentFamilies.length === 0) {
|
|
3757
|
-
graph.unknowns.push({
|
|
3758
|
-
id: 'unknown_agent_grouping',
|
|
3759
|
-
question: 'Multiple runtime agents were detected. Confirm whether any should be grouped into an agent family.',
|
|
3760
|
-
candidateIds: graph.runtimeAgents.map((agent) => agent.id),
|
|
3761
|
-
evidence: graph.runtimeAgents.flatMap((agent) => agent.evidence).slice(0, 5),
|
|
3762
|
-
});
|
|
3763
|
-
}
|
|
3764
|
-
graph.agentFamilies.sort((a, b) => a.id.localeCompare(b.id));
|
|
3765
|
-
graph.runtimeAgents.sort((a, b) => a.id.localeCompare(b.id));
|
|
3766
|
-
graph.channelAdapters.sort((a, b) => a.id.localeCompare(b.id));
|
|
3767
|
-
graph.artifacts.sort((a, b) => a.id.localeCompare(b.id));
|
|
3768
|
-
graph.identityNamespaces.sort((a, b) => a.id.localeCompare(b.id));
|
|
3769
|
-
graph.agentFrameworks.sort((a, b) => a.id.localeCompare(b.id));
|
|
3770
|
-
graph.modelProviders.sort((a, b) => a.id.localeCompare(b.id));
|
|
3771
|
-
graph.deployments.sort((a, b) => a.id.localeCompare(b.id));
|
|
3772
|
-
graph.relationships = dedupeRelationships(graph.relationships);
|
|
3773
|
-
}
|
|
3774
|
-
function upsertRuntimeAgent(graph, next) {
|
|
3775
|
-
const existing = graph.runtimeAgents.find((agent) => agent.id === next.id);
|
|
3776
|
-
if (!existing) {
|
|
3777
|
-
graph.runtimeAgents.push(next);
|
|
3778
|
-
return;
|
|
3779
|
-
}
|
|
3780
|
-
existing.evidence = mergeEvidence(existing.evidence, next.evidence);
|
|
3781
|
-
existing.confidence = Math.max(existing.confidence, next.confidence);
|
|
3782
|
-
existing.entrypointIds = unique([...existing.entrypointIds, ...next.entrypointIds]);
|
|
3783
|
-
existing.channelAdapterIds = unique([...existing.channelAdapterIds, ...next.channelAdapterIds]);
|
|
3784
|
-
existing.artifactIds = unique([...existing.artifactIds, ...next.artifactIds]);
|
|
3785
|
-
existing.identityNamespaceIds = unique([...existing.identityNamespaceIds, ...next.identityNamespaceIds]);
|
|
3786
|
-
existing.deploymentIds = unique([...existing.deploymentIds, ...next.deploymentIds]);
|
|
3787
|
-
existing.frameworkIds = unique([...existing.frameworkIds, ...next.frameworkIds]);
|
|
3788
|
-
existing.modelProviderIds = unique([...existing.modelProviderIds, ...next.modelProviderIds]);
|
|
3789
|
-
existing.familyId = existing.familyId ?? next.familyId;
|
|
3790
|
-
existing.purpose = existing.purpose ?? next.purpose;
|
|
3791
|
-
}
|
|
3792
|
-
function upsertAgentFamily(graph, next) {
|
|
3793
|
-
const existing = graph.agentFamilies.find((family) => family.id === next.id);
|
|
3794
|
-
if (!existing) {
|
|
3795
|
-
graph.agentFamilies.push(next);
|
|
3796
|
-
return;
|
|
3797
|
-
}
|
|
3798
|
-
existing.evidence = mergeEvidence(existing.evidence, next.evidence);
|
|
3799
|
-
existing.confidence = Math.max(existing.confidence, next.confidence);
|
|
3800
|
-
existing.runtimeAgentIds = unique([...existing.runtimeAgentIds, ...next.runtimeAgentIds]);
|
|
3801
|
-
existing.sharedArtifactIds = unique([...existing.sharedArtifactIds, ...next.sharedArtifactIds]);
|
|
3802
|
-
}
|
|
3803
|
-
function upsertChannel(graph, next) {
|
|
3804
|
-
const existing = graph.channelAdapters.find((channel) => channel.id === next.id);
|
|
3805
|
-
if (!existing) {
|
|
3806
|
-
graph.channelAdapters.push(next);
|
|
3807
|
-
return;
|
|
3808
|
-
}
|
|
3809
|
-
existing.evidence = mergeEvidence(existing.evidence, next.evidence);
|
|
3810
|
-
existing.confidence = Math.max(existing.confidence, next.confidence);
|
|
3811
|
-
}
|
|
3812
|
-
function upsertArtifact(graph, next) {
|
|
3813
|
-
const existing = graph.artifacts.find((artifact) => artifact.id === next.id);
|
|
3814
|
-
if (!existing) {
|
|
3815
|
-
graph.artifacts.push(next);
|
|
3816
|
-
return;
|
|
3817
|
-
}
|
|
3818
|
-
existing.evidence = mergeEvidence(existing.evidence, next.evidence);
|
|
3819
|
-
existing.confidence = Math.max(existing.confidence, next.confidence);
|
|
3820
|
-
existing.usedByAgentIds = unique([...existing.usedByAgentIds, ...next.usedByAgentIds]);
|
|
3821
|
-
existing.ownedByAgentId = existing.ownedByAgentId ?? next.ownedByAgentId;
|
|
3822
|
-
existing.contentHash = existing.contentHash ?? next.contentHash;
|
|
3823
|
-
existing.sourcePath = existing.sourcePath ?? next.sourcePath;
|
|
3824
|
-
if (!existing.body && next.body)
|
|
3825
|
-
existing.body = next.body;
|
|
3826
|
-
if (existing.scope !== 'shared')
|
|
3827
|
-
existing.scope = next.scope;
|
|
3828
|
-
}
|
|
3829
|
-
function upsertIdentity(graph, next) {
|
|
3830
|
-
const existing = graph.identityNamespaces.find((identity) => identity.id === next.id);
|
|
3831
|
-
if (!existing) {
|
|
3832
|
-
graph.identityNamespaces.push(next);
|
|
3833
|
-
return;
|
|
3834
|
-
}
|
|
3835
|
-
existing.evidence = mergeEvidence(existing.evidence, next.evidence);
|
|
3836
|
-
existing.confidence = Math.max(existing.confidence, next.confidence);
|
|
3837
|
-
existing.canonicalId = existing.canonicalId ?? next.canonicalId;
|
|
3838
|
-
}
|
|
3839
|
-
function upsertEnvironment(graph, next) {
|
|
3840
|
-
const existing = graph.environments.find((env) => env.id === next.id);
|
|
3841
|
-
if (!existing) {
|
|
3842
|
-
graph.environments.push(next);
|
|
3843
|
-
return;
|
|
3844
|
-
}
|
|
3845
|
-
existing.evidence = mergeEvidence(existing.evidence, next.evidence);
|
|
3846
|
-
existing.confidence = Math.max(existing.confidence, next.confidence);
|
|
3847
|
-
}
|
|
3848
|
-
function upsertDeployment(graph, next) {
|
|
3849
|
-
const existing = graph.deployments.find((deployment) => deployment.id === next.id);
|
|
3850
|
-
if (!existing) {
|
|
3851
|
-
graph.deployments.push(next);
|
|
3852
|
-
return;
|
|
3853
|
-
}
|
|
3854
|
-
existing.evidence = mergeEvidence(existing.evidence, next.evidence);
|
|
3855
|
-
existing.confidence = Math.max(existing.confidence, next.confidence);
|
|
3856
|
-
existing.environmentId = existing.environmentId ?? next.environmentId;
|
|
3857
|
-
}
|
|
3858
|
-
function contentHashForAnnotatedSource(sourcePath, annotation) {
|
|
3859
|
-
// Reuse the annotation-relative candidate resolver; prefer the canonicalized
|
|
3860
|
-
// repo-relative path first so hashing matches hydrated artifact bodies.
|
|
3861
|
-
const bodyAnnotation = annotation;
|
|
3862
|
-
const rootCandidate = resolve(annotation.rootDir, sourcePath);
|
|
3863
|
-
const candidates = [
|
|
3864
|
-
rootCandidate,
|
|
3865
|
-
...annotatedSourceCandidates(sourcePath, bodyAnnotation).filter((c) => c !== rootCandidate),
|
|
3866
|
-
];
|
|
3867
|
-
const seen = new Set();
|
|
3868
|
-
for (const candidate of candidates) {
|
|
3869
|
-
if (seen.has(candidate))
|
|
3870
|
-
continue;
|
|
3871
|
-
seen.add(candidate);
|
|
3872
|
-
const text = readSmallTextFileForHash(candidate, annotation.rootDir);
|
|
3873
|
-
if (text !== undefined)
|
|
3874
|
-
return hashText(text);
|
|
3875
|
-
}
|
|
3876
|
-
return undefined;
|
|
3877
|
-
}
|
|
3878
|
-
function readSmallTextFileForHash(absPath, rootDir) {
|
|
3879
|
-
if (!existsSync(absPath))
|
|
3880
|
-
return undefined;
|
|
3881
|
-
const rel = normalizePath(relative(rootDir, absPath));
|
|
3882
|
-
if (isSecretishPath(rel))
|
|
3883
|
-
return undefined;
|
|
3884
|
-
let stat;
|
|
3885
|
-
try {
|
|
3886
|
-
stat = lstatSync(absPath);
|
|
3887
|
-
}
|
|
3888
|
-
catch {
|
|
3889
|
-
return undefined;
|
|
3890
|
-
}
|
|
3891
|
-
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_SCANNED_FILE_BYTES) {
|
|
3892
|
-
return undefined;
|
|
3893
|
-
}
|
|
3894
|
-
if (!isTextFile(rel))
|
|
3895
|
-
return undefined;
|
|
3896
|
-
try {
|
|
3897
|
-
return readFileSync(absPath, 'utf8');
|
|
3898
|
-
}
|
|
3899
|
-
catch {
|
|
3900
|
-
return undefined;
|
|
3901
|
-
}
|
|
3902
|
-
}
|
|
3903
|
-
function dedupeRelationships(relationships) {
|
|
3904
|
-
const seen = new Set();
|
|
3905
|
-
const result = [];
|
|
3906
|
-
for (const relationship of relationships) {
|
|
3907
|
-
const key = [
|
|
3908
|
-
relationship.type,
|
|
3909
|
-
relationship.from,
|
|
3910
|
-
relationship.to,
|
|
3911
|
-
relationship.artifactId ?? '',
|
|
3912
|
-
].join('\0');
|
|
3913
|
-
if (seen.has(key))
|
|
3914
|
-
continue;
|
|
3915
|
-
seen.add(key);
|
|
3916
|
-
result.push(relationship);
|
|
3917
|
-
}
|
|
3918
|
-
return result.sort((a, b) => `${a.type}:${a.from}:${a.to}:${a.artifactId ?? ''}`.localeCompare(`${b.type}:${b.from}:${b.to}:${b.artifactId ?? ''}`));
|
|
3919
|
-
}
|
|
3920
|
-
function mergeEvidence(left, right) {
|
|
3921
|
-
const seen = new Set();
|
|
3922
|
-
const result = [];
|
|
3923
|
-
for (const item of [...left, ...right]) {
|
|
3924
|
-
const key = `${item.path}\0${item.reason}`;
|
|
3925
|
-
if (seen.has(key))
|
|
3926
|
-
continue;
|
|
3927
|
-
seen.add(key);
|
|
3928
|
-
result.push(item);
|
|
3929
|
-
}
|
|
3930
|
-
return result;
|
|
3931
|
-
}
|
|
3932
|
-
function artifactType(value) {
|
|
3933
|
-
if (value === 'entrypoint' ||
|
|
3934
|
-
value === 'prompt' ||
|
|
3935
|
-
value === 'tool' ||
|
|
3936
|
-
value === 'skill' ||
|
|
3937
|
-
value === 'eval' ||
|
|
3938
|
-
value === 'retrieval_index' ||
|
|
3939
|
-
value === 'memory' ||
|
|
3940
|
-
value === 'guardrail' ||
|
|
3941
|
-
value === 'model_config') {
|
|
3942
|
-
return value;
|
|
3943
|
-
}
|
|
3944
|
-
return 'unknown';
|
|
3945
|
-
}
|
|
3946
|
-
function artifactTypeFromId(id) {
|
|
3947
|
-
if (id.startsWith('prompt_'))
|
|
3948
|
-
return 'prompt';
|
|
3949
|
-
if (id.startsWith('tool_'))
|
|
3950
|
-
return 'tool';
|
|
3951
|
-
if (id.startsWith('skill_'))
|
|
3952
|
-
return 'skill';
|
|
3953
|
-
if (id.startsWith('eval_'))
|
|
3954
|
-
return 'eval';
|
|
3955
|
-
if (id.startsWith('entry_'))
|
|
3956
|
-
return 'entrypoint';
|
|
3957
|
-
if (id.startsWith('memory_'))
|
|
3958
|
-
return 'memory';
|
|
3959
|
-
if (id.startsWith('guardrail_'))
|
|
3960
|
-
return 'guardrail';
|
|
3961
|
-
if (id.startsWith('model_config_'))
|
|
3962
|
-
return 'model_config';
|
|
3963
|
-
return 'unknown';
|
|
3964
|
-
}
|
|
3965
|
-
function scopeValue(value) {
|
|
3966
|
-
if (value === 'shared' || value === 'global' || value === 'agent')
|
|
3967
|
-
return value;
|
|
3968
|
-
return 'agent';
|
|
3969
|
-
}
|
|
3970
|
-
function numberValue(value, fallback) {
|
|
3971
|
-
if (!value)
|
|
3972
|
-
return fallback;
|
|
3973
|
-
const parsed = Number(value);
|
|
3974
|
-
return Number.isFinite(parsed) ? Math.max(0, Math.min(1, parsed)) : fallback;
|
|
3975
|
-
}
|
|
3976
|
-
function first(values) {
|
|
3977
|
-
return values?.[0];
|
|
3978
|
-
}
|
|
3979
|
-
function unique(values) {
|
|
3980
|
-
return [...new Set(values.filter(Boolean))];
|
|
3981
|
-
}
|
|
3982
|
-
function stableId(prefix, raw) {
|
|
3983
|
-
const slug = raw
|
|
3984
|
-
.toLowerCase()
|
|
3985
|
-
.replace(/\.[^.]+$/g, '')
|
|
3986
|
-
.replace(/[^a-z0-9]+/g, '_')
|
|
3987
|
-
.replace(/^_+|_+$/g, '')
|
|
3988
|
-
.replace(/_+/g, '_')
|
|
3989
|
-
.slice(0, 72);
|
|
3990
|
-
return `${prefix}_${slug || hashText(raw).slice(0, 10)}`;
|
|
3991
|
-
}
|
|
3992
|
-
function titleFromId(id) {
|
|
3993
|
-
return id
|
|
3994
|
-
.replace(/^(agent|family|channel|prompt|tool|skill|eval|identity|deploy|entry|env)_/, '')
|
|
3995
|
-
.split(/[_/.-]+/)
|
|
3996
|
-
.filter(Boolean)
|
|
3997
|
-
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
3998
|
-
.join(' ') || id;
|
|
3999
|
-
}
|
|
4000
|
-
function promptNameFromPath(path) {
|
|
4001
|
-
return titleFromId(basename(path).replace(/\.(prompt\.)?(md|json|ya?ml)$/i, ''));
|
|
4002
|
-
}
|
|
4003
|
-
function normalizePath(path) {
|
|
4004
|
-
return path.split(sep).join('/');
|
|
4005
|
-
}
|
|
4006
|
-
function hashText(text) {
|
|
4007
|
-
return createHash('sha256').update(text).digest('hex');
|
|
4008
|
-
}
|
|
4009
|
-
//# sourceMappingURL=harness.js.map
|