edsger 0.63.1 → 0.65.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.
@@ -4,12 +4,30 @@
4
4
  * its own line and free of logger formatting so it parses cleanly.
5
5
  */
6
6
  export declare const SESSION_ID_MARKER = "__EDSGER_SESSION_ID__=";
7
+ /**
8
+ * Marker emitted on stdout for each background `cli_*` run still in flight when
9
+ * the turn ends. The desktop main process parses these, watches each run to
10
+ * completion (the turn process itself exits immediately), and then re-engages
11
+ * the agent via `--run-finished` so it reports results without the user having
12
+ * to ask. The JSON payload is `{ run_id, pid, log_path, command }`.
13
+ */
14
+ export declare const CLI_RUN_MARKER = "__EDSGER_CLI_RUN__=";
7
15
  export interface SessionTurnCliOptions {
8
16
  channelId: string;
9
17
  productId: string;
10
18
  repositoryIds?: string[];
11
19
  /** SDK session id from a previous turn, to resume the same conversation. */
12
20
  resumeSessionId?: string;
21
+ /**
22
+ * Follow-up turn triggered by the desktop watcher when a background `cli_*`
23
+ * run finishes. When set, the turn is driven by a synthetic prompt instead of
24
+ * the channel's pending human messages: the agent inspects what the run
25
+ * produced and reports it to the user.
26
+ */
27
+ runFinished?: {
28
+ runId: string;
29
+ command?: string;
30
+ };
13
31
  verbose?: boolean;
14
32
  }
15
33
  export declare function runSessionTurnCommand(options: SessionTurnCliOptions): Promise<void>;
@@ -4,6 +4,14 @@
4
4
  * its own line and free of logger formatting so it parses cleanly.
5
5
  */
6
6
  export const SESSION_ID_MARKER = '__EDSGER_SESSION_ID__=';
7
+ /**
8
+ * Marker emitted on stdout for each background `cli_*` run still in flight when
9
+ * the turn ends. The desktop main process parses these, watches each run to
10
+ * completion (the turn process itself exits immediately), and then re-engages
11
+ * the agent via `--run-finished` so it reports results without the user having
12
+ * to ask. The JSON payload is `{ run_id, pid, log_path, command }`.
13
+ */
14
+ export const CLI_RUN_MARKER = '__EDSGER_CLI_RUN__=';
7
15
  /**
8
16
  * `edsger session-turn <channelId>` — run one conversational agent turn for a
9
17
  * chat session (an ai_assistant channel bound to a product).
@@ -17,106 +25,120 @@ export const SESSION_ID_MARKER = '__EDSGER_SESSION_ID__=';
17
25
  * there is no fixed pipeline.
18
26
  */
19
27
  import { query } from '@anthropic-ai/claude-agent-sdk';
20
- import { createSessionMcpServer } from 'edsger-tools';
28
+ import { buildSessionAgentOptions, buildSessionSystemPrompt, buildSessionUserPrompt, createSessionMcpServer, listActiveCliRuns, loadExternalMcpServers, SESSION_MAX_TURNS, } from 'edsger-tools';
21
29
  import { callMcpEndpoint } from '../../api/mcp-client.js';
22
30
  import { DEFAULT_MODEL } from '../../constants.js';
23
31
  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.`;
32
+ import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
33
+ import { cloneSessionRepos, describeSessionRepos, } from '../../workspace/session-workspace.js';
34
+ /**
35
+ * Clone (or refresh) the session product's repositories into a local session
36
+ * directory and produce the agent's cwd + a prompt note describing the layout.
37
+ *
38
+ * One session dir holds every repo as a subdirectory; clones are reused across
39
+ * turns. Best-effort: if nothing can be cloned (no GitHub config, private repo
40
+ * the token can't reach), the agent still runs — it can answer, file issues,
41
+ * etc. — just without a checked-out codebase.
42
+ */
43
+ async function prepareSessionWorkspace(opts) {
44
+ let workspace = null;
45
+ try {
46
+ workspace = await cloneSessionRepos(opts);
47
+ }
48
+ catch (error) {
49
+ logWarning(`Could not prepare session repos: ${error instanceof Error ? error.message : String(error)}`);
50
+ }
51
+ return {
52
+ sessionDir: workspace?.sessionDir,
53
+ repoScopeNote: workspace ? describeSessionRepos(workspace.repos) : '',
54
+ };
67
55
  }
68
56
  export async function runSessionTurnCommand(options) {
69
- const { channelId, productId, repositoryIds = [], resumeSessionId, verbose = false, } = options;
57
+ const { channelId, productId, repositoryIds = [], resumeSessionId, runFinished, verbose = false, } = options;
70
58
  // Emit the SDK session id so the desktop can persist it for the next turn.
71
59
  const emitSessionId = (id) => {
72
60
  if (id) {
73
61
  process.stdout.write(`\n${SESSION_ID_MARKER}${id}\n`);
74
62
  }
75
63
  };
76
- // 1. Load the pending human messages that triggered this turn.
77
- const pendingResult = (await callMcpEndpoint('chat/messages/pending', {
78
- channel_id: channelId,
79
- }));
80
- const messages = pendingResult.messages ?? [];
81
- if (messages.length === 0) {
82
- logInfo('No pending messages for this session — nothing to do.');
83
- return;
64
+ // Emit a marker for every background cli_* run still in flight, so the
65
+ // desktop watcher can poll each to completion and re-engage the agent.
66
+ const emitActiveCliRuns = () => {
67
+ for (const run of listActiveCliRuns()) {
68
+ const payload = JSON.stringify({
69
+ run_id: run.run_id,
70
+ pid: run.pid,
71
+ log_path: run.log_path,
72
+ command: run.command,
73
+ });
74
+ process.stdout.write(`\n${CLI_RUN_MARKER}${payload}\n`);
75
+ }
76
+ };
77
+ // 1. Decide what drives this turn. A normal turn replays the channel's
78
+ // pending human messages; a watcher-triggered follow-up replays a
79
+ // synthetic prompt about the background run that just finished.
80
+ let messages = [];
81
+ if (runFinished) {
82
+ logInfo(`Reporting completion of background run ${runFinished.runId} for session ${channelId}`);
83
+ }
84
+ else {
85
+ const pendingResult = (await callMcpEndpoint('chat/messages/pending', {
86
+ channel_id: channelId,
87
+ }));
88
+ messages = pendingResult.messages ?? [];
89
+ if (messages.length === 0) {
90
+ logInfo('No pending messages for this session — nothing to do.');
91
+ return;
92
+ }
93
+ logInfo(`Processing ${messages.length} message(s) for session ${channelId}`);
84
94
  }
85
- logInfo(`Processing ${messages.length} message(s) for session ${channelId}`);
86
- // 2. Build the session toolbelt + prompts.
95
+ // 2. Clone the product's in-scope repositories into a local session
96
+ // directory so the agent's Read/Grep/Glob can inspect the real code.
97
+ const { sessionDir, repoScopeNote } = await prepareSessionWorkspace({
98
+ channelId,
99
+ productId,
100
+ repositoryIds,
101
+ verbose,
102
+ });
103
+ // 3. Build the session toolbelt + prompts.
87
104
  const deps = getToolDeps({
88
105
  verbose,
89
106
  context: { productId, channelId, repositoryIds },
90
107
  });
91
108
  const sessionServer = createSessionMcpServer(deps);
92
- const systemPrompt = buildSystemPrompt({
109
+ // User-configured external MCP servers (~/.edsger/mcp.json). Best-effort.
110
+ const external = loadExternalMcpServers({ warn: logWarning });
111
+ if (external.names.length > 0) {
112
+ logInfo(`External MCP servers: ${external.names.join(', ')}`);
113
+ }
114
+ const systemPrompt = buildSessionSystemPrompt({
93
115
  channelId,
94
116
  productId,
95
117
  repositoryIds,
118
+ repoScopeNote,
119
+ externalMcpNames: external.names,
96
120
  });
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.
121
+ const userPrompt = runFinished
122
+ ? buildRunFinishedPrompt(runFinished)
123
+ : buildSessionUserPrompt(messages);
124
+ // 4. Run one SDK turn. Resume the prior SDK session when we have its id so
125
+ // the conversation (and prompt cache) carries across turns. Point the
126
+ // agent's cwd at the session directory so its read-only file tools resolve
127
+ // against the cloned repos. Prompt + tool/MCP wiring come from the shared
128
+ // session core (edsger-tools) so CLI and worker can't drift apart.
100
129
  let finalResponse = '';
101
130
  let sdkSessionId = resumeSessionId ?? '';
102
131
  try {
103
132
  for await (const message of query({
104
133
  prompt: userPrompt,
105
134
  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,
135
+ ...buildSessionAgentOptions(systemPrompt, {
136
+ sessionServer,
137
+ externalServers: external.servers,
138
+ externalNames: external.names,
139
+ sessionDir,
140
+ maxTurns: SESSION_MAX_TURNS,
141
+ }),
120
142
  model: DEFAULT_MODEL,
121
143
  ...(resumeSessionId ? { resume: resumeSessionId } : {}),
122
144
  },
@@ -146,10 +168,11 @@ export async function runSessionTurnCommand(options) {
146
168
  metadata: {},
147
169
  }).catch(() => undefined);
148
170
  emitSessionId(sdkSessionId);
171
+ emitActiveCliRuns();
149
172
  await markProcessed(messages);
150
173
  return;
151
174
  }
152
- // 4. Post the final reply (the agent may also have posted via
175
+ // 5. Post the final reply (the agent may also have posted via
153
176
  // send_chat_message during the turn; this carries the closing summary).
154
177
  const reply = finalResponse.trim();
155
178
  if (reply) {
@@ -162,12 +185,27 @@ export async function runSessionTurnCommand(options) {
162
185
  logError(`Failed to post reply: ${e instanceof Error ? e.message : String(e)}`);
163
186
  });
164
187
  }
165
- // 5. Mark the triggering messages processed so they aren't re-run.
188
+ // 6. Mark the triggering messages processed so they aren't re-run. (No-op for
189
+ // a watcher follow-up, which has no triggering human messages.)
166
190
  await markProcessed(messages);
167
- // 6. Emit the SDK session id so the desktop persists it for the next turn.
191
+ // 7. Emit the SDK session id so the desktop persists it for the next turn,
192
+ // plus a marker for any background run launched this turn so the watcher
193
+ // keeps following it to completion.
168
194
  emitSessionId(sdkSessionId);
195
+ emitActiveCliRuns();
169
196
  logSuccess('Session turn complete.');
170
197
  }
198
+ /**
199
+ * The synthetic user prompt for a watcher-triggered follow-up turn. Steers the
200
+ * agent to inspect what the finished run produced and report it — explicitly
201
+ * NOT to ask the user whether it should check.
202
+ */
203
+ function buildRunFinishedPrompt(runFinished) {
204
+ const which = runFinished.command
205
+ ? `the \`${runFinished.command}\` analysis (run ${runFinished.runId})`
206
+ : `a background analysis you launched earlier (run ${runFinished.runId})`;
207
+ return `${which} just finished. Inspect what it produced — call get_cli_run with run_id "${runFinished.runId}" for the log tail, then use the list_* / get_* tools (e.g. list_issues) to see the findings it filed. Summarize the results for the user via send_chat_message. Do not ask whether you should check — just report what you found. If nothing of note was produced, say so briefly.`;
208
+ }
171
209
  async function markProcessed(messages) {
172
210
  for (const m of messages) {
173
211
  await callMcpEndpoint('chat/messages/mark_processed', {
@@ -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
  }
package/dist/index.js CHANGED
@@ -441,6 +441,8 @@ program
441
441
  .requiredOption('--product <productId>', 'Product the session is bound to')
442
442
  .option('--repos <ids>', 'Comma-separated repository IDs the agent may touch', '')
443
443
  .option('--resume <sessionId>', 'SDK session id from a previous turn to resume the same conversation')
444
+ .option('--run-finished <runId>', 'Follow-up turn: report the results of a background cli_* run that finished')
445
+ .option('--run-command <command>', 'The command name of the finished run (used with --run-finished)')
444
446
  .option('-v, --verbose', 'Verbose output')
445
447
  .action(async (channelId, opts) => {
446
448
  try {
@@ -452,6 +454,9 @@ program
452
454
  .map((s) => s.trim())
453
455
  .filter(Boolean),
454
456
  resumeSessionId: opts.resume,
457
+ runFinished: opts.runFinished
458
+ ? { runId: opts.runFinished, command: opts.runCommand }
459
+ : undefined,
455
460
  verbose: opts.verbose,
456
461
  });
457
462
  }
@@ -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.1",
3
+ "version": "0.65.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.8.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
  })