edsger 0.63.0 → 0.64.0

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.
@@ -17,53 +17,33 @@ export const SESSION_ID_MARKER = '__EDSGER_SESSION_ID__=';
17
17
  * there is no fixed pipeline.
18
18
  */
19
19
  import { query } from '@anthropic-ai/claude-agent-sdk';
20
- import { createSessionMcpServer } from 'edsger-tools';
20
+ import { buildSessionAgentOptions, buildSessionSystemPrompt, buildSessionUserPrompt, createSessionMcpServer, loadExternalMcpServers, SESSION_MAX_TURNS, } from 'edsger-tools';
21
21
  import { callMcpEndpoint } from '../../api/mcp-client.js';
22
22
  import { DEFAULT_MODEL } from '../../constants.js';
23
23
  import { getToolDeps } from '../../tools/bootstrap.js';
24
- import { logError, logInfo, logSuccess } from '../../utils/logger.js';
25
- const MAX_TURNS = 40;
26
- function buildSystemPrompt(opts) {
27
- const { channelId, productId, repositoryIds } = opts;
28
- const repoLine = repositoryIds.length > 0
29
- ? repositoryIds.join(', ')
30
- : '(none provided call list_session_repositories to discover them)';
31
- return `You are an engineering agent working in a chat session with a user, like a pair-programming assistant.
32
-
33
- ## This session
34
- - Session channel_id: ${channelId}
35
- - Product product_id: ${productId}
36
- - In-scope repository_ids: ${repoLine}
37
-
38
- ## How you work
39
- You are an orchestrator, not a fixed pipeline. Do only what the user asked:
40
- - A question or status check → just answer (you can read code with Read/Grep/Glob).
41
- - "Write the test cases" / "add user stories" → use create_user_story / create_test_case / create_test_report. No code.
42
- - "Check quality" / "find bugs" → launch the matching cli_* tool (asynchronous: it returns a run handle, results appear later; report progress with get_cli_run).
43
-
44
- ## Implementing code changes (you hand this off — you do NOT write code or open PRs yourself)
45
- When the user wants code changes:
46
- 1. Optionally list_session_repositories to understand the codebase scope.
47
- 2. create_issue with a clear, implementable description (the product may span multiple repos — say which).
48
- 3. Mark it ready for AI with update_issue_status so the engineering pipeline picks it up, clones the repos, implements the change, and opens the pull request(s).
49
- 4. Tell the user you filed the issue and that the pipeline will produce the PR(s); offer to check status later.
50
- Only create issues/tasks when the work warrants tracking — a pure question or quality check should not leave an issue behind.
51
-
52
- ## Talking to the user
53
- - send_chat_message for explanations, summaries, clarifying questions (channel_id "${channelId}").
54
- - provide_options when there are 2-4 clear next steps.
55
- - Be concise; report what you did and what you need.`;
56
- }
57
- function buildUserPrompt(messages) {
58
- const conversation = messages
59
- .map((m) => {
60
- const who = m.sender_agent_id
61
- ? `[Agent: ${m.sender_name || 'Unknown'}]`
62
- : `[${m.sender_name || 'User'}]`;
63
- return `${who}: ${m.content}`;
64
- })
65
- .join('\n\n');
66
- return `New message(s) in this session:\n\n${conversation}\n\nDecide what to do and act. Keep the user informed via send_chat_message.`;
24
+ import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
25
+ import { cloneSessionRepos, describeSessionRepos, } from '../../workspace/session-workspace.js';
26
+ /**
27
+ * Clone (or refresh) the session product's repositories into a local session
28
+ * directory and produce the agent's cwd + a prompt note describing the layout.
29
+ *
30
+ * One session dir holds every repo as a subdirectory; clones are reused across
31
+ * turns. Best-effort: if nothing can be cloned (no GitHub config, private repo
32
+ * the token can't reach), the agent still runs — it can answer, file issues,
33
+ * etc. — just without a checked-out codebase.
34
+ */
35
+ async function prepareSessionWorkspace(opts) {
36
+ let workspace = null;
37
+ try {
38
+ workspace = await cloneSessionRepos(opts);
39
+ }
40
+ catch (error) {
41
+ logWarning(`Could not prepare session repos: ${error instanceof Error ? error.message : String(error)}`);
42
+ }
43
+ return {
44
+ sessionDir: workspace?.sessionDir,
45
+ repoScopeNote: workspace ? describeSessionRepos(workspace.repos) : '',
46
+ };
67
47
  }
68
48
  export async function runSessionTurnCommand(options) {
69
49
  const { channelId, productId, repositoryIds = [], resumeSessionId, verbose = false, } = options;
@@ -83,40 +63,51 @@ export async function runSessionTurnCommand(options) {
83
63
  return;
84
64
  }
85
65
  logInfo(`Processing ${messages.length} message(s) for session ${channelId}`);
86
- // 2. Build the session toolbelt + prompts.
66
+ // 2. Clone the product's in-scope repositories into a local session
67
+ // directory so the agent's Read/Grep/Glob can inspect the real code.
68
+ const { sessionDir, repoScopeNote } = await prepareSessionWorkspace({
69
+ channelId,
70
+ productId,
71
+ repositoryIds,
72
+ verbose,
73
+ });
74
+ // 3. Build the session toolbelt + prompts.
87
75
  const deps = getToolDeps({
88
76
  verbose,
89
77
  context: { productId, channelId, repositoryIds },
90
78
  });
91
79
  const sessionServer = createSessionMcpServer(deps);
92
- const systemPrompt = buildSystemPrompt({
80
+ // User-configured external MCP servers (~/.edsger/mcp.json). Best-effort.
81
+ const external = loadExternalMcpServers({ warn: logWarning });
82
+ if (external.names.length > 0) {
83
+ logInfo(`External MCP servers: ${external.names.join(', ')}`);
84
+ }
85
+ const systemPrompt = buildSessionSystemPrompt({
93
86
  channelId,
94
87
  productId,
95
88
  repositoryIds,
89
+ repoScopeNote,
90
+ externalMcpNames: external.names,
96
91
  });
97
- const userPrompt = buildUserPrompt(messages);
98
- // 3. Run one SDK turn. Resume the prior SDK session when we have its id so
99
- // the conversation (and prompt cache) carries across turns.
92
+ const userPrompt = buildSessionUserPrompt(messages);
93
+ // 4. Run one SDK turn. Resume the prior SDK session when we have its id so
94
+ // the conversation (and prompt cache) carries across turns. Point the
95
+ // agent's cwd at the session directory so its read-only file tools resolve
96
+ // against the cloned repos. Prompt + tool/MCP wiring come from the shared
97
+ // session core (edsger-tools) so CLI and worker can't drift apart.
100
98
  let finalResponse = '';
101
99
  let sdkSessionId = resumeSessionId ?? '';
102
100
  try {
103
101
  for await (const message of query({
104
102
  prompt: userPrompt,
105
103
  options: {
106
- agent: 'session-agent',
107
- agents: {
108
- 'session-agent': {
109
- description: 'Conversational engineering agent for a session',
110
- prompt: systemPrompt,
111
- maxTurns: MAX_TURNS,
112
- mcpServers: ['edsger-session'],
113
- disallowedTools: ['Edit', 'Write', 'Bash', 'NotebookEdit', 'Agent'],
114
- },
115
- },
116
- mcpServers: { 'edsger-session': sessionServer },
117
- tools: ['Read', 'Grep', 'Glob'],
118
- permissionMode: 'bypassPermissions',
119
- allowDangerouslySkipPermissions: true,
104
+ ...buildSessionAgentOptions(systemPrompt, {
105
+ sessionServer,
106
+ externalServers: external.servers,
107
+ externalNames: external.names,
108
+ sessionDir,
109
+ maxTurns: SESSION_MAX_TURNS,
110
+ }),
120
111
  model: DEFAULT_MODEL,
121
112
  ...(resumeSessionId ? { resume: resumeSessionId } : {}),
122
113
  },
@@ -149,7 +140,7 @@ export async function runSessionTurnCommand(options) {
149
140
  await markProcessed(messages);
150
141
  return;
151
142
  }
152
- // 4. Post the final reply (the agent may also have posted via
143
+ // 5. Post the final reply (the agent may also have posted via
153
144
  // send_chat_message during the turn; this carries the closing summary).
154
145
  const reply = finalResponse.trim();
155
146
  if (reply) {
@@ -162,9 +153,9 @@ export async function runSessionTurnCommand(options) {
162
153
  logError(`Failed to post reply: ${e instanceof Error ? e.message : String(e)}`);
163
154
  });
164
155
  }
165
- // 5. Mark the triggering messages processed so they aren't re-run.
156
+ // 6. Mark the triggering messages processed so they aren't re-run.
166
157
  await markProcessed(messages);
167
- // 6. Emit the SDK session id so the desktop persists it for the next turn.
158
+ // 7. Emit the SDK session id so the desktop persists it for the next turn.
168
159
  emitSessionId(sdkSessionId);
169
160
  logSuccess('Session turn complete.');
170
161
  }
@@ -11,7 +11,7 @@
11
11
  import { exec as execCb } from 'child_process';
12
12
  import { promisify } from 'util';
13
13
  import { callMcpEndpoint } from '../../api/mcp-client.js';
14
- import { logError, logInfo, logSuccess, logWarning } from '../../utils/logger.js';
14
+ import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
15
15
  const exec = promisify(execCb);
16
16
  async function runAwsCmd(args, region) {
17
17
  const regionFlag = region ? ` --region ${region}` : '';
@@ -37,7 +37,10 @@ async function checkAwsCli() {
37
37
  }
38
38
  export async function runSyncAws(teamId, options = {}) {
39
39
  const { verbose, noCost } = options;
40
- const region = options.region ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? 'us-east-1';
40
+ const region = options.region ??
41
+ process.env.AWS_REGION ??
42
+ process.env.AWS_DEFAULT_REGION ??
43
+ 'us-east-1';
41
44
  logInfo(`Starting AWS sync for team ${teamId} (region: ${region})`);
42
45
  if (!(await checkAwsCli())) {
43
46
  logError('AWS CLI not found. Install it from https://aws.amazon.com/cli/ ' +
@@ -57,7 +60,7 @@ export async function runSyncAws(teamId, options = {}) {
57
60
  }
58
61
  // ── Phase 1: Resource discovery by Service tag ──
59
62
  logInfo('Phase 1: Discovering resources by Service tag...');
60
- let resources = [];
63
+ const resources = [];
61
64
  try {
62
65
  let paginationToken;
63
66
  do {
@@ -79,10 +82,12 @@ export async function runSyncAws(teamId, options = {}) {
79
82
  const byService = new Map();
80
83
  for (const res of resources) {
81
84
  const serviceTag = res.Tags.find((t) => t.Key === 'Service')?.Value;
82
- if (!serviceTag)
85
+ if (!serviceTag) {
83
86
  continue;
84
- if (!byService.has(serviceTag))
87
+ }
88
+ if (!byService.has(serviceTag)) {
85
89
  byService.set(serviceTag, []);
90
+ }
86
91
  byService.get(serviceTag).push(res);
87
92
  }
88
93
  logInfo(`Mapped to ${byService.size} distinct services`);
@@ -92,10 +97,13 @@ export async function runSyncAws(teamId, options = {}) {
92
97
  for (const [serviceName, svcResources] of byService) {
93
98
  const teamTag = svcResources[0]?.Tags.find((t) => t.Key === 'Team')?.Value;
94
99
  // Tier signals
95
- const hasMultiAz = svcResources.some((r) => r.ResourceARN.includes(':rds:') || r.ResourceARN.includes(':elasticache:'));
100
+ const hasMultiAz = svcResources.some((r) => r.ResourceARN.includes(':rds:') ||
101
+ r.ResourceARN.includes(':elasticache:'));
96
102
  const hasLambda = svcResources.some((r) => r.ResourceARN.includes(':lambda:'));
97
103
  const hasEcs = svcResources.some((r) => r.ResourceARN.includes(':ecs:'));
98
- const tier = hasMultiAz || (hasEcs && svcResources.length > 3) ? 'critical' : 'standard';
104
+ const tier = hasMultiAz || (hasEcs && svcResources.length > 3)
105
+ ? 'critical'
106
+ : 'standard';
99
107
  const kind = hasLambda && !hasEcs ? 'job' : 'service';
100
108
  if (verbose) {
101
109
  logInfo(` ${serviceName}: ${svcResources.length} resources, tier=${tier}`);
@@ -109,16 +117,36 @@ export async function runSyncAws(teamId, options = {}) {
109
117
  source: 'aws',
110
118
  source_ref: `aws:${region}:${serviceName}`,
111
119
  field_sources: {
112
- name: { value: serviceName, confidence: 1.0, source: 'aws_tag', source_detail: 'Tag:Service' },
113
- tier: { value: tier, confidence: hasMultiAz ? 0.8 : 0.5, source: 'aws_resource_analysis' },
114
- ...(teamTag ? { owner: { value: teamTag, confidence: 0.8, source: 'aws_tag', source_detail: 'Tag:Team' } } : {}),
120
+ name: {
121
+ value: serviceName,
122
+ confidence: 1.0,
123
+ source: 'aws_tag',
124
+ source_detail: 'Tag:Service',
125
+ },
126
+ tier: {
127
+ value: tier,
128
+ confidence: hasMultiAz ? 0.8 : 0.5,
129
+ source: 'aws_resource_analysis',
130
+ },
131
+ ...(teamTag
132
+ ? {
133
+ owner: {
134
+ value: teamTag,
135
+ confidence: 0.8,
136
+ source: 'aws_tag',
137
+ source_detail: 'Tag:Team',
138
+ },
139
+ }
140
+ : {}),
115
141
  },
116
142
  needs_review: false,
117
143
  }));
118
- if (result.action === 'created')
144
+ if (result.action === 'created') {
119
145
  created++;
120
- else
146
+ }
147
+ else {
121
148
  updated++;
149
+ }
122
150
  // Add resource components
123
151
  for (const res of svcResources) {
124
152
  const arnParts = res.ResourceARN.split(':');
@@ -164,11 +192,13 @@ export async function runSyncAws(teamId, options = {}) {
164
192
  for (const period of parsed.ResultsByTime) {
165
193
  for (const group of period.Groups) {
166
194
  const tagValue = group.Keys[0]?.replace('Service$', '') ?? '';
167
- if (!tagValue || tagValue === '')
195
+ if (!tagValue || tagValue === '') {
168
196
  continue;
197
+ }
169
198
  const cost = parseFloat(group.Metrics.BlendedCost.Amount);
170
- if (cost <= 0)
199
+ if (cost <= 0) {
171
200
  continue;
201
+ }
172
202
  if (verbose) {
173
203
  logInfo(` ${tagValue}: $${cost.toFixed(2)}/month`);
174
204
  }
@@ -11,40 +11,54 @@
11
11
  * 3. Upsert via MCP endpoints (services.upsert_draft, services.add_component)
12
12
  */
13
13
  import { callMcpEndpoint } from '../../api/mcp-client.js';
14
- import { logError, logInfo, logSuccess, logWarning } from '../../utils/logger.js';
14
+ import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
15
15
  function readDatadogEnv() {
16
16
  const apiKey = process.env.DD_API_KEY ?? '';
17
17
  const appKey = process.env.DD_APP_KEY ?? '';
18
18
  const site = process.env.DD_SITE ?? 'datadoghq.com';
19
19
  const missing = [];
20
- if (!apiKey)
20
+ if (!apiKey) {
21
21
  missing.push('DD_API_KEY');
22
- if (!appKey)
22
+ }
23
+ if (!appKey) {
23
24
  missing.push('DD_APP_KEY');
24
- if (missing.length > 0)
25
+ }
26
+ if (missing.length > 0) {
25
27
  return { ok: false, missing };
28
+ }
26
29
  return { ok: true, env: { apiKey, appKey, site } };
27
30
  }
28
31
  function mapTier(ddTier) {
29
- if (!ddTier)
32
+ if (!ddTier) {
30
33
  return 'standard';
34
+ }
31
35
  const lower = ddTier.toLowerCase();
32
- if (lower.includes('1') || lower.includes('critical') || lower.includes('high'))
36
+ if (lower.includes('1') ||
37
+ lower.includes('critical') ||
38
+ lower.includes('high')) {
33
39
  return 'critical';
34
- if (lower.includes('3') || lower.includes('low') || lower.includes('experiment'))
40
+ }
41
+ if (lower.includes('3') ||
42
+ lower.includes('low') ||
43
+ lower.includes('experiment')) {
35
44
  return 'experimental';
45
+ }
36
46
  return 'standard';
37
47
  }
38
48
  function mapKind(ddType) {
39
- if (!ddType)
49
+ if (!ddType) {
40
50
  return 'service';
51
+ }
41
52
  const lower = ddType.toLowerCase();
42
- if (lower === 'web' || lower === 'website' || lower === 'frontend')
53
+ if (lower === 'web' || lower === 'website' || lower === 'frontend') {
43
54
  return 'website';
44
- if (lower === 'library' || lower === 'lib')
55
+ }
56
+ if (lower === 'library' || lower === 'lib') {
45
57
  return 'library';
46
- if (lower === 'job' || lower === 'worker' || lower === 'cron')
58
+ }
59
+ if (lower === 'job' || lower === 'worker' || lower === 'cron') {
47
60
  return 'job';
61
+ }
48
62
  return 'service';
49
63
  }
50
64
  export async function runSyncDatadog(teamId, options = {}) {
@@ -97,8 +111,9 @@ export async function runSyncDatadog(teamId, options = {}) {
97
111
  skipped++;
98
112
  continue;
99
113
  }
100
- if (verbose)
114
+ if (verbose) {
101
115
  logInfo(`Processing: ${name}`);
116
+ }
102
117
  try {
103
118
  // Upsert service
104
119
  const result = (await callMcpEndpoint('services.upsert_draft', {
@@ -114,21 +129,40 @@ export async function runSyncDatadog(teamId, options = {}) {
114
129
  source_ref: `datadog:${name}`,
115
130
  field_sources: {
116
131
  name: { value: name, confidence: 1.0, source: 'datadog' },
117
- tier: { value: mapTier(schema.tier), confidence: 0.9, source: 'datadog' },
118
- language: { value: schema.languages?.[0], confidence: 1.0, source: 'datadog' },
119
- ...(schema.team ? { owner: { value: schema.team, confidence: 0.9, source: 'datadog' } } : {}),
132
+ tier: {
133
+ value: mapTier(schema.tier),
134
+ confidence: 0.9,
135
+ source: 'datadog',
136
+ },
137
+ language: {
138
+ value: schema.languages?.[0],
139
+ confidence: 1.0,
140
+ source: 'datadog',
141
+ },
142
+ ...(schema.team
143
+ ? {
144
+ owner: {
145
+ value: schema.team,
146
+ confidence: 0.9,
147
+ source: 'datadog',
148
+ },
149
+ }
150
+ : {}),
120
151
  },
121
152
  needs_review: false,
122
153
  }));
123
154
  const serviceId = result.service.id;
124
- if (result.action === 'created')
155
+ if (result.action === 'created') {
125
156
  created++;
126
- else
157
+ }
158
+ else {
127
159
  updated++;
160
+ }
128
161
  // Add repo components
129
162
  for (const repo of schema.repos ?? []) {
130
- if (!repo.url)
163
+ if (!repo.url) {
131
164
  continue;
165
+ }
132
166
  const repoName = repo.url
133
167
  .replace(/^https?:\/\/github\.com\//, '')
134
168
  .replace(/\.git$/, '');
@@ -143,14 +177,16 @@ export async function runSyncDatadog(teamId, options = {}) {
143
177
  componentCount++;
144
178
  }
145
179
  catch {
146
- if (verbose)
180
+ if (verbose) {
147
181
  logWarning(` Failed to add repo: ${repo.url}`);
182
+ }
148
183
  }
149
184
  }
150
185
  // Add link components (dashboards, runbooks, etc.)
151
186
  for (const link of schema.links ?? []) {
152
- if (!link.url)
187
+ if (!link.url) {
153
188
  continue;
189
+ }
154
190
  const kind = link.type === 'dashboard' || link.type === 'grafana'
155
191
  ? 'dashboard'
156
192
  : link.type === 'runbook' || link.type === 'doc'
@@ -169,8 +205,9 @@ export async function runSyncDatadog(teamId, options = {}) {
169
205
  componentCount++;
170
206
  }
171
207
  catch {
172
- if (verbose)
208
+ if (verbose) {
173
209
  logWarning(` Failed to add link: ${link.url}`);
210
+ }
174
211
  }
175
212
  }
176
213
  // Add Slack channel
@@ -4,8 +4,8 @@
4
4
  * Reads the team's configured github_org, fetches all repos from that org
5
5
  * via the local `gh` CLI, and upserts a repositories row for each repo.
6
6
  */
7
- import { getSupabase, hasSupabaseSession } from '../../supabase/client.js';
8
7
  import { syncOrgRepos } from '../../phases/sync-org-repos/index.js';
8
+ import { getSupabase, hasSupabaseSession } from '../../supabase/client.js';
9
9
  import { logError, logInfo, logSuccess } from '../../utils/logger.js';
10
10
  const ORG_NAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
11
11
  export async function runSyncOrgRepos(teamId, options = {}) {
@@ -10,11 +10,11 @@
10
10
  * about module structure, variable passing, resource tagging, etc.
11
11
  */
12
12
  import { execSync } from 'child_process';
13
- import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'fs';
13
+ import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'fs';
14
14
  import { tmpdir } from 'os';
15
15
  import { join, relative } from 'path';
16
16
  import { callMcpEndpoint } from '../../api/mcp-client.js';
17
- import { logError, logInfo, logSuccess, logWarning } from '../../utils/logger.js';
17
+ import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
18
18
  export async function runSyncTerraform(teamId, options = {}) {
19
19
  const { verbose } = options;
20
20
  logInfo(`Starting Terraform sync for team ${teamId}`);
@@ -57,8 +57,9 @@ export async function runSyncTerraform(teamId, options = {}) {
57
57
  function walk(dir) {
58
58
  try {
59
59
  for (const entry of readdirSync(dir)) {
60
- if (entry.startsWith('.') || entry === 'node_modules')
60
+ if (entry.startsWith('.') || entry === 'node_modules') {
61
61
  continue;
62
+ }
62
63
  const full = join(dir, entry);
63
64
  try {
64
65
  const stat = statSync(full);
@@ -100,8 +101,9 @@ export async function runSyncTerraform(teamId, options = {}) {
100
101
  moduleFiles.get(moduleDir).push({ path: relPath, content });
101
102
  }
102
103
  catch {
103
- if (verbose)
104
+ if (verbose) {
104
105
  logWarning(`Could not read ${relPath}`);
106
+ }
105
107
  }
106
108
  }
107
109
  logInfo(`Found ${moduleFiles.size} module directories`);
@@ -111,13 +113,14 @@ export async function runSyncTerraform(teamId, options = {}) {
111
113
  let components = 0;
112
114
  for (const [moduleDir, files] of moduleFiles) {
113
115
  // Skip root-level files that are just backend/provider config
114
- if (moduleDir === '.' && files.every((f) => /^(backend|provider|versions|terraform)\b/.test(f.path))) {
116
+ if (moduleDir === '.' &&
117
+ files.every((f) => /^(backend|provider|versions|terraform)\b/.test(f.path))) {
115
118
  continue;
116
119
  }
117
120
  // Extract service name from module directory
118
121
  const moduleName = moduleDir === '.'
119
- ? repoFullName?.split('/')[1] ?? 'root'
120
- : moduleDir.split('/').pop() ?? moduleDir;
122
+ ? (repoFullName?.split('/')[1] ?? 'root')
123
+ : (moduleDir.split('/').pop() ?? moduleDir);
121
124
  // Extract resource ARNs and types from .tf content
122
125
  const resources = [];
123
126
  const tags = {};
@@ -137,18 +140,22 @@ export async function runSyncTerraform(teamId, options = {}) {
137
140
  tags.Team = m[1];
138
141
  }
139
142
  // Tier signals
140
- if (/multi_az\s*=\s*true/i.test(file.content))
143
+ if (/multi_az\s*=\s*true/i.test(file.content)) {
141
144
  hasMultiAz = true;
142
- if (/aws_appautoscaling|autoscaling_group/i.test(file.content))
145
+ }
146
+ if (/aws_appautoscaling|autoscaling_group/i.test(file.content)) {
143
147
  hasAutoscaling = true;
148
+ }
144
149
  // Module references (dependencies)
145
150
  for (const m of file.content.matchAll(/module\.([a-zA-Z0-9_-]+)\./g)) {
146
- if (!moduleRefs.includes(m[1]))
151
+ if (!moduleRefs.includes(m[1])) {
147
152
  moduleRefs.push(m[1]);
153
+ }
148
154
  }
149
155
  }
150
- if (resources.length === 0)
156
+ if (resources.length === 0) {
151
157
  continue;
158
+ }
152
159
  const serviceName = tags.Service ?? moduleName;
153
160
  const tier = hasMultiAz || hasAutoscaling ? 'critical' : 'standard';
154
161
  if (verbose) {
@@ -163,16 +170,35 @@ export async function runSyncTerraform(teamId, options = {}) {
163
170
  source: 'terraform',
164
171
  source_ref: `terraform:${repoFullName ?? localDir}:${moduleDir}`,
165
172
  field_sources: {
166
- name: { value: serviceName, confidence: tags.Service ? 1.0 : 0.7, source: 'terraform', source_detail: moduleDir },
167
- tier: { value: tier, confidence: hasMultiAz ? 0.8 : 0.5, source: 'terraform' },
168
- ...(tags.Team ? { owner: { value: tags.Team, confidence: 0.8, source: 'terraform_tag' } } : {}),
173
+ name: {
174
+ value: serviceName,
175
+ confidence: tags.Service ? 1.0 : 0.7,
176
+ source: 'terraform',
177
+ source_detail: moduleDir,
178
+ },
179
+ tier: {
180
+ value: tier,
181
+ confidence: hasMultiAz ? 0.8 : 0.5,
182
+ source: 'terraform',
183
+ },
184
+ ...(tags.Team
185
+ ? {
186
+ owner: {
187
+ value: tags.Team,
188
+ confidence: 0.8,
189
+ source: 'terraform_tag',
190
+ },
191
+ }
192
+ : {}),
169
193
  },
170
194
  needs_review: !tags.Service,
171
195
  }));
172
- if (result.action === 'created')
196
+ if (result.action === 'created') {
173
197
  created++;
174
- else
198
+ }
199
+ else {
175
200
  updated++;
201
+ }
176
202
  // Add AWS resource components
177
203
  for (const res of resources) {
178
204
  try {
@@ -181,7 +207,11 @@ export async function runSyncTerraform(teamId, options = {}) {
181
207
  kind: 'aws_resource',
182
208
  provider: 'terraform',
183
209
  external_id: `${res.type}.${res.name}`,
184
- metadata: { resource_type: res.type, resource_name: res.name, module: moduleDir },
210
+ metadata: {
211
+ resource_type: res.type,
212
+ resource_name: res.name,
213
+ module: moduleDir,
214
+ },
185
215
  });
186
216
  components++;
187
217
  }
@@ -11,9 +11,10 @@ import { callMcpEndpoint } from '../../api/mcp-client.js';
11
11
  export async function buildProductChatContext(productId, channelId, verbose) {
12
12
  // Fetch product, issues, and team members in parallel
13
13
  const [productResult, issuesResult, membersResult, recentMessages] = await Promise.all([
14
- callMcpEndpoint('products/list', {
15
- product_id: productId,
16
- } // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
+ // products/list takes no params; the matching product is selected
15
+ // from the returned list below via products.find(p => p.id === productId).
16
+ callMcpEndpoint('products/list', {}
17
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
17
18
  ),
18
19
  callMcpEndpoint('issues/list', {
19
20
  product_id: productId,
@@ -1,4 +1,4 @@
1
1
  /**
2
2
  * System prompts for the product chat AI processor.
3
3
  */
4
- export declare const PRODUCT_CHAT_RESPONSE_PROMPT = "You are an AI assistant embedded in Edsger, a software development platform. You are helping a team manage a product at the product level.\n\n## Your Capabilities\nYou can see the product's current state, all issues (with statuses), and team members. You have tools to:\n- List issues with status filtering\n- Create new issues for the product\n- Get detailed issue information (drill down into any issue)\n- Create tasks for team members (human) or AI\n- Look up product team members by name\n- Send follow-up messages and present options to the user\n\n## How to Respond\n1. **Understand the intent** \u2014 Is this a question about product state, a request to create something, or coordination work?\n2. **Take action if needed** \u2014 Use the appropriate tools to make changes\n3. **Respond concisely** \u2014 Summarize what you understood and what you did\n4. **Ask for clarification** \u2014 If the message is ambiguous, use provide_options to present choices\n\n## Communication Style\n- Respond in the same language the user writes in\n- Be concise but thorough \u2014 no filler text\n- Reference specific issues, statuses, and team members by name\n- When making changes, always explain what you changed and why\n\n## Product-Level Scope\nUnlike issue chat (which focuses on a single issue's lifecycle), you operate at the product level:\n- Answer cross-issue questions (e.g., \"which issues are blocked?\", \"what's our progress?\")\n- Help with product planning and prioritization\n- Create new issues when the user describes new work\n- Coordinate team work by creating and assigning tasks\n- Provide product-wide insights and summaries\n\n## Task Creation\nWhen the user asks to notify someone, assign a review, or request action from a team member:\n1. Use list_product_members to find the person by name\n2. Use create_task with executor=\"human\", the resolved user ID, and the correct action_url\n3. Confirm in chat what you created and who it's assigned to\n\nWhen the user describes work for AI to do:\n1. Use create_task with executor=\"ai\" \u2014 the task worker will pick it up automatically\n\n### Action URL Patterns\nAlways set action_url to link to the most relevant page:\n- Product page: `/products/{product_id}`\n- Issue details: `/products/{product_id}/issues/{issue_id}`\n- Issue tab: `/products/{product_id}/issues/{issue_id}?tab={tab}`\n\n## Important Rules\n- Never make destructive changes without confirmation\n- For ambiguous requests, present options rather than guessing\n- If you can't do something, explain why clearly\n- When creating tasks for people, always confirm the person's identity if the name is ambiguous\n";
4
+ export declare const PRODUCT_CHAT_RESPONSE_PROMPT = "You are an AI assistant embedded in Edsger, a software development platform. You are helping a team manage a product at the product level.\n\n## Your Capabilities\nYou can see the product's current state, all issues (with statuses), and team members. You have tools to:\n- List issues with status filtering\n- Create new issues for the product\n- Get detailed issue information (drill down into any issue)\n- Create tasks for team members (human) or AI\n- Look up product team members by name\n- Send follow-up messages and present options to the user\n- Launch a code analysis of the product's repo (cli_find_bugs / cli_find_smells / cli_find_architecture / cli_find_features) and check a run's progress (get_cli_run)\n\n## Analyzing the code\nYou cannot read the source directly, but you can launch an audit that does. When the user asks about code quality, what to optimize / refactor / improve, bugs, dead code, performance, or architecture, do NOT just speculate from issue titles \u2014 launch the matching analysis:\n- \"what should we optimize / refactor / improve\", code smells, dead code, perf, type-safety \u2192 cli_find_smells\n- bugs / correctness audit \u2192 cli_find_bugs\n- architecture / coupling / layering / cycles \u2192 cli_find_architecture\n- feature opportunities from feedback + code \u2192 cli_find_features\n\nThese run in the background and FILE EACH FINDING AS AN ISSUE \u2014 they do not return results inline. So: launch the run, tell the user it's running and that findings will show up as new issues, and offer to check progress later with get_cli_run (or re-list issues). Don't claim to have analyzed the code yourself.\n\n## How to Respond\n1. **Understand the intent** \u2014 Is this a question about product state, a request to create something, or coordination work?\n2. **Take action if needed** \u2014 Use the appropriate tools to make changes\n3. **Respond concisely** \u2014 Summarize what you understood and what you did\n4. **Ask for clarification** \u2014 If the message is ambiguous, use provide_options to present choices\n\n## Communication Style\n- Respond in the same language the user writes in\n- Be concise but thorough \u2014 no filler text\n- Reference specific issues, statuses, and team members by name\n- When making changes, always explain what you changed and why\n\n## Product-Level Scope\nUnlike issue chat (which focuses on a single issue's lifecycle), you operate at the product level:\n- Answer cross-issue questions (e.g., \"which issues are blocked?\", \"what's our progress?\")\n- Help with product planning and prioritization\n- Create new issues when the user describes new work\n- Coordinate team work by creating and assigning tasks\n- Provide product-wide insights and summaries\n\n## Task Creation\nWhen the user asks to notify someone, assign a review, or request action from a team member:\n1. Use list_product_members to find the person by name\n2. Use create_task with executor=\"human\", the resolved user ID, and the correct action_url\n3. Confirm in chat what you created and who it's assigned to\n\nWhen the user describes work for AI to do:\n1. Use create_task with executor=\"ai\" \u2014 the task worker will pick it up automatically\n\n### Action URL Patterns\nAlways set action_url to link to the most relevant page:\n- Product page: `/products/{product_id}`\n- Issue details: `/products/{product_id}/issues/{issue_id}`\n- Issue tab: `/products/{product_id}/issues/{issue_id}?tab={tab}`\n\n## Important Rules\n- Never make destructive changes without confirmation\n- For ambiguous requests, present options rather than guessing\n- If you can't do something, explain why clearly\n- When creating tasks for people, always confirm the person's identity if the name is ambiguous\n";
@@ -11,6 +11,16 @@ You can see the product's current state, all issues (with statuses), and team me
11
11
  - Create tasks for team members (human) or AI
12
12
  - Look up product team members by name
13
13
  - Send follow-up messages and present options to the user
14
+ - Launch a code analysis of the product's repo (cli_find_bugs / cli_find_smells / cli_find_architecture / cli_find_features) and check a run's progress (get_cli_run)
15
+
16
+ ## Analyzing the code
17
+ You cannot read the source directly, but you can launch an audit that does. When the user asks about code quality, what to optimize / refactor / improve, bugs, dead code, performance, or architecture, do NOT just speculate from issue titles — launch the matching analysis:
18
+ - "what should we optimize / refactor / improve", code smells, dead code, perf, type-safety → cli_find_smells
19
+ - bugs / correctness audit → cli_find_bugs
20
+ - architecture / coupling / layering / cycles → cli_find_architecture
21
+ - feature opportunities from feedback + code → cli_find_features
22
+
23
+ These run in the background and FILE EACH FINDING AS AN ISSUE — they do not return results inline. So: launch the run, tell the user it's running and that findings will show up as new issues, and offer to check progress later with get_cli_run (or re-list issues). Don't claim to have analyzed the code yourself.
14
24
 
15
25
  ## How to Respond
16
26
  1. **Understand the intent** — Is this a question about product state, a request to create something, or coordination work?
@@ -31,6 +31,19 @@ export interface CloneFlowReposFailure {
31
31
  ok: false;
32
32
  message: string;
33
33
  }
34
+ export declare function safeDirName(fullName: string): string;
35
+ /**
36
+ * Resolve the repositories a flow targets (by id, preserving the stored
37
+ * order), falling back to the product's primary repo.
38
+ */
39
+ export declare function resolveTargetRepos(productId: string, repositoryIds: string[], fallback: {
40
+ owner: string;
41
+ repo: string;
42
+ }): Promise<{
43
+ fullName: string;
44
+ owner: string;
45
+ repo: string;
46
+ }[]>;
34
47
  export declare function cloneFlowRepos(opts: {
35
48
  productId: string;
36
49
  repositoryIds: string[];
@@ -17,14 +17,14 @@ import { getGitHubConfigByProduct } from '../../api/github.js';
17
17
  import { getSupabase } from '../../supabase/client.js';
18
18
  import { logInfo, logWarning } from '../../utils/logger.js';
19
19
  import { cloneIssueRepo, ensureWorkspaceDir, getIssueRepoPath, } from '../../workspace/workspace-manager.js';
20
- function safeDirName(fullName) {
20
+ export function safeDirName(fullName) {
21
21
  return fullName.replace(/[^a-zA-Z0-9._-]/g, '_');
22
22
  }
23
23
  /**
24
24
  * Resolve the repositories a flow targets (by id, preserving the stored
25
25
  * order), falling back to the product's primary repo.
26
26
  */
27
- async function resolveTargetRepos(productId, repositoryIds, fallback) {
27
+ export async function resolveTargetRepos(productId, repositoryIds, fallback) {
28
28
  if (repositoryIds.length === 0) {
29
29
  return [
30
30
  {
@@ -39,10 +39,7 @@ async function resolveTargetRepos(productId, repositoryIds, fallback) {
39
39
  .from('repositories')
40
40
  .select('id, full_name')
41
41
  .in('id', repositoryIds);
42
- const byId = new Map(((data) ?? []).map((r) => [
43
- r.id,
44
- r.full_name,
45
- ]));
42
+ const byId = new Map((data ?? []).map((r) => [r.id, r.full_name]));
46
43
  // Preserve the caller's order (flows.repository_ids is ordered).
47
44
  const resolved = [];
48
45
  for (const id of repositoryIds) {
@@ -104,7 +101,10 @@ export async function cloneFlowRepos(opts) {
104
101
  }
105
102
  }
106
103
  if (repos.length === 0) {
107
- return { ok: false, message: 'Failed to clone any of the selected repositories.' };
104
+ return {
105
+ ok: false,
106
+ message: 'Failed to clone any of the selected repositories.',
107
+ };
108
108
  }
109
109
  if (repos.length > 1) {
110
110
  logInfo(`Cloned ${repos.length} repos for ${workspaceKey}: ${repos.map((r) => r.fullName).join(', ')}`);
@@ -29,8 +29,9 @@ async function fetchOrgRepos(orgLogin, verbose) {
29
29
  }
30
30
  const repos = [];
31
31
  for (const line of stdout.trim().split('\n')) {
32
- if (!line)
32
+ if (!line) {
33
33
  continue;
34
+ }
34
35
  try {
35
36
  repos.push(JSON.parse(line));
36
37
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Session workspace — clones a product's configured repositories into a single
3
+ * local "session directory", one subdirectory per repo.
4
+ *
5
+ * A chat session (an `ai_assistant` channel) is bound to a product, and a
6
+ * product can span several repositories. For the session agent's read-only
7
+ * tools (Read / Grep / Glob) to inspect the real code, we clone every in-scope
8
+ * repo into:
9
+ *
10
+ * <workspace>/sessions/<channelId>/<owner>_<repo>/
11
+ *
12
+ * and point the agent's cwd at the per-session directory. The clones are reused
13
+ * (and fetched) across turns of the same session rather than re-cloned each
14
+ * time, so follow-up turns are fast and always see the latest code.
15
+ *
16
+ * Repo resolution and the secure-token clone are shared with flow generation
17
+ * (`phases/flow-shared/clone-repos.ts`).
18
+ */
19
+ import { type ClonedRepo } from '../phases/flow-shared/clone-repos.js';
20
+ export interface SessionWorkspace {
21
+ /** Directory holding every cloned repo as a subdirectory; the agent's cwd. */
22
+ sessionDir: string;
23
+ /** The repos that were successfully cloned/refreshed for this session. */
24
+ repos: ClonedRepo[];
25
+ }
26
+ /**
27
+ * Clone (or refresh) every repository the session's product is scoped to into a
28
+ * single local session directory. Returns the directory to use as the agent's
29
+ * cwd plus the cloned repos, or `null` when no repo could be made available
30
+ * (no GitHub config, or every clone failed) so the caller can still run the
31
+ * agent without a codebase.
32
+ */
33
+ export declare function cloneSessionRepos(opts: {
34
+ channelId: string;
35
+ productId: string;
36
+ repositoryIds: string[];
37
+ verbose?: boolean;
38
+ }): Promise<SessionWorkspace | null>;
39
+ /**
40
+ * Build a note for the agent's system prompt describing where each repo is
41
+ * checked out, so it points Read / Grep / Glob at the right subdirectory.
42
+ */
43
+ export declare function describeSessionRepos(repos: ClonedRepo[]): string;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Session workspace — clones a product's configured repositories into a single
3
+ * local "session directory", one subdirectory per repo.
4
+ *
5
+ * A chat session (an `ai_assistant` channel) is bound to a product, and a
6
+ * product can span several repositories. For the session agent's read-only
7
+ * tools (Read / Grep / Glob) to inspect the real code, we clone every in-scope
8
+ * repo into:
9
+ *
10
+ * <workspace>/sessions/<channelId>/<owner>_<repo>/
11
+ *
12
+ * and point the agent's cwd at the per-session directory. The clones are reused
13
+ * (and fetched) across turns of the same session rather than re-cloned each
14
+ * time, so follow-up turns are fast and always see the latest code.
15
+ *
16
+ * Repo resolution and the secure-token clone are shared with flow generation
17
+ * (`phases/flow-shared/clone-repos.ts`).
18
+ */
19
+ import { mkdirSync } from 'fs';
20
+ import { getGitHubConfigByProduct } from '../api/github.js';
21
+ import { resolveTargetRepos, safeDirName, } from '../phases/flow-shared/clone-repos.js';
22
+ import { logInfo, logWarning } from '../utils/logger.js';
23
+ import { cloneIssueRepo, ensureWorkspaceDir, getIssueRepoPath, } from './workspace-manager.js';
24
+ const SESSIONS_DIR_NAME = 'sessions';
25
+ /**
26
+ * Clone (or refresh) every repository the session's product is scoped to into a
27
+ * single local session directory. Returns the directory to use as the agent's
28
+ * cwd plus the cloned repos, or `null` when no repo could be made available
29
+ * (no GitHub config, or every clone failed) so the caller can still run the
30
+ * agent without a codebase.
31
+ */
32
+ export async function cloneSessionRepos(opts) {
33
+ const { channelId, productId, repositoryIds, verbose } = opts;
34
+ const gh = await getGitHubConfigByProduct(productId, verbose);
35
+ if (!gh.configured || !gh.token || !gh.owner || !gh.repo) {
36
+ logWarning(gh.message ||
37
+ 'No GitHub repository configured for this product; the session agent will run without a codebase.');
38
+ return null;
39
+ }
40
+ // Resolve the in-scope repos (ordered), falling back to the product's
41
+ // primary repo when none were explicitly selected.
42
+ const targets = await resolveTargetRepos(productId, repositoryIds, {
43
+ owner: gh.owner,
44
+ repo: gh.repo,
45
+ });
46
+ const workspaceRoot = ensureWorkspaceDir();
47
+ const sessionDir = getIssueRepoPath(workspaceRoot, `${SESSIONS_DIR_NAME}/${safeDirName(channelId)}`);
48
+ // Ensure the per-session parent exists before cloning into subdirectories.
49
+ mkdirSync(sessionDir, { recursive: true });
50
+ const repos = [];
51
+ for (const target of targets) {
52
+ try {
53
+ // The product-level token (installation or user PAT/OAuth) is reused for
54
+ // every repo; if it can't access one, that clone fails and we skip it
55
+ // rather than aborting the whole session.
56
+ const { repoPath } = cloneIssueRepo(sessionDir, safeDirName(target.fullName), target.owner, target.repo, gh.token);
57
+ repos.push({
58
+ fullName: target.fullName,
59
+ owner: target.owner,
60
+ repo: target.repo,
61
+ dir: repoPath,
62
+ });
63
+ }
64
+ catch (err) {
65
+ logWarning(`Skipping ${target.fullName}: clone failed (${err instanceof Error ? err.message : String(err)})`);
66
+ }
67
+ }
68
+ if (repos.length === 0) {
69
+ return null;
70
+ }
71
+ logInfo(`Session ${channelId}: ${repos.length} repo(s) ready in ${sessionDir}`);
72
+ return { sessionDir, repos };
73
+ }
74
+ /**
75
+ * Build a note for the agent's system prompt describing where each repo is
76
+ * checked out, so it points Read / Grep / Glob at the right subdirectory.
77
+ */
78
+ export function describeSessionRepos(repos) {
79
+ if (repos.length === 0) {
80
+ return '';
81
+ }
82
+ const list = repos.map((r) => `- ${r.fullName} → subdirectory \`${safeDirName(r.fullName)}/\``);
83
+ return [
84
+ repos.length === 1
85
+ ? 'The product code is checked out in your working directory:'
86
+ : `This product spans ${repos.length} repositories, each checked out as a subdirectory of your working directory:`,
87
+ ...list,
88
+ 'Use Read / Grep / Glob against these paths to inspect the actual code before answering.',
89
+ ].join('\n');
90
+ }
package/eslint.config.mjs CHANGED
@@ -5,6 +5,10 @@ import {
5
5
  } from '../edsger-lint/eslint/index.mjs'
6
6
 
7
7
  export default [
8
+ // Test-only infra (the node:test→vitest shim) lives outside the src TS
9
+ // project, so skip it for the type-checked lint.
10
+ { ignores: ['test/**'] },
11
+
8
12
  ...baseConfig,
9
13
 
10
14
  // Type-checked rules
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edsger",
3
- "version": "0.63.0",
3
+ "version": "0.64.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "edsger": "dist/index.js"
@@ -50,8 +50,8 @@
50
50
  "commander": "^12.0.0",
51
51
  "cosmiconfig": "^9.0.0",
52
52
  "dotenv": "^16.4.5",
53
- "edsger-contract": "0.6.0",
54
- "edsger-tools": "0.6.0",
53
+ "edsger-contract": "0.7.0",
54
+ "edsger-tools": "0.7.0",
55
55
  "gray-matter": "^4.0.3",
56
56
  "zod": "^4.0.0"
57
57
  },
package/vitest.config.ts CHANGED
@@ -1,26 +1,19 @@
1
+ import { fileURLToPath } from 'node:url'
2
+
1
3
  import { defineConfig } from 'vitest/config'
2
4
 
3
5
  export default defineConfig({
4
6
  test: {
5
- // Most other __tests__ folders in this package use the node:test API
6
- // (assert + node --test), not vitest. Scope vitest to the folders whose
7
- // tests are written against `vitest`; migrating the rest can happen
8
- // separately.
9
- include: [
10
- 'src/phases/run-sheet/__tests__/**/*.test.ts',
11
- 'src/phases/code-refine-verification/__tests__/**/*.test.ts',
12
- 'src/phases/find-bugs/__tests__/**/*.test.ts',
13
- 'src/phases/find-features/__tests__/**/*.test.ts',
14
- 'src/phases/find-smells/__tests__/**/*.test.ts',
15
- 'src/phases/sync-github-issues/__tests__/**/*.test.ts',
16
- 'src/phases/sync-sentry-issues/__tests__/**/*.test.ts',
17
- 'src/phases/sync-shared/__tests__/**/*.test.ts',
18
- 'src/phases/screen-flow/__tests__/**/*.test.ts',
19
- 'src/phases/recipes/__tests__/**/*.test.ts',
20
- 'src/types/__tests__/**/*.test.ts',
21
- 'src/commands/find-smells/__tests__/**/*.test.ts',
22
- ],
7
+ // Run every test under src, including the many files written against the
8
+ // node:test API those used to be silently skipped. The `node:test` alias
9
+ // below lets them run under vitest unchanged.
10
+ include: ['src/**/__tests__/**/*.test.ts'],
23
11
  exclude: ['dist/**', 'node_modules/**'],
24
12
  environment: 'node',
13
+ alias: {
14
+ 'node:test': fileURLToPath(
15
+ new URL('./test/node-test-shim.ts', import.meta.url)
16
+ ),
17
+ },
25
18
  },
26
19
  })