@moda-ai/cli 1.19.0 → 1.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -141,6 +141,7 @@ Commands:
141
141
  harness validate-report Validate .moda/harness-report.json citations
142
142
  harness approve Approve a validated harness report for sync
143
143
  harness scan Discover local runtime agents and write .moda/harness.json
144
+ harness candidates Static (no-LLM) harness candidate pre-pass with file:line citations
144
145
  harness sync Sync the local harness graph to Moda Cloud
145
146
  harness agents List detected runtime agents and families
146
147
  harness explain Explain the discovered harness graph
@@ -293,6 +294,7 @@ Examples:
293
294
  moda sync --dry-run
294
295
  moda sync harness --profile=prod
295
296
  moda harness scan --json
297
+ moda harness candidates --out=.moda/harness-candidates.json
296
298
  moda harness explain --agent=agent_support
297
299
  moda init --ci-cd
298
300
  moda init --autosync
@@ -505,7 +507,7 @@ export async function runCommand(command, positional, flags, positionals = posit
505
507
  // reject an empty/whitespace query with the shared CLI input error before
506
508
  // it reaches the schema (which also guards via q.min(1)).
507
509
  if (!positional || positional.trim() === '') {
508
- throw new CliInputError('<query> is required', 'Usage: moda search "<query>" [--mode=keyword|semantic|hybrid] [--user-id=ID] [--time-range=RANGE] [--limit=N]');
510
+ throw new CliInputError('<query> is required', 'Usage: moda search "<query>" [--mode=keyword|semantic|hybrid] [--user-id=ID] [--time-range=RANGE] [--include-tool-io] [--limit=N]');
509
511
  }
510
512
  const args = flagsToArgs(flags, 'q', positional);
511
513
  const params = SearchSchema.parse(args);
@@ -519,6 +521,8 @@ export async function runCommand(command, positional, flags, positionals = posit
519
521
  query.set('time_range', params.time_range);
520
522
  if (params.limit)
521
523
  query.set('limit', params.limit.toString());
524
+ if (params.include_tool_io)
525
+ query.set('include_tool_io', 'true');
522
526
  const data = await callDataAPI(`/search?${query.toString()}`);
523
527
  recordSearchTelemetry(context, data);
524
528
  context.output.writeData(data);
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Static harness-candidate pre-pass (Phase 2 prototype).
3
+ *
4
+ * A deterministic, LLM-free detector that walks a repo and extracts
5
+ * *candidate* harness surfaces — LLM SDK call sites, agent-framework
6
+ * constructs, tool definitions/schemas, prompt files and prompt-like string
7
+ * literals, and model config assignments — each with a file:line citation,
8
+ * a confidence heuristic, and a short excerpt.
9
+ *
10
+ * Purpose: today `harness analyze` (local or hosted) has a single agent read
11
+ * everything, which caps out around ~450 files / ~20 minutes. This pre-pass is
12
+ * pure regex/heuristics (no LLM calls, no network), runs in milliseconds per
13
+ * thousand files, and produces a prioritized read plan so the LLM
14
+ * classification stage only needs to open the files that plausibly matter.
15
+ *
16
+ * Output contract: `harness_candidates.v0.1` — see {@link HarnessCandidateReport}.
17
+ * Candidates are recall-oriented: false positives are expected and cheap (the
18
+ * LLM stage filters them); false negatives are the expensive failure mode.
19
+ */
20
+ import { type FileInventoryItem, type HarnessScanMetadata } from './harness.js';
21
+ export type HarnessCandidateKind = 'llm_call_site' | 'framework_construct' | 'tool_definition' | 'tool_schema' | 'message_construction' | 'prompt_file' | 'prompt_literal' | 'model_config';
22
+ export type HarnessCandidateConfidence = 'high' | 'medium' | 'low';
23
+ export interface HarnessCandidate {
24
+ kind: HarnessCandidateKind;
25
+ /** Repo-relative, forward-slash path. */
26
+ path: string;
27
+ /** 1-based inclusive line range. */
28
+ startLine: number;
29
+ endLine: number;
30
+ confidence: HarnessCandidateConfidence;
31
+ /** Which detector fired, e.g. `openai.chat_completions_create`. */
32
+ signal: string;
33
+ /** Normalized single-line excerpt, capped at {@link EXCERPT_MAX_CHARS}. */
34
+ excerpt: string;
35
+ }
36
+ export interface HarnessCandidateFileSummary {
37
+ path: string;
38
+ /** Sum of per-candidate confidence weights; higher = read first. */
39
+ score: number;
40
+ kinds: HarnessCandidateKind[];
41
+ candidateCount: number;
42
+ }
43
+ export interface HarnessCandidateStats {
44
+ filesScanned: number;
45
+ filesWithCandidates: number;
46
+ candidateCount: number;
47
+ textBytesScanned: number;
48
+ durationMs: number;
49
+ filesPerSecond: number;
50
+ }
51
+ export interface HarnessCandidateReport {
52
+ schemaVersion: 'harness_candidates.v0.1';
53
+ generatedAt: string;
54
+ rootDir: string;
55
+ stats: HarnessCandidateStats;
56
+ candidates: HarnessCandidate[];
57
+ /** Per-file rollup sorted by descending score — the LLM-stage read plan. */
58
+ files: HarnessCandidateFileSummary[];
59
+ skipped: HarnessScanMetadata['skipped'];
60
+ }
61
+ /**
62
+ * Pure detector core: run every heuristic over an already-materialized file
63
+ * inventory. No filesystem access — unit tests and the walker wrapper both
64
+ * call this.
65
+ */
66
+ export declare function detectHarnessCandidatesInFiles(files: Array<Pick<FileInventoryItem, 'path' | 'text'>>): {
67
+ candidates: HarnessCandidate[];
68
+ files: HarnessCandidateFileSummary[];
69
+ };
70
+ /**
71
+ * Walk `rootDir` with the harness inventory walker (same skip/secret/symlink
72
+ * budget rules as `harness scan`, widened to include all prompt-doc files)
73
+ * and run the static detector over it.
74
+ */
75
+ export declare function detectHarnessCandidates(rootDir: string): HarnessCandidateReport;
76
+ export declare function writeHarnessCandidatesReport(report: HarnessCandidateReport, outPath: string): string;
77
+ //# sourceMappingURL=harness-candidates.d.ts.map
@@ -0,0 +1,482 @@
1
+ /**
2
+ * Static harness-candidate pre-pass (Phase 2 prototype).
3
+ *
4
+ * A deterministic, LLM-free detector that walks a repo and extracts
5
+ * *candidate* harness surfaces — LLM SDK call sites, agent-framework
6
+ * constructs, tool definitions/schemas, prompt files and prompt-like string
7
+ * literals, and model config assignments — each with a file:line citation,
8
+ * a confidence heuristic, and a short excerpt.
9
+ *
10
+ * Purpose: today `harness analyze` (local or hosted) has a single agent read
11
+ * everything, which caps out around ~450 files / ~20 minutes. This pre-pass is
12
+ * pure regex/heuristics (no LLM calls, no network), runs in milliseconds per
13
+ * thousand files, and produces a prioritized read plan so the LLM
14
+ * classification stage only needs to open the files that plausibly matter.
15
+ *
16
+ * Output contract: `harness_candidates.v0.1` — see {@link HarnessCandidateReport}.
17
+ * Candidates are recall-oriented: false positives are expected and cheap (the
18
+ * LLM stage filters them); false negatives are the expensive failure mode.
19
+ */
20
+ import { mkdirSync, writeFileSync } from 'node:fs';
21
+ import { basename, dirname, extname, resolve } from 'node:path';
22
+ import { inventoryFiles, } from './harness.js';
23
+ import { redactDeep } from './redact.js';
24
+ const EXCERPT_MAX_CHARS = 240;
25
+ const MAX_CANDIDATES_PER_FILE_PER_SIGNAL = 20;
26
+ const MIN_PROMPT_LITERAL_CHARS = 160;
27
+ const CONFIDENCE_WEIGHT = {
28
+ high: 3,
29
+ medium: 2,
30
+ low: 1,
31
+ };
32
+ const PY = ['.py'];
33
+ const JS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'];
34
+ /**
35
+ * Line-anchored detector table. Every regex here must be safe to run on every
36
+ * line of every text file (no catastrophic backtracking), and every entry maps
37
+ * to exactly one candidate signal so results stay explainable.
38
+ */
39
+ const LINE_PATTERNS = [
40
+ // ---- LLM SDK call sites -------------------------------------------------
41
+ {
42
+ kind: 'llm_call_site', signal: 'openai.import', confidence: 'high',
43
+ pattern: /(^\s*(import|from)\s+openai\b)|(\bfrom\s+['"]openai['"])|(\brequire\(\s*['"]openai['"]\s*\))/,
44
+ },
45
+ {
46
+ kind: 'llm_call_site', signal: 'openai.client_construct', confidence: 'high',
47
+ pattern: /\b(new\s+)?(Async)?(OpenAI|AzureOpenAI)\s*\(/,
48
+ },
49
+ {
50
+ kind: 'llm_call_site', signal: 'openai.chat_completions_create', confidence: 'high',
51
+ pattern: /\.\s*(chat\s*\.\s*)?completions\s*\.\s*(create|parse|stream)\s*\(|\.\s*responses\s*\.\s*(create|parse|stream)\s*\(/,
52
+ },
53
+ {
54
+ kind: 'llm_call_site', signal: 'anthropic.import', confidence: 'high',
55
+ pattern: /(^\s*(import|from)\s+anthropic\b)|(@anthropic-ai\/sdk)|(\bfrom\s+['"]@anthropic-ai\/)/,
56
+ },
57
+ {
58
+ kind: 'llm_call_site', signal: 'anthropic.client_or_messages', confidence: 'high',
59
+ pattern: /\b(new\s+)?(Async)?Anthropic(Bedrock|Vertex)?\s*\(|\.\s*messages\s*\.\s*(create|stream)\s*\(/,
60
+ },
61
+ {
62
+ kind: 'llm_call_site', signal: 'openrouter.reference', confidence: 'medium',
63
+ pattern: /openrouter\.ai|OPENROUTER_API_KEY|\bopen_?router\b/i,
64
+ },
65
+ {
66
+ kind: 'llm_call_site', signal: 'vercel_ai_sdk.generate', confidence: 'high',
67
+ pattern: /\b(generateText|streamText|generateObject|streamObject)\s*\(/,
68
+ extensions: JS,
69
+ },
70
+ {
71
+ kind: 'llm_call_site', signal: 'litellm.reference', confidence: 'high',
72
+ pattern: /\blitellm\b/,
73
+ extensions: PY,
74
+ },
75
+ {
76
+ kind: 'llm_call_site', signal: 'google_genai.reference', confidence: 'high',
77
+ pattern: /google\.generativeai|@google\/generative-ai|@google\/genai|\bGenerativeModel\s*\(/,
78
+ },
79
+ {
80
+ kind: 'llm_call_site', signal: 'bedrock.invoke_model', confidence: 'medium',
81
+ pattern: /\binvoke_model\s*\(|InvokeModelCommand|bedrock-runtime/,
82
+ },
83
+ // ---- Agent framework constructs ----------------------------------------
84
+ {
85
+ kind: 'framework_construct', signal: 'langchain.import', confidence: 'high',
86
+ pattern: /(^\s*(import|from)\s+langchain\w*\b)|(\bfrom\s+['"]@?langchain\b)|(@langchain\/)/,
87
+ },
88
+ {
89
+ kind: 'framework_construct', signal: 'langchain.construct', confidence: 'high',
90
+ pattern: /\b(ChatOpenAI|ChatAnthropic|ChatVertexAI|ChatBedrock|AgentExecutor|LLMChain|ChatPromptTemplate|PromptTemplate)\s*(\(|\.)/,
91
+ },
92
+ {
93
+ kind: 'framework_construct', signal: 'langgraph.import_or_graph', confidence: 'high',
94
+ pattern: /(^\s*(import|from)\s+langgraph\b)|(@langchain\/langgraph)|\bStateGraph\s*\(|\bcreate_react_agent\s*\(/,
95
+ },
96
+ {
97
+ kind: 'framework_construct', signal: 'crewai.import_or_crew', confidence: 'high',
98
+ pattern: /(^\s*(import|from)\s+crewai\b)|\bcrewai\b/,
99
+ },
100
+ {
101
+ kind: 'framework_construct', signal: 'openai_agents.import_or_runner', confidence: 'high',
102
+ pattern: /(^\s*from\s+agents\s+import\b)|\bopenai-agents\b|\bRunner\s*\.\s*run(_sync|_streamed)?\s*\(/,
103
+ },
104
+ {
105
+ kind: 'framework_construct', signal: 'claude_agent_sdk.import', confidence: 'high',
106
+ pattern: /@anthropic-ai\/claude-agent-sdk|\bclaude_agent_sdk\b|\bclaude-agent-sdk\b/,
107
+ },
108
+ {
109
+ kind: 'framework_construct', signal: 'other_framework.import', confidence: 'medium',
110
+ pattern: /(^\s*(import|from)\s+(autogen|pydantic_ai|dspy|semantic_kernel|smolagents|llama_index)\b)|\bfrom\s+['"]llamaindex['"]/,
111
+ },
112
+ // ---- Tool definitions ---------------------------------------------------
113
+ {
114
+ kind: 'tool_definition', signal: 'python.tool_decorator', confidence: 'high',
115
+ pattern: /^\s*@(tool|function_tool|mcp\.tool|app\.tool)\b/,
116
+ extensions: PY,
117
+ },
118
+ {
119
+ kind: 'tool_definition', signal: 'python.base_tool_subclass', confidence: 'high',
120
+ pattern: /class\s+\w+\s*\(\s*(BaseTool|StructuredTool)\b/,
121
+ extensions: PY,
122
+ },
123
+ {
124
+ kind: 'tool_definition', signal: 'js.ai_sdk_tool', confidence: 'medium',
125
+ pattern: /\btool\s*\(\s*\{|\bcreateTool\s*\(|\bDynamicStructuredTool\s*\(/,
126
+ extensions: JS,
127
+ },
128
+ {
129
+ kind: 'tool_definition', signal: 'tool_registry.registration', confidence: 'medium',
130
+ pattern: /\b(register_tool|registerTool|add_tool|ToolRegistry)\s*\(/,
131
+ },
132
+ // ---- Tool schema dicts --------------------------------------------------
133
+ {
134
+ kind: 'tool_schema', signal: 'openai.function_schema', confidence: 'high',
135
+ pattern: /["']type["']\s*:\s*["']function["']/,
136
+ },
137
+ {
138
+ kind: 'tool_schema', signal: 'anthropic.input_schema', confidence: 'high',
139
+ pattern: /\binput_schema\b|\binputSchema\s*[:=]/,
140
+ },
141
+ {
142
+ kind: 'tool_schema', signal: 'json_schema.parameters_block', confidence: 'medium',
143
+ pattern: /["']parameters["']\s*:\s*\{/,
144
+ },
145
+ // ---- Message construction (weak corroborating signal) -------------------
146
+ {
147
+ kind: 'message_construction', signal: 'chat.role_content_dict', confidence: 'low',
148
+ pattern: /["']role["']\s*:\s*["'](system|user|assistant|tool)["']|\brole\s*=\s*["'](system|user|assistant)["']/,
149
+ },
150
+ // ---- Model config assignments -------------------------------------------
151
+ {
152
+ kind: 'model_config', signal: 'model.assignment', confidence: 'high',
153
+ pattern: /\b(model|model_name|model_id|modelName|modelId|MODEL[A-Z_]*)\s*[:=]\s*["'`][\w.:/-]*(gpt-|o[134](-mini)?\b|claude|gemini|llama|mistral|mixtral|deepseek|qwen|grok|sonnet|opus|haiku|command-r|glm|gemma)[\w.:/-]*["'`]/i,
154
+ },
155
+ {
156
+ kind: 'model_config', signal: 'model.env_default', confidence: 'medium',
157
+ pattern: /\b(OPENAI_MODEL|ANTHROPIC_MODEL|MODEL_NAME|LLM_MODEL|DEFAULT_MODEL)\b/,
158
+ },
159
+ ];
160
+ const CODE_EXTENSIONS = new Set([...JS, ...PY]);
161
+ const PROMPT_DOC_EXTENSIONS = new Set(['.md', '.mdc', '.txt', '.prompt', '.jinja', '.jinja2', '.j2', '.hbs', '.tpl']);
162
+ const NON_PROMPT_DOC_BASENAMES = new Set([
163
+ 'readme.md', 'agents.md', 'claude.md', 'changelog.md', 'contributing.md',
164
+ 'license.md', 'license.txt', 'code_of_conduct.md', 'security.md', 'requirements.txt',
165
+ ]);
166
+ const PROMPT_NAME_HINT = /(prompt|system[-_]?message|instruction|persona|sop)/i;
167
+ const AGENTIC_DIR_HINT = /(^|\/)(agents?|prompts?|llm|ai|assistants?|chains?|crew|graph)s?(\/|$)/i;
168
+ const INSTRUCTION_MARKERS = [
169
+ /\byou are\b/i,
170
+ /\byou're\s+(a|an|the)\b/i,
171
+ /\byour (task|role|job|goal|objective)\b/i,
172
+ /\b(do not|don't|never|always|must|should)\b/i,
173
+ /\brespond (with|only|in|using)\b/i,
174
+ /\bstep[- ]by[- ]step\b/i,
175
+ /\binstructions?\b/i,
176
+ /\bassistant\b/i,
177
+ /\bthe user\b/i,
178
+ /\bif the user\b/i,
179
+ ];
180
+ function instructionScore(text) {
181
+ let score = 0;
182
+ for (const marker of INSTRUCTION_MARKERS) {
183
+ if (marker.test(text))
184
+ score += 1;
185
+ }
186
+ return score;
187
+ }
188
+ function normalizeExcerpt(text) {
189
+ const collapsed = text.replace(/\s+/g, ' ').trim();
190
+ return collapsed.length > EXCERPT_MAX_CHARS
191
+ ? `${collapsed.slice(0, EXCERPT_MAX_CHARS - 1)}…`
192
+ : collapsed;
193
+ }
194
+ /** Map a character index into 1-based line numbers using precomputed offsets. */
195
+ function buildLineIndex(text) {
196
+ const offsets = [0];
197
+ for (let i = 0; i < text.length; i += 1) {
198
+ if (text.charCodeAt(i) === 10)
199
+ offsets.push(i + 1);
200
+ }
201
+ return offsets;
202
+ }
203
+ function lineAtIndex(offsets, index) {
204
+ let lo = 0;
205
+ let hi = offsets.length - 1;
206
+ while (lo < hi) {
207
+ const mid = (lo + hi + 1) >> 1;
208
+ if (offsets[mid] <= index)
209
+ lo = mid;
210
+ else
211
+ hi = mid - 1;
212
+ }
213
+ return lo + 1;
214
+ }
215
+ function detectLinePatterns(file, out) {
216
+ const ext = extname(file.path).toLowerCase();
217
+ const lines = file.text.split('\n');
218
+ const perSignal = new Map();
219
+ for (const pattern of LINE_PATTERNS) {
220
+ if (pattern.extensions && !pattern.extensions.includes(ext))
221
+ continue;
222
+ for (let i = 0; i < lines.length; i += 1) {
223
+ const line = lines[i];
224
+ if (line.length > 4000)
225
+ continue; // minified/bundled line — skip, not agent source
226
+ if (!pattern.pattern.test(line))
227
+ continue;
228
+ const seen = perSignal.get(pattern.signal) ?? 0;
229
+ if (seen >= MAX_CANDIDATES_PER_FILE_PER_SIGNAL)
230
+ break;
231
+ perSignal.set(pattern.signal, seen + 1);
232
+ out.push({
233
+ kind: pattern.kind,
234
+ path: file.path,
235
+ startLine: i + 1,
236
+ endLine: i + 1,
237
+ confidence: pattern.confidence,
238
+ signal: pattern.signal,
239
+ excerpt: normalizeExcerpt(line),
240
+ });
241
+ }
242
+ }
243
+ }
244
+ /** A line that is (only) a string literal, optionally with prefix/trailing punctuation. */
245
+ const STRING_ONLY_LINE = /^\s*[frbu]{0,3}("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')\s*[,)\]]*;?\s*$/i;
246
+ const MIN_ADJACENT_STRING_LINES = 4;
247
+ /**
248
+ * Prompts assembled from runs of adjacent single-line string literals
249
+ * (Python implicit concatenation, JS `[...].join('\n')` arrays). The
250
+ * triple-quote/template-literal scan cannot see these.
251
+ */
252
+ function detectAdjacentStringBlocks(file, out) {
253
+ const lines = file.text.split('\n');
254
+ let blockStart = -1;
255
+ let parts = [];
256
+ let found = 0;
257
+ const flush = (endLineIndex) => {
258
+ const start = blockStart;
259
+ blockStart = -1;
260
+ const content = parts.join('\n');
261
+ parts = [];
262
+ if (start === -1 || endLineIndex - start + 1 < MIN_ADJACENT_STRING_LINES)
263
+ return;
264
+ if (content.length < MIN_PROMPT_LITERAL_CHARS)
265
+ return;
266
+ const score = instructionScore(content);
267
+ if (score < 2)
268
+ return;
269
+ if (found >= MAX_CANDIDATES_PER_FILE_PER_SIGNAL)
270
+ return;
271
+ found += 1;
272
+ out.push({
273
+ kind: 'prompt_literal',
274
+ path: file.path,
275
+ startLine: start + 1,
276
+ endLine: endLineIndex + 1,
277
+ confidence: score >= 4 || content.length >= 600 ? 'high' : 'medium',
278
+ signal: 'code.adjacent_string_block',
279
+ excerpt: normalizeExcerpt(content),
280
+ });
281
+ };
282
+ for (let i = 0; i < lines.length; i += 1) {
283
+ const match = lines[i].length <= 4000 ? STRING_ONLY_LINE.exec(lines[i]) : null;
284
+ if (match) {
285
+ if (blockStart === -1)
286
+ blockStart = i;
287
+ parts.push(match[1].slice(1, -1));
288
+ }
289
+ else {
290
+ flush(i - 1);
291
+ }
292
+ }
293
+ flush(lines.length - 1);
294
+ }
295
+ const PROMPT_MODULE_NAME_HINT = /(^|[._-])(system[._-]?)?prompts?([._-]|$)/i;
296
+ /** Code modules named like prompt builders (system_prompt.py, prompts.ts, ...). */
297
+ function detectPromptModule(file, out) {
298
+ const base = basename(file.path, extname(file.path));
299
+ if (!PROMPT_MODULE_NAME_HINT.test(base))
300
+ return;
301
+ out.push({
302
+ kind: 'prompt_file',
303
+ path: file.path,
304
+ startLine: 1,
305
+ endLine: Math.max(1, file.text.split('\n').length),
306
+ confidence: 'medium',
307
+ signal: 'prompt_module.name_hint',
308
+ excerpt: normalizeExcerpt(file.text.slice(0, EXCERPT_MAX_CHARS * 2)),
309
+ });
310
+ }
311
+ /**
312
+ * Multi-line string literal scan: Python triple-quoted strings and JS/TS
313
+ * template literals whose contents are long and instruction-like.
314
+ */
315
+ function detectPromptLiterals(file, out) {
316
+ const ext = extname(file.path).toLowerCase();
317
+ const offsets = buildLineIndex(file.text);
318
+ let found = 0;
319
+ const record = (startIndex, endIndex, content, signal) => {
320
+ if (content.length < MIN_PROMPT_LITERAL_CHARS)
321
+ return;
322
+ const score = instructionScore(content);
323
+ if (score < 2)
324
+ return;
325
+ if (found >= MAX_CANDIDATES_PER_FILE_PER_SIGNAL)
326
+ return;
327
+ found += 1;
328
+ out.push({
329
+ kind: 'prompt_literal',
330
+ path: file.path,
331
+ startLine: lineAtIndex(offsets, startIndex),
332
+ endLine: lineAtIndex(offsets, endIndex),
333
+ confidence: score >= 4 || content.length >= 600 ? 'high' : 'medium',
334
+ signal,
335
+ excerpt: normalizeExcerpt(content),
336
+ });
337
+ };
338
+ if (ext === '.py') {
339
+ const tripleQuoted = /("""|''')([\s\S]*?)\1/g;
340
+ let match;
341
+ while ((match = tripleQuoted.exec(file.text)) !== null) {
342
+ record(match.index, match.index + match[0].length - 1, match[2], 'python.triple_quoted_string');
343
+ }
344
+ return;
345
+ }
346
+ if (CODE_EXTENSIONS.has(ext)) {
347
+ // Approximate template-literal spans: toggle on unescaped backticks. Nested
348
+ // ${`...`} interpolation can split a literal in two — acceptable for a
349
+ // recall-oriented heuristic (both halves are still instruction-scored).
350
+ let start = -1;
351
+ for (let i = 0; i < file.text.length; i += 1) {
352
+ if (file.text[i] !== '`' || (i > 0 && file.text[i - 1] === '\\'))
353
+ continue;
354
+ if (start === -1) {
355
+ start = i;
356
+ }
357
+ else {
358
+ record(start, i, file.text.slice(start + 1, i), 'js.template_literal');
359
+ start = -1;
360
+ }
361
+ }
362
+ }
363
+ }
364
+ function detectPromptFile(file, out) {
365
+ const ext = extname(file.path).toLowerCase();
366
+ if (!PROMPT_DOC_EXTENSIONS.has(ext))
367
+ return;
368
+ const base = basename(file.path).toLowerCase();
369
+ if (NON_PROMPT_DOC_BASENAMES.has(base))
370
+ return;
371
+ if (/(^|\/)(docs?|documentation|examples?|\.github)\//i.test(file.path))
372
+ return;
373
+ const dir = dirname(file.path);
374
+ const nameHint = PROMPT_NAME_HINT.test(base);
375
+ const dirHint = AGENTIC_DIR_HINT.test(dir);
376
+ const score = instructionScore(file.text.slice(0, 20_000));
377
+ let confidence;
378
+ let signal;
379
+ if (nameHint) {
380
+ confidence = 'high';
381
+ signal = 'prompt_file.name_hint';
382
+ }
383
+ else if (dirHint && score >= 3) {
384
+ confidence = 'medium';
385
+ signal = 'prompt_file.agent_dir_instruction_text';
386
+ }
387
+ else if (score >= 5) {
388
+ confidence = 'low';
389
+ signal = 'prompt_file.instruction_text';
390
+ }
391
+ if (!confidence || !signal)
392
+ return;
393
+ out.push({
394
+ kind: 'prompt_file',
395
+ path: file.path,
396
+ startLine: 1,
397
+ endLine: Math.max(1, file.text.split('\n').length),
398
+ confidence,
399
+ signal,
400
+ excerpt: normalizeExcerpt(file.text.slice(0, EXCERPT_MAX_CHARS * 2)),
401
+ });
402
+ }
403
+ /**
404
+ * Pure detector core: run every heuristic over an already-materialized file
405
+ * inventory. No filesystem access — unit tests and the walker wrapper both
406
+ * call this.
407
+ */
408
+ export function detectHarnessCandidatesInFiles(files) {
409
+ const candidates = [];
410
+ for (const file of files) {
411
+ if (typeof file.text !== 'string' || file.text.length === 0)
412
+ continue;
413
+ const scoped = { path: file.path, text: file.text };
414
+ const ext = extname(file.path).toLowerCase();
415
+ if (CODE_EXTENSIONS.has(ext) || ext === '.json' || ext === '.yaml' || ext === '.yml' || ext === '.toml') {
416
+ detectLinePatterns(scoped, candidates);
417
+ }
418
+ if (CODE_EXTENSIONS.has(ext)) {
419
+ detectAdjacentStringBlocks(scoped, candidates);
420
+ detectPromptModule(scoped, candidates);
421
+ }
422
+ detectPromptLiterals(scoped, candidates);
423
+ detectPromptFile(scoped, candidates);
424
+ }
425
+ candidates.sort((a, b) => a.path === b.path ? a.startLine - b.startLine || a.signal.localeCompare(b.signal) : a.path.localeCompare(b.path));
426
+ const byFile = new Map();
427
+ for (const candidate of candidates) {
428
+ let summary = byFile.get(candidate.path);
429
+ if (!summary) {
430
+ summary = { path: candidate.path, score: 0, kinds: [], candidateCount: 0 };
431
+ byFile.set(candidate.path, summary);
432
+ }
433
+ summary.score += CONFIDENCE_WEIGHT[candidate.confidence];
434
+ summary.candidateCount += 1;
435
+ if (!summary.kinds.includes(candidate.kind))
436
+ summary.kinds.push(candidate.kind);
437
+ }
438
+ const fileSummaries = [...byFile.values()].sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
439
+ return { candidates, files: fileSummaries };
440
+ }
441
+ /**
442
+ * Walk `rootDir` with the harness inventory walker (same skip/secret/symlink
443
+ * budget rules as `harness scan`, widened to include all prompt-doc files)
444
+ * and run the static detector over it.
445
+ */
446
+ export function detectHarnessCandidates(rootDir) {
447
+ const startedAt = Date.now();
448
+ const skipped = [];
449
+ const root = resolve(rootDir);
450
+ const inventory = inventoryFiles(root, skipped, {
451
+ includeFile: (path) => PROMPT_DOC_EXTENSIONS.has(extname(path).toLowerCase()),
452
+ });
453
+ const { candidates, files } = detectHarnessCandidatesInFiles(inventory);
454
+ const durationMs = Math.max(1, Date.now() - startedAt);
455
+ const textBytesScanned = inventory.reduce((sum, file) => sum + (file.text ? Buffer.byteLength(file.text, 'utf8') : 0), 0);
456
+ const report = {
457
+ schemaVersion: 'harness_candidates.v0.1',
458
+ generatedAt: new Date().toISOString(),
459
+ rootDir: root,
460
+ stats: {
461
+ filesScanned: inventory.length,
462
+ filesWithCandidates: files.length,
463
+ candidateCount: candidates.length,
464
+ textBytesScanned,
465
+ durationMs,
466
+ filesPerSecond: Math.round((inventory.length / durationMs) * 1000),
467
+ },
468
+ candidates,
469
+ files,
470
+ skipped,
471
+ };
472
+ // Excerpts quote user source verbatim; strip anything token-shaped before
473
+ // the report leaves the process.
474
+ return redactDeep(report);
475
+ }
476
+ export function writeHarnessCandidatesReport(report, outPath) {
477
+ const target = resolve(outPath);
478
+ mkdirSync(dirname(target), { recursive: true });
479
+ writeFileSync(target, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
480
+ return target;
481
+ }
482
+ //# sourceMappingURL=harness-candidates.js.map
package/dist/harness.d.ts CHANGED
@@ -173,6 +173,12 @@ export interface HarnessGraph {
173
173
  relationships: HarnessRelationship[];
174
174
  unknowns: HarnessUnknown[];
175
175
  }
176
+ export interface FileInventoryItem {
177
+ path: string;
178
+ absPath: string;
179
+ rootDir: string;
180
+ text?: string;
181
+ }
176
182
  interface ScanOptions {
177
183
  rootDir?: string;
178
184
  dryRun?: boolean;
@@ -439,6 +445,18 @@ export declare function writeHarnessArtifacts(graph: HarnessGraph, rootDir: stri
439
445
  summary?: boolean;
440
446
  }): string[];
441
447
  export declare function graphForSync(graph: HarnessGraph): HarnessGraph;
448
+ /**
449
+ * Bounded repo walk shared by `harness scan`, snapshot upload, and the static
450
+ * candidates pre-pass: skips ignored/secret-bearing paths and symlinks,
451
+ * enforces the file-count and text-byte budgets, and reads text for known
452
+ * text extensions plus any file admitted by `options.includeFile`.
453
+ * `options.includeFile` widens (ORs with) the default inventory predicate and
454
+ * also opts its files into text reading — it can never re-admit secret-bearing
455
+ * or oversized files, which are rejected before the predicate runs.
456
+ */
457
+ export declare function inventoryFiles(rootDir: string, skipped: HarnessScanMetadata['skipped'], options?: {
458
+ includeFile?: (relPath: string) => boolean;
459
+ }): FileInventoryItem[];
442
460
  export declare function numericEnv(name: string, fallback: number): number;
443
461
  export declare function numericEnvAllowZero(name: string, fallback: number): number;
444
462
  export {};
package/dist/harness.js CHANGED
@@ -12,6 +12,7 @@ import { HARNESS_REMOTE_RUN_STATE_PATH, isRemoteHarnessAnalyze, readRemoteRunSta
12
12
  import { getActiveProfile } from './profiles.js';
13
13
  import { enrichScanCaptureMetadata, annotatedSourceCandidates, extractToolBody, hydratePromptAndToolBodies, readPromptOrToolBody, resolveAnnotatedSourcePath, } from './harness-artifact-bodies.js';
14
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';
15
16
  import { assertHarnessGraphSyncSize } from './harness-sync-limits.js';
16
17
  import { renderHarnessAnalystChoice, renderHarnessAnalystLaunch, renderHarnessAnalystPreflight, renderHarnessAnalyzeHumanResult, renderHarnessProgress, } from './tui.js';
17
18
  const MODA_DIR = '.moda';
@@ -381,6 +382,29 @@ export async function runHarnessCommand(context) {
381
382
  });
382
383
  return { exitCode: 0 };
383
384
  }
385
+ case 'candidates': {
386
+ await approveScanIfNeeded(scanPlan, {
387
+ context,
388
+ message: 'Scan this project for static harness candidates?',
389
+ });
390
+ writeHumanProgress(context, 'Harness candidates: static pre-pass (no LLM, no network)...');
391
+ const report = detectHarnessCandidates(rootDir);
392
+ const written = [];
393
+ const outFlag = context.flags.out;
394
+ // A value-less `--out` is parsed as the sentinel string 'true'; reject it
395
+ // rather than silently writing the report to `<cwd>/true`.
396
+ if (outFlag === 'true') {
397
+ throw new CliInputError('Missing output path for --out.', 'Usage: moda harness candidates --out=<file>');
398
+ }
399
+ if (outFlag && !context.dryRun) {
400
+ written.push(writeHarnessCandidatesReport(report, resolve(context.cwd, outFlag)));
401
+ }
402
+ writeHumanProgress(context, `Harness candidates: ${report.stats.candidateCount} candidates across ` +
403
+ `${report.stats.filesWithCandidates} files (${report.stats.filesScanned} scanned, ` +
404
+ `${report.stats.filesPerSecond.toLocaleString()} files/sec).`);
405
+ context.output.writeData({ scanPlan, ...report, written, dryRun: context.dryRun });
406
+ return { exitCode: 0 };
407
+ }
384
408
  case 'agents': {
385
409
  const graph = loadOrScanHarness(rootDir, tenant, context);
386
410
  context.output.writeData({
@@ -470,7 +494,7 @@ export async function runHarnessCommand(context) {
470
494
  return { exitCode: 0 };
471
495
  }
472
496
  default:
473
- throw new CliInputError(`Unknown harness command '${subcommand}'.`, 'Usage: moda harness analyze|pull|validate-report|approve|scan|sync|agents|graph|explain|write-summary|status');
497
+ throw new CliInputError(`Unknown harness command '${subcommand}'.`, 'Usage: moda harness analyze|pull|validate-report|approve|scan|candidates|sync|agents|graph|explain|write-summary|status');
474
498
  }
475
499
  }
476
500
  export function writeHarnessAnalyzeResult(context, payload) {
@@ -2406,7 +2430,16 @@ function explainAgent(graph, agent) {
2406
2430
  relationships: graph.relationships.filter((relationship) => relationship.from === agent.id || relationship.to === agent.id),
2407
2431
  };
2408
2432
  }
2409
- function inventoryFiles(rootDir, skipped) {
2433
+ /**
2434
+ * Bounded repo walk shared by `harness scan`, snapshot upload, and the static
2435
+ * candidates pre-pass: skips ignored/secret-bearing paths and symlinks,
2436
+ * enforces the file-count and text-byte budgets, and reads text for known
2437
+ * text extensions plus any file admitted by `options.includeFile`.
2438
+ * `options.includeFile` widens (ORs with) the default inventory predicate and
2439
+ * also opts its files into text reading — it can never re-admit secret-bearing
2440
+ * or oversized files, which are rejected before the predicate runs.
2441
+ */
2442
+ export function inventoryFiles(rootDir, skipped, options = {}) {
2410
2443
  const files = [];
2411
2444
  const rootReal = resolve(rootDir);
2412
2445
  const maxFiles = numericEnv('MODA_HARNESS_MAX_FILES', DEFAULT_MAX_INVENTORY_FILES);
@@ -2446,7 +2479,7 @@ function inventoryFiles(rootDir, skipped) {
2446
2479
  recordSkipped(skipped, { path: rel, reason: 'secret-bearing file skipped' });
2447
2480
  return;
2448
2481
  }
2449
- if (!shouldInventoryFile(rel))
2482
+ if (!shouldInventoryFile(rel) && !(options.includeFile?.(rel) ?? false))
2450
2483
  return;
2451
2484
  if (files.length >= maxFiles) {
2452
2485
  throwHarnessScanTooBroad(rootReal, {
@@ -2458,7 +2491,12 @@ function inventoryFiles(rootDir, skipped) {
2458
2491
  });
2459
2492
  }
2460
2493
  const item = { path: rel, absPath: current, rootDir: rootReal };
2461
- if (isTextFile(rel)) {
2494
+ // Read text for known text extensions, but also for any file the caller
2495
+ // widened in via `includeFile` (e.g. the candidates pass admits template
2496
+ // prompt files like `.jinja`/`.hbs`/`.tpl`/`.prompt` whose extensions are
2497
+ // not in `isTextFile`) — otherwise a widened-in file is counted as scanned
2498
+ // but never gets its text read, so downstream detectors silently skip it.
2499
+ if (isTextFile(rel) || (options.includeFile?.(rel) ?? false)) {
2462
2500
  if (textBytes + stat.size > maxTextBytes) {
2463
2501
  recordSkipped(skipped, { path: rel, reason: 'scan text budget exceeded' });
2464
2502
  }
package/dist/redact.js CHANGED
@@ -19,6 +19,15 @@ const SECRET_PATTERNS = [
19
19
  { pattern: /\bmoda_ct_[A-Za-z0-9_-]+\b/g, prefixLength: 8 },
20
20
  { pattern: /\bBearer\s+([A-Za-z0-9._~+/-]+=*)\b/gi, prefixLength: 8 },
21
21
  { pattern: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, prefixLength: 8 },
22
+ // Third-party provider credentials that can appear inline in scanned source
23
+ // (e.g. harness-candidate excerpts) — the detectors deliberately fire on the
24
+ // very lines most likely to carry hardcoded keys.
25
+ { pattern: /\bsk-ant-api\d{2}-[A-Za-z0-9_-]{20,}\b/g, prefixLength: 12 }, // Anthropic
26
+ { pattern: /\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{20,}\b/g, prefixLength: 8 }, // OpenAI
27
+ { pattern: /\bAKIA[A-Z0-9]{16}\b/g, prefixLength: 8 }, // AWS access key id
28
+ { pattern: /\bAIza[A-Za-z0-9_-]{35}\b/g, prefixLength: 8 }, // Google API key
29
+ { pattern: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g, prefixLength: 8 }, // GitHub token
30
+ { pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, prefixLength: 8 }, // Slack token
22
31
  ];
23
32
  export function redactDeep(value) {
24
33
  return redactValue(value, 0, new WeakSet());
package/dist/schemas.d.ts CHANGED
@@ -83,12 +83,12 @@ export declare const ContextSchema: z.ZodObject<{
83
83
  window: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
84
84
  }, "strip", z.ZodTypeAny, {
85
85
  conversation_id: string;
86
- msg_index?: number | undefined;
87
86
  window?: number | undefined;
87
+ msg_index?: number | undefined;
88
88
  }, {
89
89
  conversation_id: string;
90
- msg_index?: number | undefined;
91
90
  window?: number | undefined;
91
+ msg_index?: number | undefined;
92
92
  }>;
93
93
  export declare const FrustrationsSchema: z.ZodObject<{
94
94
  days_back: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
@@ -97,16 +97,16 @@ export declare const FrustrationsSchema: z.ZodObject<{
97
97
  include_window: z.ZodOptional<z.ZodEffects<z.ZodBoolean, boolean, unknown>>;
98
98
  window: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
99
99
  }, "strip", z.ZodTypeAny, {
100
+ window?: number | undefined;
100
101
  days_back?: number | undefined;
101
102
  limit?: number | undefined;
102
103
  offset?: number | undefined;
103
- window?: number | undefined;
104
104
  include_window?: boolean | undefined;
105
105
  }, {
106
+ window?: number | undefined;
106
107
  days_back?: number | undefined;
107
108
  limit?: number | undefined;
108
109
  offset?: number | undefined;
109
- window?: number | undefined;
110
110
  include_window?: unknown;
111
111
  }>;
112
112
  export declare const ToolFailuresSchema: z.ZodObject<{
@@ -126,19 +126,19 @@ export declare const ToolFailureDetailSchema: z.ZodObject<{
126
126
  window: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
127
127
  }, "strip", z.ZodTypeAny, {
128
128
  tool_name: string;
129
+ window?: number | undefined;
129
130
  subtype?: string | undefined;
130
131
  days_back?: number | undefined;
131
132
  limit?: number | undefined;
132
133
  offset?: number | undefined;
133
- window?: number | undefined;
134
134
  include_window?: boolean | undefined;
135
135
  }, {
136
136
  tool_name: string;
137
+ window?: number | undefined;
137
138
  subtype?: string | undefined;
138
139
  days_back?: number | undefined;
139
140
  limit?: number | undefined;
140
141
  offset?: number | undefined;
141
- window?: number | undefined;
142
142
  include_window?: unknown;
143
143
  }>;
144
144
  /**
@@ -172,17 +172,20 @@ export declare const SearchSchema: z.ZodObject<{
172
172
  user_id: z.ZodOptional<z.ZodString>;
173
173
  time_range: z.ZodOptional<z.ZodDefault<z.ZodEnum<["all", "1h", "3d", "7d", "24h", "30d", "90d"]>>>;
174
174
  limit: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
175
+ include_tool_io: z.ZodOptional<z.ZodDefault<z.ZodBoolean>>;
175
176
  }, "strip", z.ZodTypeAny, {
176
177
  q: string;
177
178
  mode?: "keyword" | "semantic" | "hybrid" | undefined;
178
179
  time_range?: "all" | "1h" | "3d" | "7d" | "24h" | "30d" | "90d" | undefined;
179
180
  limit?: number | undefined;
180
181
  user_id?: string | undefined;
182
+ include_tool_io?: boolean | undefined;
181
183
  }, {
182
184
  q: string;
183
185
  mode?: "keyword" | "semantic" | "hybrid" | undefined;
184
186
  time_range?: "all" | "1h" | "3d" | "7d" | "24h" | "30d" | "90d" | undefined;
185
187
  limit?: number | undefined;
186
188
  user_id?: string | undefined;
189
+ include_tool_io?: boolean | undefined;
187
190
  }>;
188
191
  //# sourceMappingURL=schemas.d.ts.map
package/dist/schemas.js CHANGED
@@ -91,5 +91,6 @@ export const SearchSchema = z.object({
91
91
  user_id: z.string().optional(),
92
92
  time_range: z.enum(TIME_RANGES).default('all').optional(),
93
93
  limit: z.number().min(1).max(100).default(20).optional(),
94
+ include_tool_io: z.boolean().default(false).optional(),
94
95
  });
95
96
  //# sourceMappingURL=schemas.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moda-ai/cli",
3
- "version": "1.19.0",
3
+ "version": "1.19.2",
4
4
  "description": "CLI for Moda - AI agent analytics and observability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": "moda.skill_index.v1",
3
- "bundled_at": "2026-07-11T00:30:28.897Z",
4
- "cli_version": "1.19.0",
3
+ "bundled_at": "2026-07-11T20:58:39.588Z",
4
+ "cli_version": "1.19.2",
5
5
  "skills": [
6
6
  {
7
7
  "id": "integration-node-anthropic",