chorus-cli 0.4.6 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -6,7 +6,6 @@ const _configEnv = _path.join(_configDir, '.env');
6
6
  const _localEnv = _path.join(__dirname, '.env');
7
7
  // Prefer user config dir (works when installed globally), fall back to local .env for dev
8
8
  require('dotenv').config({ path: require('fs').existsSync(_configEnv) ? _configEnv : _localEnv });
9
- const { Octokit } = require('@octokit/rest');
10
9
 
11
10
  const { createProvider } = require('./providers');
12
11
  const OpenAI = require('openai');
@@ -29,16 +28,16 @@ async function getMachineId() {
29
28
  const { stdout } = await execPromise(
30
29
  "ioreg -rd1 -c IOPlatformExpertDevice | awk -F'\"' '/IOPlatformUUID/{print $4}'"
31
30
  );
32
- if (stdout.trim()) rawId = stdout.trim();
31
+ if (stdout.trim()) {rawId = stdout.trim();}
33
32
  } else if (process.platform === 'linux') {
34
33
  const id = (await fs.readFile('/etc/machine-id', 'utf8')).trim();
35
- if (id) rawId = id;
34
+ if (id) {rawId = id;}
36
35
  } else if (process.platform === 'win32') {
37
36
  const { stdout } = await execPromise('wmic csproduct get UUID');
38
37
  const lines = stdout.trim().split('\n');
39
38
  if (lines.length > 1) {
40
39
  const uuid = lines[1].trim();
41
- if (uuid && uuid !== 'FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF') rawId = uuid;
40
+ if (uuid && uuid !== 'FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF') {rawId = uuid;}
42
41
  }
43
42
  }
44
43
  } catch { /* fall through to persistent fallback */ }
@@ -49,14 +48,13 @@ async function getMachineId() {
49
48
  const idPath = path.join(configDir, 'machine-id');
50
49
  try {
51
50
  const existing = await fs.readFile(idPath, 'utf8');
52
- if (existing.trim()) rawId = existing.trim();
51
+ if (existing.trim()) {rawId = existing.trim();}
53
52
  } catch { /* no file yet */ }
54
53
 
55
54
  if (!rawId) {
56
55
  rawId = randomUUID();
57
- const configDir2 = path.join(os.homedir(), '.config', 'chorus');
58
- await fs.mkdir(configDir2, { recursive: true });
59
- await fs.writeFile(path.join(configDir2, 'machine-id'), rawId + '\n');
56
+ await fs.mkdir(configDir, { recursive: true });
57
+ await fs.writeFile(idPath, rawId + '\n');
60
58
  }
61
59
  }
62
60
 
@@ -78,7 +76,7 @@ function runCoder(prompt) {
78
76
  cwd: process.cwd(),
79
77
  env,
80
78
  stdio: ['ignore', 'pipe', 'pipe'],
81
- timeout: 600000,
79
+ timeout: 600000
82
80
  });
83
81
 
84
82
  let stdout = '';
@@ -111,14 +109,14 @@ function runQAChat(issue, enrichedDetails, qaName, useSuper = false) {
111
109
  issue_number: issue.number,
112
110
  issue_title: issue.title,
113
111
  issue_body: issue.body || '',
114
- enriched_questions: enrichedDetails,
112
+ enriched_questions: enrichedDetails
115
113
  });
116
114
 
117
- const args = ['--messenger', CONFIG.messenger, '--qa', qaName];
115
+ const args = ['--messenger', CONFIG.messenger, '--qa', qaName?.login];
118
116
  if (CONFIG.messenger === 'teams') {
119
117
  args.push('--auth', CONFIG.teams.authPath);
120
118
  }
121
- if (useSuper) args.push('--super');
119
+ if (useSuper) {args.push('--super');}
122
120
 
123
121
  const env = { ...process.env };
124
122
  if (CONFIG.ai.chorusApiKey) {
@@ -135,7 +133,7 @@ function runQAChat(issue, enrichedDetails, qaName, useSuper = false) {
135
133
  cwd: process.cwd(),
136
134
  env,
137
135
  stdio: ['pipe', 'pipe', 'pipe'],
138
- timeout: 1800000, // 30 min
136
+ timeout: 1800000 // 30 min
139
137
  });
140
138
 
141
139
  let stdout = '';
@@ -169,13 +167,13 @@ const CONFIG = {
169
167
  github: {
170
168
  owner: process.env.GITHUB_OWNER,
171
169
  repo: process.env.GITHUB_REPO,
172
- token: process.env.GITHUB_TOKEN,
170
+ token: process.env.GITHUB_TOKEN
173
171
  },
174
172
  azuredevops: {
175
173
  org: process.env.AZDO_ORG,
176
174
  project: process.env.AZDO_PROJECT,
177
175
  repo: process.env.AZDO_REPO,
178
- pat: process.env.AZDO_PAT,
176
+ pat: process.env.AZDO_PAT
179
177
  },
180
178
  messenger: process.env.MESSENGER || 'teams',
181
179
  teams: {
@@ -183,11 +181,11 @@ const CONFIG = {
183
181
  pollInterval: 60000 // Check for response every 60 seconds
184
182
  },
185
183
  slack: {
186
- botToken: process.env.SLACK_BOT_TOKEN || '',
184
+ botToken: process.env.SLACK_BOT_TOKEN || ''
187
185
  },
188
186
  ai: {
189
- enrichmentTool: 'claude', // 'claude' or 'kimi'
190
- codingTool: 'coder', // 'coder', 'claude', or 'kimi'
187
+ enrichmentTool: 'claude', // 'claude' or 'kimi'
188
+ codingTool: 'coder', // 'coder', 'claude', or 'kimi'
191
189
  coderPath: path.join(__dirname, 'tools', 'coder.py'),
192
190
  qaPath: path.join(__dirname, 'tools', 'qa.py'),
193
191
  mapperPath: path.join(__dirname, 'tools', 'mapper.py'),
@@ -195,7 +193,7 @@ const CONFIG = {
195
193
  ? path.join(os.homedir(), '.config', 'chorus', '.venv', 'Scripts', 'python.exe')
196
194
  : path.join(os.homedir(), '.config', 'chorus', '.venv', 'bin', 'python'),
197
195
  chorusApiKey: process.env.CHORUS_API_KEY,
198
- chorusApiUrl: process.env.CHORUS_API_URL || 'https://chorus-bad0f.web.app/v1',
196
+ chorusApiUrl: process.env.CHORUS_API_URL || 'https://chorus-bad0f.web.app/v1'
199
197
  }
200
198
  };
201
199
 
@@ -237,9 +235,9 @@ Thanks!
237
235
  IMPORTANT: Output ONLY the message above. Do not include any preamble, thinking process, or explanations. Start directly with "Hi! I need clarification..."`;
238
236
 
239
237
  console.log('🤖 Enriching issue with AI...');
240
-
238
+
241
239
  const tool = CONFIG.ai.enrichmentTool;
242
-
240
+
243
241
  if (tool === 'claude') {
244
242
  // Use Chorus proxy API
245
243
  if (!CONFIG.ai.chorusApiKey) {
@@ -247,13 +245,13 @@ IMPORTANT: Output ONLY the message above. Do not include any preamble, thinking
247
245
  }
248
246
  const openaiOpts = {
249
247
  apiKey: CONFIG.ai.chorusApiKey,
250
- baseURL: CONFIG.ai.chorusApiUrl,
248
+ baseURL: CONFIG.ai.chorusApiUrl
251
249
  };
252
250
  if (CONFIG.ai.machineId) {
253
251
  openaiOpts.defaultHeaders = { 'X-Machine-Id': CONFIG.ai.machineId };
254
252
  }
255
253
  const openai = new OpenAI(openaiOpts);
256
-
254
+
257
255
  const response = await openai.chat.completions.create({
258
256
  model: 'anthropic/claude-opus-4',
259
257
  max_tokens: 2000,
@@ -270,12 +268,12 @@ IMPORTANT: Output ONLY the message above. Do not include any preamble, thinking
270
268
  }
271
269
 
272
270
  return response.choices[0].message.content.trim();
273
- } else {
274
- // Use Kimi CLI
275
- const escapedPrompt = prompt.replace(/"/g, '\\"').replace(/\$/g, '\\$');
276
- const { stdout } = await execPromise(`kimi --prompt "${escapedPrompt}"`);
277
- return stdout.trim();
278
271
  }
272
+ // Use Kimi CLI
273
+ const escapedPrompt = prompt.replace(/"/g, '\\"').replace(/\$/g, '\\$');
274
+ const { stdout } = await execPromise(`kimi --prompt "${escapedPrompt}"`);
275
+ return stdout.trim();
276
+
279
277
  }
280
278
 
281
279
  // ===== CODE GENERATION =====
@@ -397,10 +395,10 @@ async function getCodeRabbitReview(solution, issue, provider) {
397
395
  );
398
396
 
399
397
  // 5. Clean up — undo the commit so changes stay in working tree, then go back to main
400
- await execPromise(`git reset --mixed HEAD~1`); // undo commit, keep changes in working tree
401
- await execPromise(`git stash`); // stash working tree changes
398
+ await execPromise(`git reset --mixed HEAD~1`); // undo commit, keep changes in working tree
399
+ await execPromise(`git stash`); // stash working tree changes
402
400
  await execPromise(`git checkout main`);
403
- await execPromise(`git stash pop`); // restore coder's changes on main
401
+ await execPromise(`git stash pop`); // restore coder's changes on main
404
402
  await execPromise(`git branch -D ${branchName}`).catch(() => {});
405
403
 
406
404
  return {
@@ -490,7 +488,7 @@ async function lintAndFix(solution, provider) {
490
488
  console.log(' Auto-fix applied');
491
489
  } catch (e) {
492
490
  // eslint exits non-zero when there are unfixable errors — that's expected
493
- if (e.stdout) console.log(e.stdout);
491
+ if (e.stdout) {console.log(e.stdout);}
494
492
  }
495
493
 
496
494
  // Second pass: check for remaining errors
@@ -526,7 +524,7 @@ ${lintOutput.slice(0, 5000)}`;
526
524
 
527
525
  // Update solution with any new files coder touched
528
526
  for (const f of (fixResult.files_modified || [])) {
529
- if (!solution.files_modified.includes(f)) solution.files_modified.push(f);
527
+ if (!solution.files_modified.includes(f)) {solution.files_modified.push(f);}
530
528
  }
531
529
 
532
530
  console.log(' Lint fix pass complete\n');
@@ -581,17 +579,17 @@ async function processTicket(issueArg, { useSuper = false, skipQA = false, qaNam
581
579
  efs(CONFIG.ai.venvPython, ['-m', 'pip', 'install', '-r', reqFile], { stdio: 'inherit' });
582
580
  }
583
581
 
584
- // 0a. Verify no modified tracked files (untracked files like .coder/ are fine)
582
+ // 0a. Verify no modified tracked files (untracked files like .chorus/ are fine)
585
583
  const { stdout: gitStatus } = await execPromise('git status --porcelain --untracked-files=no');
586
584
  if (gitStatus.trim()) {
587
585
  console.error('⚠️ Working directory has uncommitted changes. Commit or stash first:');
588
586
  console.error(gitStatus);
589
- //throw new Error('Dirty working directory');
587
+ // throw new Error('Dirty working directory');
590
588
  }
591
589
  const { stdout: currentBranch } = await execPromise('git rev-parse --abbrev-ref HEAD');
592
590
  if (currentBranch.trim() !== 'main') {
593
591
  console.warn(`⚠️ Not on main branch (currently on ${currentBranch.trim()}). Switch to main first.`);
594
- //throw new Error('Not on main branch');
592
+ // throw new Error('Not on main branch');
595
593
  }
596
594
 
597
595
  // 0b. Map the codebase so coder starts with full context
@@ -630,6 +628,7 @@ async function processTicket(issueArg, { useSuper = false, skipQA = false, qaNam
630
628
  console.log('⏭️ Skipping QA conversation (--skip-qa)\n');
631
629
  } else {
632
630
  const qaName = qaNameOverride || await provider.getUserDisplayName(issue.user.login);
631
+ console.log(JSON.stringify(qaName));
633
632
  console.log(`💬 Starting QA conversation with ${qaName?.login}...`);
634
633
  const qaResult = await runQAChat(issue, enrichedDetails, qaName, useSuper);
635
634
  qaResponse = qaResult.requirements;
@@ -643,8 +642,17 @@ async function processTicket(issueArg, { useSuper = false, skipQA = false, qaNam
643
642
  }
644
643
  console.log(`QA complete (${qaResult.conversation_rounds} rounds)\n`);
645
644
  }
646
-
645
+
647
646
  // 4. Generate code (with retry if coder doesn't actually write files)
647
+ // Snapshot current working-tree state so we can detect what the coder *actually* changed
648
+ let baselineDirty = new Set();
649
+ try {
650
+ const { stdout: bDiff } = await execPromise('git diff --name-only');
651
+ const { stdout: bNew } = await execPromise('git ls-files --others --exclude-standard');
652
+ bDiff.trim().split('\n').filter(Boolean).forEach(f => baselineDirty.add(f));
653
+ bNew.trim().split('\n').filter(Boolean).forEach(f => baselineDirty.add(f));
654
+ } catch { /* non-git or error — baseline stays empty */ }
655
+
648
656
  const maxCodeAttempts = 3;
649
657
  let solution;
650
658
 
@@ -684,7 +692,27 @@ CRITICAL: You MUST write code to actual files. Do not just describe changes —
684
692
  throw new Error('Code generation failed');
685
693
  }
686
694
 
687
- const filesChanged = (solution.files_modified || []).length + (solution.files_created || []).length;
695
+ let filesChanged = (solution.files_modified || []).length + (solution.files_created || []).length;
696
+
697
+ // If coder reports 0 files, check git for actual working-tree changes
698
+ // the coder may have written files without reporting them
699
+ if (filesChanged === 0) {
700
+ try {
701
+ const { stdout: gitDiff } = await execPromise('git diff --name-only');
702
+ const { stdout: gitUntracked } = await execPromise('git ls-files --others --exclude-standard');
703
+ const diffFiles = gitDiff.trim().split('\n').filter(Boolean).filter(f => !baselineDirty.has(f));
704
+ const untrackedFiles = gitUntracked.trim().split('\n').filter(Boolean).filter(f => !baselineDirty.has(f));
705
+ const gitChangedFiles = [...diffFiles, ...untrackedFiles];
706
+
707
+ if (gitChangedFiles.length > 0) {
708
+ console.log(` Coder didn't report files, but git detected ${gitChangedFiles.length} changed file(s)`);
709
+ // Backfill the solution so downstream steps (lint, review, PR) work
710
+ solution.files_modified = [...(solution.files_modified || []), ...diffFiles];
711
+ solution.files_created = [...(solution.files_created || []), ...untrackedFiles];
712
+ filesChanged = gitChangedFiles.length;
713
+ }
714
+ } catch { /* git check failed, fall through to existing logic */ }
715
+ }
688
716
 
689
717
  if (filesChanged > 0) {
690
718
  break; // Success — coder actually wrote files
@@ -699,7 +727,7 @@ CRITICAL: You MUST write code to actual files. Do not just describe changes —
699
727
  }
700
728
 
701
729
  console.log('Code generation complete\n');
702
- if (solution.summary) console.log(`Summary: ${solution.summary}\n`);
730
+ if (solution.summary) {console.log(`Summary: ${solution.summary}\n`);}
703
731
 
704
732
  // 5. Lint files the coder touched
705
733
  await lintAndFix(solution, provider);
@@ -755,11 +783,11 @@ CRITICAL: You MUST write code to actual files. Do not just describe changes —
755
783
  while (userIteration < maxUserIterations) {
756
784
  const allFiles = [...new Set([
757
785
  ...(solution.files_modified || []),
758
- ...(solution.files_created || []),
786
+ ...(solution.files_created || [])
759
787
  ])];
760
788
  console.log('FILES CHANGED:');
761
789
  allFiles.forEach(f => console.log(` ${f}`));
762
- if (solution.summary) console.log(`\nSUMMARY: ${solution.summary}`);
790
+ if (solution.summary) {console.log(`\nSUMMARY: ${solution.summary}`);}
763
791
  console.log();
764
792
 
765
793
  const answer = await prompt(rl, '✅ Happy with the changes? (y/n): ');
@@ -857,7 +885,7 @@ function prompt(rl, question, hidden = false) {
857
885
  } else if (ch === '\u0003') {
858
886
  process.exit();
859
887
  } else if (ch === '\u007F' || ch === '\b') {
860
- if (input.length > 0) input = input.slice(0, -1);
888
+ if (input.length > 0) {input = input.slice(0, -1);}
861
889
  } else {
862
890
  input += ch;
863
891
  }
@@ -889,7 +917,7 @@ async function setupGitHub() {
889
917
  } catch { /* no .env yet */ }
890
918
 
891
919
  const updates = { GITHUB_OWNER: owner, GITHUB_REPO: repo };
892
- if (token) updates.GITHUB_TOKEN = token;
920
+ if (token) {updates.GITHUB_TOKEN = token;}
893
921
 
894
922
  for (const [key, value] of Object.entries(updates)) {
895
923
  const regex = new RegExp(`^${key}=.*$`, 'm');
@@ -905,9 +933,9 @@ async function setupGitHub() {
905
933
  // Update in-memory config
906
934
  CONFIG.github.owner = owner;
907
935
  CONFIG.github.repo = repo;
908
- if (token) CONFIG.github.token = token;
936
+ if (token) {CONFIG.github.token = token;}
909
937
 
910
- console.log(`\n✅ GitHub config saved to ${envPath} (${owner}/${repo})\n`);
938
+ console.log(`\n✅ GitHub config saved (${owner}/${repo})\n`);
911
939
  }
912
940
 
913
941
  async function setupProxyAuth() {
@@ -941,7 +969,7 @@ async function setupProxyAuth() {
941
969
  const mod = parsed.protocol === 'https:' ? https : http;
942
970
  const req = mod.request(parsed, { method: 'POST', headers: { 'Content-Type': 'application/json' } }, (res) => {
943
971
  let data = '';
944
- res.on('data', (chunk) => data += chunk);
972
+ res.on('data', (chunk) => { data += chunk; });
945
973
  res.on('end', () => {
946
974
  try {
947
975
  resolve({ status: res.statusCode, body: JSON.parse(data) });
@@ -1003,7 +1031,7 @@ async function setupProxyAuth() {
1003
1031
  CONFIG.ai.chorusApiKey = apiKey;
1004
1032
  process.env.CHORUS_API_KEY = apiKey;
1005
1033
 
1006
- console.log(`\n✅ Chorus API key saved to ${envPath}\n`);
1034
+ console.log(`\n✅ Chorus API key saved\n`);
1007
1035
  }
1008
1036
 
1009
1037
  async function setupTeamsAuth() {
@@ -1028,7 +1056,7 @@ async function setupTeamsAuth() {
1028
1056
  await context.storageState({ path: CONFIG.teams.authPath });
1029
1057
  await browser.close();
1030
1058
 
1031
- console.log(`\n✅ Authentication state saved to ${CONFIG.teams.authPath}`);
1059
+ console.log(`\n✅ Teams authentication saved`);
1032
1060
  }
1033
1061
 
1034
1062
  async function setupSlack() {
@@ -1076,7 +1104,7 @@ async function setupSlack() {
1076
1104
  CONFIG.messenger = 'slack';
1077
1105
  CONFIG.slack.botToken = token;
1078
1106
 
1079
- console.log(`\n✅ Slack config saved to ${envPath}\n`);
1107
+ console.log(`\n✅ Slack config saved\n`);
1080
1108
  }
1081
1109
 
1082
1110
  async function setupAzureDevOps() {
@@ -1100,7 +1128,7 @@ async function setupAzureDevOps() {
1100
1128
  } catch { /* no .env yet */ }
1101
1129
 
1102
1130
  const updates = { AZDO_ORG: adoOrg, AZDO_PROJECT: adoProject, AZDO_REPO: adoRepo, PROVIDER: 'azuredevops' };
1103
- if (adoPat) updates.AZDO_PAT = adoPat;
1131
+ if (adoPat) {updates.AZDO_PAT = adoPat;}
1104
1132
 
1105
1133
  for (const [key, value] of Object.entries(updates)) {
1106
1134
  const regex = new RegExp(`^${key}=.*$`, 'm');
@@ -1116,9 +1144,9 @@ async function setupAzureDevOps() {
1116
1144
  CONFIG.azuredevops.org = adoOrg;
1117
1145
  CONFIG.azuredevops.project = adoProject;
1118
1146
  CONFIG.azuredevops.repo = adoRepo;
1119
- if (adoPat) CONFIG.azuredevops.pat = adoPat;
1147
+ if (adoPat) {CONFIG.azuredevops.pat = adoPat;}
1120
1148
 
1121
- console.log(`\n✅ Azure DevOps config saved to ${envPath} (${adoOrg}/${adoProject}/${adoRepo})\n`);
1149
+ console.log(`\n✅ Azure DevOps config saved (${adoOrg}/${adoProject}/${adoRepo})\n`);
1122
1150
  }
1123
1151
 
1124
1152
  async function setup() {
@@ -1155,6 +1183,70 @@ async function setup() {
1155
1183
  console.log('\n✅ Setup complete. You can now run: chorus run\n');
1156
1184
  }
1157
1185
 
1186
+ // === EASTER EGG ====
1187
+
1188
+ function printZEP() {
1189
+ const w = 'zep';
1190
+ const size = 9;
1191
+ const thick = 2;
1192
+ const blank = ' '.repeat(w.length);
1193
+
1194
+ // Colors
1195
+ const red = '\x1b[31m';
1196
+ const yellow = '\x1b[33m';
1197
+ const cyan = '\x1b[36m';
1198
+ const bold = '\x1b[1m';
1199
+ const reset = '\x1b[0m';
1200
+
1201
+ // Z
1202
+ const Z = [];
1203
+ for (let i = 0; i < size; i++) {
1204
+ if (i < thick || i >= size - thick) {
1205
+ Z.push(w.repeat(size));
1206
+ } else {
1207
+ const pos = size - 1 - i;
1208
+ Z.push(blank.repeat(pos) + w.repeat(thick) + blank.repeat(Math.max(0, size - pos - thick)));
1209
+ }
1210
+ }
1211
+
1212
+ // E
1213
+ const E = [];
1214
+ for (let i = 0; i < size; i++) {
1215
+ if (i < thick || i >= size - thick || (i >= Math.floor(size / 2) - Math.floor(thick / 2) && i < Math.floor(size / 2) + thick - Math.floor(thick / 2))) {
1216
+ E.push(w.repeat(size));
1217
+ } else {
1218
+ E.push(w.repeat(thick) + blank.repeat(size - thick));
1219
+ }
1220
+ }
1221
+
1222
+ // P
1223
+ const P = [];
1224
+ for (let i = 0; i < size; i++) {
1225
+ if (i < thick || (i >= Math.floor(size / 2) - Math.floor(thick / 2) && i < Math.floor(size / 2) + thick - Math.floor(thick / 2))) {
1226
+ P.push(w.repeat(size));
1227
+ } else if (i < Math.floor(size / 2)) {
1228
+ P.push(w.repeat(thick) + blank.repeat(size - thick * 2) + w.repeat(thick));
1229
+ } else {
1230
+ P.push(w.repeat(thick) + blank.repeat(size - thick));
1231
+ }
1232
+ }
1233
+
1234
+ const gap = ' ';
1235
+
1236
+ console.log('\n\n');
1237
+ for (let i = 0; i < size; i++) {
1238
+ console.log(
1239
+ bold + red + Z[i] + reset +
1240
+ gap +
1241
+ bold + yellow + E[i] + reset +
1242
+ gap +
1243
+ bold + cyan + P[i] + reset
1244
+ );
1245
+ }
1246
+ console.log('\n\n');
1247
+ }
1248
+
1249
+
1158
1250
  // ===== CLI =====
1159
1251
  const command = process.argv[2];
1160
1252
  const _envExists = require('fs').existsSync(path.join(os.homedir(), '.config', 'chorus', '.env'));
@@ -1191,6 +1283,8 @@ if (command === 'setup') {
1191
1283
  const { issueArg, ...opts } = parseRunArgs();
1192
1284
  processTicket(issueArg, opts).catch(console.error);
1193
1285
  }
1286
+ } else if (command === 'zep') {
1287
+ printZEP()
1194
1288
  } else {
1195
1289
  if (!_envExists) {
1196
1290
  console.log(`
@@ -1211,12 +1305,12 @@ Usage:
1211
1305
  chorus run 4464 - Process specific issue by number
1212
1306
  chorus run <url> - Process issue from full URL (auto-detects provider)
1213
1307
  chorus run 4464 --super - Use Opus 4.6 for QA evaluation
1214
- chorus run 4464 --qa 'John Doe' - Specify QA contact name for chat
1308
+ chorus run 4464 --qa 'John Doe' - Specify QA contact name for chat
1215
1309
  chorus run 4464 --skip-qa - Skip QA conversation, go straight to coding
1216
1310
 
1217
1311
  GitHub examples:
1218
1312
  chorus run 4464
1219
- chorus run https://github.com/shesha-io/shesha-framework/issues/4464
1313
+ chorus run https://github.com/org/repository/issues/4464
1220
1314
 
1221
1315
  Azure DevOps examples:
1222
1316
  chorus run 456
@@ -1239,4 +1333,4 @@ Configuration is stored in ~/.config/chorus/.env
1239
1333
  Run "chorus setup" to configure credentials and messenger auth.
1240
1334
  `);
1241
1335
  }
1242
- }
1336
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chorus-cli",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "Automated ticket resolution with AI, Teams, and Slack integration",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -15,7 +15,9 @@
15
15
  "scripts": {
16
16
  "postinstall": "node scripts/postinstall.js",
17
17
  "setup": "node index.js setup",
18
- "start": "node index.js run"
18
+ "start": "node index.js run",
19
+ "lint": "eslint .",
20
+ "lint:fix": "eslint . --fix"
19
21
  },
20
22
  "dependencies": {
21
23
  "@octokit/rest": "^20.0.2",
@@ -23,6 +25,9 @@
23
25
  "openai": "^4.0.0",
24
26
  "playwright": "^1.40.0"
25
27
  },
28
+ "devDependencies": {
29
+ "eslint": "^8.56.0"
30
+ },
26
31
  "engines": {
27
32
  "node": ">=18.0.0"
28
33
  }
@@ -22,8 +22,8 @@ function normalize(workItem) {
22
22
  user: {
23
23
  login: fields['System.CreatedBy']?.displayName
24
24
  || fields['System.CreatedBy']?.uniqueName
25
- || 'unknown',
26
- },
25
+ || 'unknown'
26
+ }
27
27
  };
28
28
  }
29
29
 
@@ -37,14 +37,14 @@ function createAzureDevOpsProvider(config) {
37
37
  const encoded = Buffer.from(`:${pat}`).toString('base64');
38
38
  return {
39
39
  Authorization: `Basic ${encoded}`,
40
- 'Content-Type': 'application/json',
40
+ 'Content-Type': 'application/json'
41
41
  };
42
42
  }
43
43
 
44
44
  async function adoFetch(url, options = {}) {
45
45
  const res = await fetch(url, {
46
46
  ...options,
47
- headers: { ...authHeaders(), ...options.headers },
47
+ headers: { ...authHeaders(), ...options.headers }
48
48
  });
49
49
  if (!res.ok) {
50
50
  const text = await res.text();
@@ -59,7 +59,7 @@ function createAzureDevOpsProvider(config) {
59
59
  WHERE [System.AssignedTo] = @me
60
60
  AND [System.State] <> 'Closed'
61
61
  AND [System.State] <> 'Removed'
62
- ORDER BY [System.CreatedDate] DESC`,
62
+ ORDER BY [System.CreatedDate] DESC`
63
63
  };
64
64
 
65
65
  const result = await adoFetch(
@@ -88,7 +88,7 @@ function createAzureDevOpsProvider(config) {
88
88
  }
89
89
 
90
90
  function parseIssueArg(arg) {
91
- if (!arg) return null;
91
+ if (!arg) {return null;}
92
92
 
93
93
  // Full ADO URL: https://dev.azure.com/{org}/{project}/_workitems/edit/{id}
94
94
  const urlMatch = arg.match(
@@ -98,7 +98,7 @@ function createAzureDevOpsProvider(config) {
98
98
  return {
99
99
  org: urlMatch[1],
100
100
  project: urlMatch[2],
101
- number: parseInt(urlMatch[3], 10),
101
+ number: parseInt(urlMatch[3], 10)
102
102
  };
103
103
  }
104
104
 
@@ -142,7 +142,7 @@ function createAzureDevOpsProvider(config) {
142
142
  const cleanPath = filepath.trim();
143
143
  if (cleanPath.includes('/') || cleanPath.includes('.')) {
144
144
  const dir = cleanPath.substring(0, cleanPath.lastIndexOf('/'));
145
- if (dir) await execPromise(`mkdir -p ${dir}`).catch(() => {});
145
+ if (dir) {await execPromise(`mkdir -p ${dir}`).catch(() => {});}
146
146
  await fs.writeFile(cleanPath, content.trim());
147
147
  }
148
148
  }
@@ -176,7 +176,7 @@ function createAzureDevOpsProvider(config) {
176
176
  sourceRefName: `refs/heads/${branchName}`,
177
177
  targetRefName: 'refs/heads/main',
178
178
  title: `Fix: ${issue.title}`,
179
- description: `Resolves AB#${issue.number}\n\n## Summary\n${summary}${filesChanged ? `\n\n## Files Changed\n${filesChanged}` : ''}`,
179
+ description: `Resolves AB#${issue.number}\n\n## Summary\n${summary}${filesChanged ? `\n\n## Files Changed\n${filesChanged}` : ''}`
180
180
  };
181
181
 
182
182
  const prResult = await adoFetch(
@@ -195,7 +195,7 @@ function createAzureDevOpsProvider(config) {
195
195
  parseIssueArg,
196
196
  createPR,
197
197
  getSolutionFiles,
198
- gitAddSolutionFiles,
198
+ gitAddSolutionFiles
199
199
  };
200
200
  }
201
201
 
@@ -22,7 +22,7 @@ function createGitHubProvider(config) {
22
22
  assignee: 'james-baloyi',
23
23
  sort: 'created',
24
24
  direction: 'desc',
25
- per_page: 10,
25
+ per_page: 10
26
26
  });
27
27
 
28
28
  if (issues.length === 0) {
@@ -37,7 +37,7 @@ function createGitHubProvider(config) {
37
37
  const { data: issue } = await octokit.issues.get({
38
38
  owner: issueOwner || owner,
39
39
  repo: issueRepo || repo,
40
- issue_number: number,
40
+ issue_number: number
41
41
  });
42
42
  return issue;
43
43
  }
@@ -49,7 +49,7 @@ function createGitHubProvider(config) {
49
49
  }
50
50
 
51
51
  function parseIssueArg(arg) {
52
- if (!arg) return null;
52
+ if (!arg) {return null;}
53
53
 
54
54
  // Full GitHub URL: https://github.com/owner/repo/issues/123
55
55
  const urlMatch = arg.match(/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/);
@@ -86,7 +86,19 @@ function createGitHubProvider(config) {
86
86
  console.log('🌿 Creating PR...');
87
87
 
88
88
  if (!isCoder) {
89
- await writeSolutionToFiles(solution._raw || solution);
89
+ // Legacy: parse code blocks and write files
90
+ const fileRegex = /```(.+?)\n([\s\S]+?)```/g;
91
+ const raw = solution._raw || solution;
92
+ let match;
93
+ while ((match = fileRegex.exec(raw)) !== null) {
94
+ const [, filepath, content] = match;
95
+ const cleanPath = filepath.trim();
96
+ if (cleanPath.includes('/') || cleanPath.includes('.')) {
97
+ const dir = cleanPath.substring(0, cleanPath.lastIndexOf('/'));
98
+ if (dir) {await execPromise(`mkdir -p ${dir}`).catch(() => {});}
99
+ await fs.writeFile(cleanPath, content.trim());
100
+ }
101
+ }
90
102
  }
91
103
 
92
104
  // Clean up stale branch from previous runs
@@ -137,7 +149,7 @@ ${filesChanged ? `\n## Files Changed\n${filesChanged}` : ''}`;
137
149
  parseIssueArg,
138
150
  createPR,
139
151
  getSolutionFiles,
140
- gitAddSolutionFiles,
152
+ gitAddSolutionFiles
141
153
  };
142
154
  }
143
155
 
@@ -8,9 +8,9 @@ const { createAzureDevOpsProvider } = require('./azuredevops');
8
8
  * Returns 'github', 'azuredevops', or null if undetectable.
9
9
  */
10
10
  function detectProviderFromArg(issueArg) {
11
- if (!issueArg || typeof issueArg !== 'string') return null;
12
- if (issueArg.includes('github.com')) return 'github';
13
- if (issueArg.includes('dev.azure.com')) return 'azuredevops';
11
+ if (!issueArg || typeof issueArg !== 'string') {return null;}
12
+ if (issueArg.includes('github.com')) {return 'github';}
13
+ if (issueArg.includes('dev.azure.com')) {return 'azuredevops';}
14
14
  return null;
15
15
  }
16
16
 
@@ -35,7 +35,7 @@ function createProvider(config, issueArg) {
35
35
  }
36
36
  return createAzureDevOpsProvider({
37
37
  ...config.azuredevops,
38
- codingTool: config.ai.codingTool,
38
+ codingTool: config.ai.codingTool
39
39
  });
40
40
  }
41
41
 
@@ -44,7 +44,7 @@ function createProvider(config, issueArg) {
44
44
  owner: config.github.owner,
45
45
  repo: config.github.repo,
46
46
  token: config.github.token,
47
- codingTool: config.ai.codingTool,
47
+ codingTool: config.ai.codingTool
48
48
  });
49
49
  }
50
50
 
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- const { execFileSync, execSync } = require('child_process');
4
+ const { execFileSync } = require('child_process');
5
5
  const path = require('path');
6
6
  const fs = require('fs');
7
7
 
@@ -25,7 +25,7 @@ function run(cmd, args, opts) {
25
25
  return execFileSync(cmd, args, { stdio: 'inherit', ...opts });
26
26
  } catch (e) {
27
27
  console.error(` Command failed: ${cmd} ${args.join(' ')}`);
28
- if (e.message) console.error(` ${e.message}`);
28
+ if (e.message) {console.error(` ${e.message}`);}
29
29
  return null;
30
30
  }
31
31
  }
@@ -35,7 +35,7 @@ function findPython() {
35
35
  try {
36
36
  const version = execFileSync(candidate, ['--version'], {
37
37
  encoding: 'utf8',
38
- stdio: ['ignore', 'pipe', 'pipe'],
38
+ stdio: ['ignore', 'pipe', 'pipe']
39
39
  }).trim(); // e.g. "Python 3.11.4"
40
40
 
41
41
  const match = version.match(/Python (\d+)\.(\d+)/);
package/tools/coder.py CHANGED
@@ -50,183 +50,8 @@ def is_token_limit_error(err):
50
50
  return "token limit exceeded" in msg or "rate_limit_error" in msg
51
51
 
52
52
 
53
- # ── Shared formatting rules (included in all prompts) ─────────────────────
54
- _FORMAT_RULES = """\
55
- OUTPUT FORMAT
56
-
57
- Your output is displayed raw in a terminal. Never use markdown.
58
- No headings with #, no **bold**, no *italic*, no `backticks`, no [links](url),
59
- no bullet characters like - or *.
60
- Use blank lines and indentation for structure. Use CAPS for section labels.
61
- Use plain numbered lists (1. 2. 3.) when listing things.
62
- Refer to code identifiers by name directly (e.g. myFunction not `myFunction`).
63
- No greetings, preambles, encouragement, or sign-offs.
64
- No "Great question!", "Let me", "Sure!", "I'll now", or similar filler.
65
- State what you are doing, then do it. After completing work, state what changed."""
66
-
67
- # ── Phase 1: Planning prompt (used once at the start of headless mode) ────
68
- PLAN_PROMPT = """\
69
- You are a coding agent. You receive a GitHub/Azure DevOps issue, a codebase map, \
70
- and optionally a QA conversation with clarified requirements. Your job is to \
71
- plan the implementation.
72
-
73
- Working directory: {cwd}
74
-
75
- """ + _FORMAT_RULES + """
76
-
77
-
78
- TASK
79
-
80
- Read the issue, QA conversation (if provided), and codebase map. Then produce a
81
- written plan with exactly these sections:
82
-
83
- UNDERSTANDING:
84
- What the issue is asking for in one sentence.
85
-
86
- QUESTIONS STILL OPEN:
87
- Anything unresolved from QA. If --skip-qa was used, list assumptions you are making
88
- and flag them clearly. Prefer the conservative/conventional choice for each.
89
-
90
- FILES TO READ:
91
- The minimum set of existing files you need to examine to understand the patterns,
92
- interfaces, and conventions. Maximum 8 files. Justify each.
93
-
94
- FILES TO CREATE:
95
- New files with their paths and a one-line description of contents.
96
-
97
- FILES TO MODIFY:
98
- Existing files with a one-line description of what changes.
99
-
100
- APPROACH:
101
- How you will implement this in 2-4 sentences. Reference specific patterns
102
- from the codebase map.
103
-
104
- ON AMBIGUITY
105
-
106
- When requirements are unclear and no QA conversation was provided:
107
- 1. State your assumption explicitly.
108
- 2. Choose the most conventional/standard approach.
109
- 3. Flag it so the reviewer can catch disagreements early.
110
- Do not guess ambitiously. Guess conservatively. A correct simple implementation beats
111
- a broken ambitious one.
112
- """
113
-
114
- # ── Phase 2: Execution prompt (used on every turn of the tool loop) ───────
115
- EXEC_PROMPT = """\
116
- You are a coding agent executing a plan. Implement the changes described in the
117
- plan below. Do not re-plan or re-analyze. Execute.
118
-
119
- Working directory: {cwd}
120
-
121
- """ + _FORMAT_RULES + """
122
-
123
-
124
- TOOL USAGE
125
-
126
- Always use read_file before editing a file.
127
- If edit_file fails with "old_string not found", re-read the file with read_file to
128
- get the actual current content before retrying. Never guess at file contents.
129
- Use edit_file for targeted changes. Use write_file for new files or complete rewrites.
130
- Prefer editing existing files over creating new ones.
131
- Use read_file, list_files, and search_files instead of bash with cat, find, or grep.
132
-
133
- EXECUTION RULES
134
-
135
- Reading files:
136
- Only use read_file on files listed in the plan. If you discover you need another
137
- file, note why -- but if you have read more than 12 files total, stop and
138
- re-evaluate. You are exploring, not implementing.
139
- The one exception to reading a file again: if edit_file fails because old_string
140
- was not found, re-read that file to get its current content before retrying.
141
-
142
- Writing code:
143
- Follow existing patterns exactly. Match the conventions you observed for: naming,
144
- exports, file structure, import ordering, indentation, and comment style.
145
- Write complete files. Do not leave TODOs, placeholders, or "implement this" comments.
146
- If the issue asks for a tool/script, it must work non-interactively (accept arguments
147
- or config, not interactive prompts) unless the issue explicitly requires interactivity.
148
-
149
- Shell commands (bash tool):
150
- You may run bash for: installing dependencies, running the project's existing
151
- linter, running the project's existing tests, checking TypeScript compilation.
152
- You may not run bash for: exploratory searching beyond what was in the plan,
153
- reading files you did not plan to read, testing scripts with improvised piped input.
154
- Maximum 10 bash commands total. If you are approaching this limit, you are doing
155
- too much exploration or too much debugging. Ship what you have.
156
-
157
- BUDGET DISCIPLINE
158
-
159
- Front-load the high-value work. Write the actual implementation code early. File
160
- exploration is not progress -- committed code is progress.
161
-
162
- Do not retry the same failing approach. If something fails twice, choose a different
163
- approach or simplify. Do not iterate more than twice on the same problem.
164
-
165
- If something fundamental is broken, stop. Produce what you have, note what is
166
- incomplete, and let the human finish. A partial, clean implementation is more
167
- valuable than a complete, broken one.
168
-
169
- No yak-shaving. Deliver the core ask. Do not create demo scripts, markdown docs,
170
- bash wrappers, or sample outputs unless the issue asks for them.
171
-
172
- WHAT NOT TO DO
173
-
174
- Do not explore the filesystem to "understand the project." The codebase map and your
175
- plan already cover that. Read specific files for specific reasons.
176
-
177
- Do not overuse list_files, search_files, or bash for exploration. If you need more
178
- than 3 exploratory calls, your plan was insufficient.
179
-
180
- Do not create interactive scripts that require stdin.
181
- Do not create documentation or READMEs unless the issue asks for them.
182
- Do not modify package.json, CI configs, or project infrastructure unless the issue
183
- specifically requires it.
184
- Do not keep going when stuck. After 2 failed attempts at the same problem, note the
185
- issue, deliver what works, and move on.
186
-
187
- WHEN YOU ARE DONE
188
-
189
- End with a verify block:
190
-
191
- 1. List every file you created or modified.
192
- 2. For each, confirm it is syntactically valid (ran linter or compiler if available).
193
- 3. If tests exist for the area you changed, run them and confirm they pass.
194
- 4. If you wrote a script/CLI tool, show the --help output or a dry-run invocation.
195
- 5. Write a summary in this exact format:
196
-
197
- PR_TITLE: conventional commit format (feat:, fix:, chore:, etc.)
198
- PR_BODY: what changed, why, any assumptions made, anything the reviewer should
199
- look at carefully.
200
-
201
- Do not output anything after the summary.
202
- """
203
-
204
- # ── Interactive REPL prompt (conversational, no phased workflow) ──────────
205
- REPL_PROMPT = """\
206
- You are a coding agent running in an interactive terminal session.
207
- Working directory: {cwd}
208
-
209
- """ + _FORMAT_RULES + """
210
-
211
-
212
- TOOL USAGE
213
-
214
- Always use read_file before editing a file.
215
- If edit_file fails with "old_string not found", re-read the file with read_file to
216
- get the actual current content before retrying. Never guess at file contents.
217
- Use edit_file for targeted changes. Use write_file for new files or complete rewrites.
218
- Prefer editing existing files over creating new ones.
219
- Use read_file, list_files, and search_files instead of bash with cat, find, or grep.
220
-
221
- GUIDELINES
222
-
223
- Be direct and concise. State what you will do, then do it.
224
- Always use your tools. Never guess when you can look.
225
- Do not write new unit tests unless the project already has substantive test coverage.
226
- Do not attempt to build or compile the project unless asked.
227
- Do not add unnecessary comments, docstrings, or type annotations.
228
- For bash commands, prefer non-interactive commands.
229
- """
53
+ # System prompts are served by the Chorus proxy (chorus_mode field).
54
+ # The client sends mode + context; the server injects the full prompt.
230
55
 
231
56
  # ── Tool Definitions ────────────────────────────────────────────────────────
232
57
 
@@ -754,21 +579,22 @@ def prune_context(messages, token_budget=None):
754
579
 
755
580
  # ── Streaming Response Handler ──────────────────────────────────────────────
756
581
 
757
- def stream_response(client, messages, system):
582
+ def stream_response(client, messages, chorus_mode, chorus_context):
758
583
  """Stream LLM response via Chorus proxy, handling tool-use loops until done."""
759
- openai_messages = [{"role": "system", "content": system}]
760
- # Convert existing messages to OpenAI format
761
- for msg in messages:
762
- openai_messages.append(msg)
763
-
584
+ openai_messages = list(messages)
585
+
764
586
  while True:
765
587
  printed_text = False
766
-
588
+
767
589
  response = client.chat.completions.create(
768
590
  model=MODEL,
769
591
  max_tokens=MAX_TOKENS,
770
592
  messages=openai_messages,
771
593
  tools=TOOLS,
594
+ extra_body={
595
+ "chorus_mode": chorus_mode,
596
+ "chorus_context": chorus_context,
597
+ },
772
598
  )
773
599
 
774
600
  message = response.choices[0].message
@@ -785,7 +611,6 @@ def stream_response(client, messages, system):
785
611
  tool_results = []
786
612
  for tool_call in message.tool_calls:
787
613
  function_name = tool_call.function.name
788
- import json
789
614
  try:
790
615
  arguments = json.loads(tool_call.function.arguments)
791
616
  except json.JSONDecodeError:
@@ -825,7 +650,7 @@ def stream_response(client, messages, system):
825
650
 
826
651
  # ── Headless Prompt Mode ────────────────────────────────────────────────────
827
652
 
828
- def run_prompt(client, prompt, plan_system, exec_system):
653
+ def run_prompt(client, prompt, chorus_context):
829
654
  """Run a single prompt non-interactively. Returns a JSON-serializable dict."""
830
655
 
831
656
  # PHASE 1: Planning - separate API call with planning prompt
@@ -834,7 +659,6 @@ def run_prompt(client, prompt, plan_system, exec_system):
834
659
 
835
660
  plan_text = ""
836
661
  plan_messages = [
837
- {"role": "system", "content": plan_system},
838
662
  {"role": "user", "content": f"{prompt}\n\nProduce the plan using the exact sections: UNDERSTANDING, QUESTIONS STILL OPEN, FILES TO READ, FILES TO CREATE, FILES TO MODIFY, APPROACH. Do NOT write any code yet."}
839
663
  ]
840
664
 
@@ -843,6 +667,10 @@ def run_prompt(client, prompt, plan_system, exec_system):
843
667
  model=MODEL,
844
668
  max_tokens=MAX_TOKENS,
845
669
  messages=plan_messages,
670
+ extra_body={
671
+ "chorus_mode": "plan",
672
+ "chorus_context": chorus_context,
673
+ },
846
674
  )
847
675
  plan_text = plan_response.choices[0].message.content.strip()
848
676
 
@@ -863,7 +691,6 @@ def run_prompt(client, prompt, plan_system, exec_system):
863
691
  exec_user_content = f"{prompt}\n\nHere is your plan. Follow it.\n\n{plan_text}"
864
692
 
865
693
  messages = [
866
- {"role": "system", "content": exec_system},
867
694
  {"role": "user", "content": exec_user_content}
868
695
  ]
869
696
  files_modified = set()
@@ -887,6 +714,10 @@ def run_prompt(client, prompt, plan_system, exec_system):
887
714
  max_tokens=MAX_TOKENS,
888
715
  messages=messages,
889
716
  tools=TOOLS,
717
+ extra_body={
718
+ "chorus_mode": "exec",
719
+ "chorus_context": chorus_context,
720
+ },
890
721
  )
891
722
  except Exception as e:
892
723
  if is_token_limit_error(e):
@@ -915,7 +746,6 @@ def run_prompt(client, prompt, plan_system, exec_system):
915
746
  tool_results = []
916
747
  for tool_call in message.tool_calls:
917
748
  function_name = tool_call.function.name
918
- import json
919
749
  try:
920
750
  arguments = json.loads(tool_call.function.arguments)
921
751
  except json.JSONDecodeError:
@@ -1064,7 +894,7 @@ def main():
1064
894
 
1065
895
  # Load codebase map if available
1066
896
  map_suffix = ""
1067
- map_file = Path.cwd() / ".coder" / "map.md"
897
+ map_file = Path.cwd() / ".chorus" / "map.md"
1068
898
  if map_file.exists():
1069
899
  try:
1070
900
  map_content = map_file.read_text(encoding="utf-8").strip()
@@ -1075,12 +905,15 @@ def main():
1075
905
  except OSError:
1076
906
  pass
1077
907
 
908
+ # Build context for server-side prompt injection
909
+ chorus_context = {"cwd": cwd}
910
+ if map_suffix:
911
+ chorus_context["map"] = map_suffix.strip()
912
+
1078
913
  # ── Headless prompt mode ────────────────────────────────────────────
1079
914
  if args.prompt:
1080
- plan_system = PLAN_PROMPT.format(cwd=cwd) + map_suffix
1081
- exec_system = EXEC_PROMPT.format(cwd=cwd) + map_suffix
1082
915
  try:
1083
- result = run_prompt(client, args.prompt, plan_system, exec_system)
916
+ result = run_prompt(client, args.prompt, chorus_context)
1084
917
  print(json.dumps(result, indent=2))
1085
918
  sys.exit(0 if result["completed"] else 1)
1086
919
  except Exception as e:
@@ -1105,7 +938,6 @@ def main():
1105
938
  sys.exit(130)
1106
939
 
1107
940
  # ── Interactive REPL mode ───────────────────────────────────────────
1108
- system = REPL_PROMPT.format(cwd=cwd) + map_suffix
1109
941
  messages = []
1110
942
 
1111
943
  mode_label = f" {C.YELLOW}(safe mode){C.RESET}" if SAFE_MODE else ""
@@ -1115,7 +947,7 @@ def main():
1115
947
  print(f"{C.DIM}Commands: /clear /quit /help{C.RESET}")
1116
948
  print()
1117
949
 
1118
- histfile = os.path.expanduser("~/.coder_history")
950
+ histfile = os.path.expanduser("~/.chorus_history")
1119
951
  try:
1120
952
  readline.read_history_file(histfile)
1121
953
  except (FileNotFoundError, OSError, PermissionError):
@@ -1177,7 +1009,7 @@ def main():
1177
1009
 
1178
1010
  try:
1179
1011
  print()
1180
- stream_response(client, messages, system)
1012
+ stream_response(client, messages, "repl", chorus_context)
1181
1013
  print()
1182
1014
  except KeyboardInterrupt:
1183
1015
  del messages[snapshot:]
package/tools/mapper.py CHANGED
@@ -2,7 +2,7 @@
2
2
  """
3
3
  mapper.py — Generate a codebase map for the coder agent.
4
4
 
5
- Scans a project directory and produces a compact .coder/map.md that coder
5
+ Scans a project directory and produces a compact .chorus/map.md that coder
6
6
  loads into its system prompt, so it starts every session already knowing
7
7
  the file structure, key modules, exports, dependencies, and test status.
8
8
 
@@ -275,6 +275,157 @@ def detect_project(root):
275
275
  return info
276
276
 
277
277
 
278
+ def _parse_jsonc(text):
279
+ """Parse JSON with trailing commas and // comments (tsconfig, eslint, etc.)."""
280
+ # Strip single-line comments (but not inside strings)
281
+ lines = []
282
+ for line in text.splitlines():
283
+ stripped = line.lstrip()
284
+ if stripped.startswith("//"):
285
+ continue
286
+ # Remove inline // comments (naive but covers common cases)
287
+ in_str = False
288
+ result = []
289
+ i = 0
290
+ while i < len(line):
291
+ ch = line[i]
292
+ if ch == '"' and (i == 0 or line[i - 1] != '\\'):
293
+ in_str = not in_str
294
+ elif ch == '/' and i + 1 < len(line) and line[i + 1] == '/' and not in_str:
295
+ break
296
+ result.append(ch)
297
+ i += 1
298
+ lines.append(''.join(result))
299
+ cleaned = '\n'.join(lines)
300
+ # Strip trailing commas before } or ]
301
+ cleaned = re.sub(r',\s*([}\]])', r'\1', cleaned)
302
+ return json.loads(cleaned)
303
+
304
+
305
+ def detect_project_rules(root):
306
+ """Extract project configuration and rules that the coder should follow."""
307
+ rules = {}
308
+
309
+ # TypeScript config — resolve extends chain to find compilerOptions
310
+ ts_candidates = ["tsconfig.json", "tsconfig.app.json", "tsconfig.build.json"]
311
+ ts_checked = set()
312
+ ts_queue = [name for name in ts_candidates if (root / name).exists()]
313
+ ts_merged = {}
314
+
315
+ while ts_queue:
316
+ name = ts_queue.pop(0)
317
+ if name in ts_checked:
318
+ continue
319
+ ts_checked.add(name)
320
+ ts_path = root / name
321
+ if not ts_path.exists():
322
+ continue
323
+ try:
324
+ data = _parse_jsonc(ts_path.read_text())
325
+ # Follow extends
326
+ extends = data.get("extends")
327
+ if extends and isinstance(extends, str):
328
+ ext_name = extends.lstrip("./")
329
+ if ext_name not in ts_checked:
330
+ ts_queue.append(ext_name)
331
+ # Merge compilerOptions (later files override earlier)
332
+ compiler = data.get("compilerOptions", {})
333
+ for key in ("strict", "target", "module", "moduleResolution",
334
+ "jsx", "baseUrl", "paths", "esModuleInterop",
335
+ "noImplicitAny", "strictNullChecks", "skipLibCheck"):
336
+ if key in compiler:
337
+ ts_merged[key] = compiler[key]
338
+ if data.get("include"):
339
+ ts_merged["include"] = data["include"]
340
+ if data.get("exclude"):
341
+ ts_merged["exclude"] = data["exclude"]
342
+ except (json.JSONDecodeError, OSError, ValueError):
343
+ pass
344
+
345
+ if ts_merged:
346
+ rules["tsconfig.json"] = ts_merged
347
+
348
+ # ESLint config
349
+ eslint_files = [
350
+ ".eslintrc.json", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.yml",
351
+ ".eslintrc.yaml", ".eslintrc", "eslint.config.js", "eslint.config.mjs",
352
+ "eslint.config.cjs",
353
+ ]
354
+ for name in eslint_files:
355
+ eslint_path = root / name
356
+ if eslint_path.exists():
357
+ try:
358
+ content = eslint_path.read_text(encoding="utf-8", errors="replace")
359
+ # For JSON configs, parse and extract key fields
360
+ if name.endswith(".json") or name == ".eslintrc":
361
+ try:
362
+ data = _parse_jsonc(content)
363
+ summary = {}
364
+ if data.get("extends"):
365
+ summary["extends"] = data["extends"]
366
+ if data.get("parser"):
367
+ summary["parser"] = data["parser"]
368
+ if data.get("rules"):
369
+ summary["rules"] = data["rules"]
370
+ rules[name] = summary
371
+ except json.JSONDecodeError:
372
+ # Treat as raw text
373
+ if len(content) <= 3000:
374
+ rules[name] = content
375
+ else:
376
+ # JS/YAML configs — include raw (truncated)
377
+ if len(content) <= 3000:
378
+ rules[name] = content
379
+ else:
380
+ rules[name] = content[:3000] + "\n... (truncated)"
381
+ except OSError:
382
+ pass
383
+ break # Only include the first eslint config found
384
+
385
+ # Prettier config
386
+ prettier_files = [
387
+ ".prettierrc", ".prettierrc.json", ".prettierrc.js", ".prettierrc.cjs",
388
+ ".prettierrc.yml", ".prettierrc.yaml", "prettier.config.js",
389
+ "prettier.config.cjs",
390
+ ]
391
+ for name in prettier_files:
392
+ p_path = root / name
393
+ if p_path.exists():
394
+ try:
395
+ content = p_path.read_text(encoding="utf-8", errors="replace")
396
+ if len(content) <= 1000:
397
+ rules[name] = content
398
+ except OSError:
399
+ pass
400
+ break
401
+
402
+ # .editorconfig
403
+ ec_path = root / ".editorconfig"
404
+ if ec_path.exists():
405
+ try:
406
+ content = ec_path.read_text(encoding="utf-8", errors="replace")
407
+ if len(content) <= 1000:
408
+ rules[".editorconfig"] = content
409
+ except OSError:
410
+ pass
411
+
412
+ # AI agent instructions (CLAUDE.md, CONTRIBUTING.md, .cursorrules)
413
+ for name in ("CLAUDE.md", ".claude", "CONTRIBUTING.md", ".cursorrules",
414
+ ".github/CONTRIBUTING.md"):
415
+ ai_path = root / name
416
+ if ai_path.exists() and ai_path.is_file():
417
+ try:
418
+ content = ai_path.read_text(encoding="utf-8", errors="replace")
419
+ if len(content) <= 5000:
420
+ rules[name] = content
421
+ else:
422
+ rules[name] = content[:5000] + "\n... (truncated)"
423
+ except OSError:
424
+ pass
425
+
426
+ return rules
427
+
428
+
278
429
  def detect_tests(root):
279
430
  """Check if the project has substantive test coverage."""
280
431
  test_dir_names = {"test", "tests", "__tests__", "spec", "specs", "test_suite"}
@@ -364,6 +515,22 @@ def generate_map(root):
364
515
  lines.append(f" Dev dependencies: {dev_str}")
365
516
  lines.append("")
366
517
 
518
+ # Project rules and configs
519
+ rules = detect_project_rules(root)
520
+ if rules:
521
+ lines.append("PROJECT RULES")
522
+ for name, value in rules.items():
523
+ lines.append(f" {name}:")
524
+ if isinstance(value, dict):
525
+ for k, v in value.items():
526
+ lines.append(f" {k}: {json.dumps(v) if not isinstance(v, str) else v}")
527
+ elif isinstance(value, str):
528
+ for vline in value.splitlines():
529
+ lines.append(f" {vline}")
530
+ else:
531
+ lines.append(f" {value}")
532
+ lines.append("")
533
+
367
534
  # Tests
368
535
  test_status, test_files = detect_tests(root)
369
536
  lines.append("TESTS")
@@ -434,22 +601,22 @@ def main():
434
601
 
435
602
  map_content = generate_map(root)
436
603
 
437
- out_dir = root / ".coder"
604
+ out_dir = root / ".chorus"
438
605
  out_dir.mkdir(exist_ok=True)
439
606
  out_file = out_dir / "map.md"
440
607
  out_file.write_text(map_content, encoding="utf-8")
441
608
 
442
- # Add .coder to .gitignore if not already there
609
+ # Add .chorus to .gitignore if not already there
443
610
  gitignore = root / ".gitignore"
444
611
  if gitignore.exists():
445
612
  gi_text = gitignore.read_text()
446
- if ".coder" not in gi_text and ".coder/" not in gi_text:
613
+ if ".chorus" not in gi_text and ".chorus/" not in gi_text:
447
614
  with open(gitignore, "a") as f:
448
- f.write("\n.coder/\n")
449
- print(f"Added .coder/ to .gitignore", file=sys.stderr)
615
+ f.write("\n.chorus/\n")
616
+ print(f"Added .chorus/ to .gitignore", file=sys.stderr)
450
617
  elif (root / ".git").is_dir():
451
- gitignore.write_text(".coder/\n")
452
- print(f"Created .gitignore with .coder/", file=sys.stderr)
618
+ gitignore.write_text(".chorus/\n")
619
+ print(f"Created .gitignore with .chorus/", file=sys.stderr)
453
620
 
454
621
  # Stats
455
622
  line_count = map_content.count("\n")