@moda-ai/cli 1.18.1 → 1.19.1
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 +8 -1
- package/dist/config.d.ts +12 -0
- package/dist/config.js +28 -0
- package/dist/harness-candidates.d.ts +77 -0
- package/dist/harness-candidates.js +482 -0
- package/dist/harness.d.ts +63 -0
- package/dist/harness.js +245 -16
- package/dist/init/index.d.ts +39 -1
- package/dist/init/index.js +422 -10
- package/dist/init/tenant.js +7 -25
- package/dist/redact.js +9 -0
- package/package.json +1 -1
- package/skills/integration/index.json +2 -2
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
|
|
@@ -250,6 +251,10 @@ Init flags:
|
|
|
250
251
|
--no-ci-cd Skip CI/CD workflow creation
|
|
251
252
|
--autosync Create GitHub Actions workflow to sync prompts + skills to Moda on push to main
|
|
252
253
|
--no-autosync Skip autosync workflow creation
|
|
254
|
+
--harness-rescan Create GitHub Actions workflow to re-scan the harness and sync it to Moda on push to main
|
|
255
|
+
--no-harness-rescan Skip harness rescan workflow creation
|
|
256
|
+
--harness-rescan-paths=GLOBS Comma-separated path globs that scope which pushes trigger the
|
|
257
|
+
harness rescan workflow (implies --harness-rescan)
|
|
253
258
|
--offline Run final validation offline (skip the online telemetry check)
|
|
254
259
|
|
|
255
260
|
Provision flags (headless; requires an existing "moda auth login" session):
|
|
@@ -289,9 +294,11 @@ Examples:
|
|
|
289
294
|
moda sync --dry-run
|
|
290
295
|
moda sync harness --profile=prod
|
|
291
296
|
moda harness scan --json
|
|
297
|
+
moda harness candidates --out=.moda/harness-candidates.json
|
|
292
298
|
moda harness explain --agent=agent_support
|
|
293
299
|
moda init --ci-cd
|
|
294
300
|
moda init --autosync
|
|
301
|
+
moda init --harness-rescan --harness-rescan-paths='src/agents/**'
|
|
295
302
|
moda overview --days-back=30
|
|
296
303
|
moda clusters --time-range=7d
|
|
297
304
|
moda conversations --search="error" --limit=5
|
|
@@ -707,7 +714,7 @@ export const commandRegistry = createCommandRegistry([
|
|
|
707
714
|
{
|
|
708
715
|
name: 'init',
|
|
709
716
|
description: 'Set up Moda in your project',
|
|
710
|
-
examples: ['moda init', 'moda init --sdk-integrate', 'moda init --ci-cd', 'moda init --autosync'],
|
|
717
|
+
examples: ['moda init', 'moda init --sdk-integrate', 'moda init --ci-cd', 'moda init --autosync', 'moda init --harness-rescan'],
|
|
711
718
|
auth: 'none',
|
|
712
719
|
outputModes: ['human', 'json', 'agent'],
|
|
713
720
|
mutability: 'write',
|
package/dist/config.d.ts
CHANGED
|
@@ -28,6 +28,18 @@ export declare function saveCliConfig(update: CliConfig, options?: ProfileIOOpti
|
|
|
28
28
|
* long-lived process (or a test) sees the current value.
|
|
29
29
|
*/
|
|
30
30
|
export declare function resolveApiKey(options?: ProfileIOOptions): string | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Signed CLI keys embed their tenant: the format is
|
|
33
|
+
* moda_sk_<base64url("v1:keyId:tenantId:timestamp:signature")> — see
|
|
34
|
+
* createApiKey/parseAndVerifySignedKey in
|
|
35
|
+
* apps/moda-backend/src/api-keys/api-keys.service.ts. The server derives the
|
|
36
|
+
* upload destination tenant from the key alone, so this is the only
|
|
37
|
+
* client-side tenant source that matches where data actually lands
|
|
38
|
+
* (config / MODA_TENANT_ID can disagree with a shadowing env key). The HMAC
|
|
39
|
+
* signature can only be verified server-side; treat the result as
|
|
40
|
+
* informational.
|
|
41
|
+
*/
|
|
42
|
+
export declare function embeddedTenantIdFromApiKey(key: string): string | undefined;
|
|
31
43
|
/**
|
|
32
44
|
* Resolve the ingest worker base URL used by `moda workspace snapshot`.
|
|
33
45
|
* Env override -> active profile -> legacy config -> production default.
|
package/dist/config.js
CHANGED
|
@@ -40,6 +40,34 @@ export function resolveApiKey(options = {}) {
|
|
|
40
40
|
loadCliConfig(options).api_key ||
|
|
41
41
|
undefined);
|
|
42
42
|
}
|
|
43
|
+
const SIGNED_API_KEY_PREFIX = 'moda_sk_';
|
|
44
|
+
const SIGNED_API_KEY_VERSION = 'v1';
|
|
45
|
+
/**
|
|
46
|
+
* Signed CLI keys embed their tenant: the format is
|
|
47
|
+
* moda_sk_<base64url("v1:keyId:tenantId:timestamp:signature")> — see
|
|
48
|
+
* createApiKey/parseAndVerifySignedKey in
|
|
49
|
+
* apps/moda-backend/src/api-keys/api-keys.service.ts. The server derives the
|
|
50
|
+
* upload destination tenant from the key alone, so this is the only
|
|
51
|
+
* client-side tenant source that matches where data actually lands
|
|
52
|
+
* (config / MODA_TENANT_ID can disagree with a shadowing env key). The HMAC
|
|
53
|
+
* signature can only be verified server-side; treat the result as
|
|
54
|
+
* informational.
|
|
55
|
+
*/
|
|
56
|
+
export function embeddedTenantIdFromApiKey(key) {
|
|
57
|
+
if (!key.startsWith(SIGNED_API_KEY_PREFIX))
|
|
58
|
+
return undefined;
|
|
59
|
+
try {
|
|
60
|
+
const decoded = Buffer.from(key.slice(SIGNED_API_KEY_PREFIX.length), 'base64url').toString('utf8');
|
|
61
|
+
// Expected format: version:keyId:tenantId:timestamp:signature
|
|
62
|
+
const parts = decoded.split(':');
|
|
63
|
+
if (parts.length !== 5 || parts[0] !== SIGNED_API_KEY_VERSION)
|
|
64
|
+
return undefined;
|
|
65
|
+
return parts[2] || undefined;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
43
71
|
/**
|
|
44
72
|
* Resolve the ingest worker base URL used by `moda workspace snapshot`.
|
|
45
73
|
* Env override -> active profile -> legacy config -> production default.
|
|
@@ -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
|