atris 2.5.3 → 2.5.4

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.
Files changed (48) hide show
  1. package/README.md +10 -0
  2. package/atris/experiments/README.md +118 -0
  3. package/atris/experiments/_examples/smoke-keep-revert/README.md +45 -0
  4. package/atris/experiments/_examples/smoke-keep-revert/candidate.py +8 -0
  5. package/atris/experiments/_examples/smoke-keep-revert/loop.py +129 -0
  6. package/atris/experiments/_examples/smoke-keep-revert/measure.py +47 -0
  7. package/atris/experiments/_examples/smoke-keep-revert/program.md +3 -0
  8. package/atris/experiments/_examples/smoke-keep-revert/proposals/bad_patch.py +19 -0
  9. package/atris/experiments/_examples/smoke-keep-revert/proposals/fix_patch.py +22 -0
  10. package/atris/experiments/_examples/smoke-keep-revert/reset.py +21 -0
  11. package/atris/experiments/_examples/smoke-keep-revert/results.tsv +5 -0
  12. package/atris/experiments/_examples/smoke-keep-revert/visual.svg +52 -0
  13. package/atris/experiments/_fixtures/invalid/BadName/loop.py +1 -0
  14. package/atris/experiments/_fixtures/invalid/BadName/program.md +3 -0
  15. package/atris/experiments/_fixtures/invalid/BadName/results.tsv +1 -0
  16. package/atris/experiments/_fixtures/invalid/bloated-context/loop.py +1 -0
  17. package/atris/experiments/_fixtures/invalid/bloated-context/measure.py +1 -0
  18. package/atris/experiments/_fixtures/invalid/bloated-context/program.md +6 -0
  19. package/atris/experiments/_fixtures/invalid/bloated-context/results.tsv +1 -0
  20. package/atris/experiments/_fixtures/valid/good-experiment/loop.py +1 -0
  21. package/atris/experiments/_fixtures/valid/good-experiment/measure.py +1 -0
  22. package/atris/experiments/_fixtures/valid/good-experiment/program.md +3 -0
  23. package/atris/experiments/_fixtures/valid/good-experiment/results.tsv +1 -0
  24. package/atris/experiments/_template/pack/loop.py +3 -0
  25. package/atris/experiments/_template/pack/measure.py +13 -0
  26. package/atris/experiments/_template/pack/program.md +3 -0
  27. package/atris/experiments/_template/pack/reset.py +3 -0
  28. package/atris/experiments/_template/pack/results.tsv +1 -0
  29. package/atris/experiments/benchmark_runtime.py +81 -0
  30. package/atris/experiments/benchmark_validate.py +70 -0
  31. package/atris/experiments/validate.py +92 -0
  32. package/atris/policies/atris-design.md +66 -0
  33. package/atris/skills/README.md +1 -0
  34. package/atris/skills/apps/SKILL.md +243 -0
  35. package/atris/skills/autoresearch/SKILL.md +63 -0
  36. package/atris/skills/create-app/SKILL.md +9 -13
  37. package/atris/skills/design/SKILL.md +15 -1
  38. package/bin/atris.js +76 -5
  39. package/commands/business.js +132 -0
  40. package/commands/clean.js +113 -70
  41. package/commands/console.js +397 -0
  42. package/commands/experiments.js +216 -0
  43. package/commands/init.js +4 -0
  44. package/commands/pull.js +311 -0
  45. package/commands/push.js +170 -0
  46. package/commands/run.js +366 -0
  47. package/commands/status.js +21 -1
  48. package/package.json +2 -1
package/bin/atris.js CHANGED
@@ -164,6 +164,14 @@ function searchJournal(keyword) {
164
164
  }
165
165
 
166
166
  function consoleCmd() {
167
+ const extractedCommand = path.join(__dirname, '..', 'commands', 'console.js');
168
+ if (fs.existsSync(extractedCommand)) {
169
+ const loaded = require('../commands/console');
170
+ if (loaded && typeof loaded.consoleCommand === 'function') {
171
+ return loaded.consoleCommand();
172
+ }
173
+ }
174
+
167
175
  const workspace = process.cwd();
168
176
  const daemonScript = path.join(workspace, 'cli', 'atrisd.sh');
169
177
 
@@ -211,11 +219,12 @@ function showHelp() {
211
219
  console.log(' plan - Create build spec with visualization');
212
220
  console.log(' do - Execute tasks');
213
221
  console.log(' review - Validate work (tests, safety checks, docs)');
222
+ console.log(' run - Auto-chain plan→do→review (autonomous loop, auto-pushes)');
214
223
  console.log('');
215
224
  console.log('Context & tracking:');
216
225
  console.log(' log - Add ideas to inbox');
217
226
  console.log(' activate - Load Atris context');
218
- console.log(' status - See active work and completions');
227
+ console.log(' status - See active work and completions (--json for machine output)');
219
228
  console.log(' analytics - Show recent productivity from journals');
220
229
  console.log(' search - Search journal history (atris search <keyword>)');
221
230
  console.log(' clean - Housekeeping (stale tasks, archive journals, broken refs)');
@@ -226,10 +235,18 @@ function showHelp() {
226
235
  console.log(' autopilot - Guided loop that can clarify TODOs and run plan → do → review');
227
236
  console.log(' visualize - Legacy visualization helper (prefer "atris plan")');
228
237
  console.log('');
238
+ console.log('Experiments:');
239
+ console.log(' experiments init [slug] - Prepare atris/experiments/ or scaffold a pack');
240
+ console.log(' experiments validate - Validate experiment packs');
241
+ console.log(' experiments benchmark [m] - Run validate/runtime experiment benchmarks');
242
+ console.log('');
229
243
  console.log('Quick commands:');
230
244
  console.log(' atris - Load context and start (natural language)');
231
245
  console.log(' next - Auto-advance to next step');
232
246
  console.log('');
247
+ console.log('Sync:');
248
+ console.log(' pull - Pull journals + member data from cloud');
249
+ console.log('');
233
250
  console.log('Cloud & agents:');
234
251
  console.log(' console - Start/attach always-on coding console (tmux daemon)');
235
252
  console.log(' agent - Select which Atris agent to use');
@@ -366,9 +383,9 @@ const { planAtris: planCmd, doAtris: doCmd, reviewAtris: reviewCmd } = require('
366
383
  // All other commands are lazy-loaded inline (require() only when invoked)
367
384
 
368
385
  // Check if this is a known command or natural language input
369
- const knownCommands = ['init', 'log', 'status', 'analytics', 'visualize', 'brainstorm', 'autopilot', 'plan', 'do', 'review',
386
+ const knownCommands = ['init', 'log', 'status', 'analytics', 'visualize', 'brainstorm', 'autopilot', 'run', 'plan', 'do', 'review',
370
387
  'activate', 'agent', 'chat', 'console', 'login', 'logout', 'whoami', 'switch', 'accounts', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
371
- 'clean', 'verify', 'search', 'skill', 'member', 'plugin', 'sync',
388
+ 'clean', 'verify', 'search', 'skill', 'member', 'plugin', 'experiments', 'pull', 'sync',
372
389
  'gmail', 'calendar', 'twitter', 'slack', 'integrations'];
373
390
 
374
391
  // Check if command is an atris.md spec file - triggers welcome visualization
@@ -715,6 +732,42 @@ if (command === 'init') {
715
732
  console.log(' Prefer: atris plan');
716
733
  console.log('');
717
734
  require('../commands/visualize').visualizeAtris();
735
+ } else if (command === 'run') {
736
+ const args = process.argv.slice(3);
737
+ if (args.includes('--help') || args.includes('-h')) {
738
+ console.log('');
739
+ console.log('Usage: atris run [options]');
740
+ console.log('');
741
+ console.log('Auto-chain plan → do → review cycles autonomously.');
742
+ console.log('Reads inbox ideas, creates tasks, builds them, validates, repeats.');
743
+ console.log('');
744
+ console.log('Options:');
745
+ console.log(' --cycles=N Max cycles (default: 5)');
746
+ console.log(' --once Single plan→do→review cycle');
747
+ console.log(' --verbose Show claude -p output');
748
+ console.log(' --dry-run Preview without executing');
749
+ console.log(' --timeout=N Phase timeout in seconds (default: 600)');
750
+ console.log(' --push Auto-push after each cycle (default: true)');
751
+ console.log(' --no-push Skip auto-push after each cycle');
752
+ console.log('');
753
+ process.exit(0);
754
+ }
755
+
756
+ const verbose = args.includes('--verbose') || args.includes('-v');
757
+ const dryRun = args.includes('--dry-run');
758
+ const once = args.includes('--once');
759
+ const push = !args.includes('--no-push');
760
+ const cyclesArg = args.find(a => a.startsWith('--cycles='));
761
+ const maxCycles = cyclesArg ? parseInt(cyclesArg.split('=')[1]) : 5;
762
+ const timeoutArg = args.find(a => a.startsWith('--timeout='));
763
+ const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1]) * 1000 : undefined;
764
+
765
+ require('../commands/run').runAtris({ maxCycles, verbose, dryRun, once, push, timeout })
766
+ .then(() => process.exit(0))
767
+ .catch((error) => {
768
+ console.error(`\u2717 Run failed: ${error.message || error}`);
769
+ process.exit(1);
770
+ });
718
771
  } else if (command === 'autopilot') {
719
772
  const args = process.argv.slice(3);
720
773
  if (args.includes('--help') || args.includes('-h')) {
@@ -813,7 +866,8 @@ if (command === 'init') {
813
866
  });
814
867
  } else if (command === 'status') {
815
868
  const isQuick = process.argv.includes('--quick') || process.argv.includes('-q');
816
- statusCmd(isQuick);
869
+ const isJson = process.argv.includes('--json');
870
+ statusCmd(isQuick, isJson);
817
871
  } else if (command === 'analytics') {
818
872
  require('../commands/analytics').analyticsAtris();
819
873
  } else if (command === 'clean') {
@@ -866,10 +920,28 @@ if (command === 'init') {
866
920
  const subcommand = process.argv[3];
867
921
  const args = process.argv.slice(4);
868
922
  require('../commands/member').memberCommand(subcommand, ...args);
923
+ } else if (command === 'pull') {
924
+ require('../commands/pull').pullAtris()
925
+ .then(() => process.exit(0))
926
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
927
+ } else if (command === 'push') {
928
+ require('../commands/push').pushAtris()
929
+ .then(() => process.exit(0))
930
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
931
+ } else if (command === 'business') {
932
+ const subcommand = process.argv[3];
933
+ const args = process.argv.slice(4);
934
+ require('../commands/business').businessCommand(subcommand, ...args)
935
+ .then(() => process.exit(0))
936
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
869
937
  } else if (command === 'plugin') {
870
938
  const subcommand = process.argv[3] || 'build';
871
939
  const args = process.argv.slice(4);
872
940
  require('../commands/plugin').pluginCommand(subcommand, ...args);
941
+ } else if (command === 'experiments') {
942
+ const subcommand = process.argv[3];
943
+ const args = process.argv.slice(4);
944
+ require('../commands/experiments').experimentsCommand(subcommand, ...args);
873
945
  } else {
874
946
  console.log(`Unknown command: ${command}`);
875
947
  console.log('Run "atris help" to see available commands');
@@ -1292,4 +1364,3 @@ async function atrisDevEntry(userInput = null) {
1292
1364
  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
1293
1365
  console.log('');
1294
1366
  }
1295
-
@@ -0,0 +1,132 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { loadCredentials } = require('../utils/auth');
4
+ const { apiRequestJson } = require('../utils/api');
5
+
6
+ function getBusinessConfigPath() {
7
+ const home = require('os').homedir();
8
+ const dir = path.join(home, '.atris');
9
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
10
+ return path.join(dir, 'businesses.json');
11
+ }
12
+
13
+ function loadBusinesses() {
14
+ const p = getBusinessConfigPath();
15
+ if (!fs.existsSync(p)) return {};
16
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return {}; }
17
+ }
18
+
19
+ function saveBusinesses(data) {
20
+ fs.writeFileSync(getBusinessConfigPath(), JSON.stringify(data, null, 2));
21
+ }
22
+
23
+ async function addBusiness(slug) {
24
+ if (!slug) {
25
+ console.error('Usage: atris business add <slug>');
26
+ process.exit(1);
27
+ }
28
+
29
+ const creds = loadCredentials();
30
+ if (!creds || !creds.token) {
31
+ console.error('Not logged in. Run: atris login');
32
+ process.exit(1);
33
+ }
34
+
35
+ // Resolve slug to business
36
+ const result = await apiRequestJson(`/businesses/by-slug/${slug}`, {
37
+ method: 'GET',
38
+ token: creds.token,
39
+ });
40
+
41
+ if (!result.ok) {
42
+ // Try listing all and matching
43
+ const listResult = await apiRequestJson('/businesses/', { method: 'GET', token: creds.token });
44
+ if (listResult.ok && Array.isArray(listResult.data)) {
45
+ const match = listResult.data.find(b => b.slug === slug || b.name.toLowerCase() === slug.toLowerCase());
46
+ if (match) {
47
+ const businesses = loadBusinesses();
48
+ businesses[slug] = {
49
+ business_id: match.id,
50
+ workspace_id: match.workspace_id,
51
+ name: match.name,
52
+ slug: match.slug,
53
+ added_at: new Date().toISOString(),
54
+ };
55
+ saveBusinesses(businesses);
56
+ console.log(`\nAdded "${match.name}" (${match.slug})`);
57
+ return;
58
+ }
59
+ }
60
+ console.error(`Business "${slug}" not found.`);
61
+ process.exit(1);
62
+ }
63
+
64
+ const biz = result.data;
65
+ const businesses = loadBusinesses();
66
+ businesses[slug] = {
67
+ business_id: biz.id,
68
+ workspace_id: biz.workspace_id,
69
+ name: biz.name,
70
+ slug: biz.slug,
71
+ added_at: new Date().toISOString(),
72
+ };
73
+ saveBusinesses(businesses);
74
+ console.log(`\nAdded "${biz.name}" (${biz.slug})`);
75
+ }
76
+
77
+ async function listBusinesses() {
78
+ const businesses = loadBusinesses();
79
+ const slugs = Object.keys(businesses);
80
+
81
+ if (slugs.length === 0) {
82
+ console.log('\nNo businesses connected. Run: atris business add <slug>');
83
+ return;
84
+ }
85
+
86
+ console.log('\nConnected businesses:\n');
87
+ for (const slug of slugs) {
88
+ const b = businesses[slug];
89
+ console.log(` ${b.name || slug} (${b.slug || slug})`);
90
+ console.log(` ID: ${b.business_id}`);
91
+ console.log(` Added: ${b.added_at || 'unknown'}`);
92
+ console.log('');
93
+ }
94
+ }
95
+
96
+ async function removeBusiness(slug) {
97
+ if (!slug) {
98
+ console.error('Usage: atris business remove <slug>');
99
+ process.exit(1);
100
+ }
101
+
102
+ const businesses = loadBusinesses();
103
+ if (!businesses[slug]) {
104
+ console.error(`Business "${slug}" not connected.`);
105
+ process.exit(1);
106
+ }
107
+
108
+ const name = businesses[slug].name || slug;
109
+ delete businesses[slug];
110
+ saveBusinesses(businesses);
111
+ console.log(`\nRemoved "${name}"`);
112
+ }
113
+
114
+ async function businessCommand(subcommand, ...args) {
115
+ switch (subcommand) {
116
+ case 'add':
117
+ await addBusiness(args[0]);
118
+ break;
119
+ case 'list':
120
+ case 'ls':
121
+ await listBusinesses();
122
+ break;
123
+ case 'remove':
124
+ case 'rm':
125
+ await removeBusiness(args[0]);
126
+ break;
127
+ default:
128
+ console.log('Usage: atris business <add|list|remove> [slug]');
129
+ }
130
+ }
131
+
132
+ module.exports = { businessCommand, loadBusinesses, saveBusinesses, getBusinessConfigPath };
package/commands/clean.js CHANGED
@@ -162,7 +162,8 @@ function findStaleTasks(atrisDir) {
162
162
  }
163
163
 
164
164
  /**
165
- * Find and heal broken MAP.md references
165
+ * Find and heal broken MAP.md references (single-line AND range refs)
166
+ * Detects both out-of-bounds AND drift (symbol moved to different line)
166
167
  * Returns { healed: number, unhealable: array }
167
168
  */
168
169
  function healBrokenMapRefs(cwd, atrisDir, dryRun = false) {
@@ -173,68 +174,85 @@ function healBrokenMapRefs(cwd, atrisDir, dryRun = false) {
173
174
  const unhealable = [];
174
175
  let healed = 0;
175
176
 
176
- // Match patterns like `file.js:123` with surrounding context
177
- // Capture: full match, file path, extension, line number, and context after
178
- const refPattern = /(`?)([a-zA-Z0-9_\-./\\]+\.(js|ts|py|go|rs|rb|java|c|cpp|h|hpp|md|json|yaml|yml)):(\d+)(`?)(\s*[\(\[—\-]?\s*([^)\]\n]+))?/g;
177
+ // Match both `file.js:123` and `file.js:123-456` with surrounding context
178
+ // [^\S\n] = horizontal whitespace only (no newlines)
179
+ // Required delimiter [(,[,—,-] prevents bleeding into adjacent refs on same line
180
+ const refPattern = /(`?)([a-zA-Z0-9_\-./\\]+\.(js|ts|py|go|rs|rb|java|c|cpp|h|hpp|md|json|yaml|yml)):(\d+)(?:-(\d+))?(`?)([^\S\n]*[\(\[—\-][^\S\n]*([^)\]\n]+))?/g;
181
+
182
+ // Cache file reads
183
+ const fileCache = {};
184
+ function readFileCached(filePath) {
185
+ if (!fileCache[filePath]) {
186
+ const fullPath = path.join(cwd, filePath);
187
+ if (!fs.existsSync(fullPath)) return null;
188
+ try {
189
+ const content = fs.readFileSync(fullPath, 'utf8');
190
+ fileCache[filePath] = { content, lines: content.split('\n') };
191
+ } catch { return null; }
192
+ }
193
+ return fileCache[filePath];
194
+ }
179
195
 
180
196
  const replacements = [];
181
197
  let match;
182
198
 
183
199
  while ((match = refPattern.exec(mapContent)) !== null) {
184
- const fullMatch = match[0];
185
200
  const backtickBefore = match[1] || '';
186
201
  const filePath = match[2];
187
- const ext = match[3];
188
- const lineNum = parseInt(match[4], 10);
189
- const backtickAfter = match[5] || '';
190
- const contextPart = match[7] || '';
191
-
192
- const fullPath = path.join(cwd, filePath);
193
-
194
- // Check if file exists
195
- if (!fs.existsSync(fullPath)) {
196
- unhealable.push({ file: filePath, line: lineNum, reason: 'File not found' });
202
+ const startLine = parseInt(match[4], 10);
203
+ const endLine = match[5] ? parseInt(match[5], 10) : null;
204
+ const backtickAfter = match[6] || '';
205
+ const contextPart = match[8] || '';
206
+
207
+ const file = readFileCached(filePath);
208
+ if (!file) {
209
+ unhealable.push({ file: filePath, line: startLine, reason: 'File not found' });
197
210
  continue;
198
211
  }
199
212
 
200
- // Read file and check line number
201
- let fileContent;
202
- try {
203
- fileContent = fs.readFileSync(fullPath, 'utf8');
204
- } catch (err) {
205
- unhealable.push({ file: filePath, line: lineNum, reason: `Cannot read: ${err.message}` });
206
- continue;
207
- }
213
+ const symbol = extractSymbol(contextPart);
208
214
 
209
- const lines = fileContent.split('\n');
215
+ // Check if reference is still accurate
216
+ const outOfBounds = startLine > file.lines.length || (endLine && endLine > file.lines.length);
217
+ const drifted = symbol && startLine <= file.lines.length && !symbolAtLine(file.content, symbol, startLine);
210
218
 
211
- // If line number is valid, skip
212
- if (lineNum <= lines.length) {
213
- continue;
214
- }
215
-
216
- // Line number is broken - try to heal
217
- const symbol = extractSymbol(contextPart);
219
+ if (!outOfBounds && !drifted) continue;
218
220
 
219
221
  if (!symbol) {
220
- unhealable.push({ file: filePath, line: lineNum, reason: 'No symbol to search for' });
222
+ unhealable.push({ file: filePath, line: startLine, reason: 'No symbol to search for' });
221
223
  continue;
222
224
  }
223
225
 
224
- // Search for the symbol in the file
225
- const newLine = findSymbolLine(fileContent, symbol);
226
-
227
- if (!newLine) {
228
- unhealable.push({ file: filePath, line: lineNum, reason: `Symbol "${symbol}" not found` });
226
+ // Find where the symbol actually is now
227
+ const newStart = findSymbolLine(file.content, symbol);
228
+ if (!newStart) {
229
+ unhealable.push({ file: filePath, line: startLine, reason: `Symbol "${symbol}" not found` });
229
230
  continue;
230
231
  }
231
232
 
232
- // Record the replacement
233
- const oldRef = `${backtickBefore}${filePath}:${lineNum}${backtickAfter}`;
234
- const newRef = `${backtickBefore}${filePath}:${newLine}${backtickAfter}`;
233
+ if (endLine) {
234
+ // Range ref: find new end by scanning for function end
235
+ const originalSpan = endLine - startLine;
236
+ const newEnd = findFunctionEnd(file.lines, newStart) || (newStart + originalSpan);
237
+ const clampedEnd = Math.min(newEnd, file.lines.length);
238
+
239
+ const oldRef = `${backtickBefore}${filePath}:${startLine}-${endLine}${backtickAfter}`;
240
+ const newRef = `${backtickBefore}${filePath}:${newStart}-${clampedEnd}${backtickAfter}`;
235
241
 
236
- replacements.push({ old: oldRef, new: newRef, symbol });
237
- healed++;
242
+ if (oldRef !== newRef) {
243
+ replacements.push({ old: oldRef, new: newRef, symbol });
244
+ healed++;
245
+ }
246
+ } else {
247
+ // Single-line ref
248
+ const oldRef = `${backtickBefore}${filePath}:${startLine}${backtickAfter}`;
249
+ const newRef = `${backtickBefore}${filePath}:${newStart}${backtickAfter}`;
250
+
251
+ if (oldRef !== newRef) {
252
+ replacements.push({ old: oldRef, new: newRef, symbol });
253
+ healed++;
254
+ }
255
+ }
238
256
  }
239
257
 
240
258
  // Apply replacements
@@ -245,7 +263,45 @@ function healBrokenMapRefs(cwd, atrisDir, dryRun = false) {
245
263
  fs.writeFileSync(mapFile, mapContent);
246
264
  }
247
265
 
248
- return { healed, unhealable };
266
+ return { healed, unhealable, replacements: dryRun ? replacements : [] };
267
+ }
268
+
269
+ /**
270
+ * Check if a symbol is actually defined at or near a given line
271
+ */
272
+ function symbolAtLine(fileContent, symbol, lineNum) {
273
+ const lines = fileContent.split('\n');
274
+ // Check a 5-line window around the referenced line
275
+ const start = Math.max(0, lineNum - 3);
276
+ const end = Math.min(lines.length, lineNum + 2);
277
+ const escaped = escapeRegExp(symbol);
278
+ const re = new RegExp(`\\b${escaped}\\b`);
279
+ for (let i = start; i < end; i++) {
280
+ if (re.test(lines[i])) return true;
281
+ }
282
+ return false;
283
+ }
284
+
285
+ /**
286
+ * Find the end line of a function starting at startLine (1-indexed)
287
+ * Tracks brace depth to find the matching closing brace
288
+ */
289
+ function findFunctionEnd(lines, startLine) {
290
+ let depth = 0;
291
+ let foundOpen = false;
292
+
293
+ for (let i = startLine - 1; i < lines.length; i++) {
294
+ const line = lines[i];
295
+ for (const ch of line) {
296
+ if (ch === '{') { depth++; foundOpen = true; }
297
+ if (ch === '}') { depth--; }
298
+ }
299
+ if (foundOpen && depth === 0) {
300
+ return i + 1; // 1-indexed
301
+ }
302
+ }
303
+
304
+ return null;
249
305
  }
250
306
 
251
307
  /**
@@ -256,6 +312,7 @@ function extractSymbol(context) {
256
312
 
257
313
  // Clean up the context
258
314
  const cleaned = context.trim()
315
+ .replace(/`/g, '') // Strip backticks
259
316
  .replace(/^[\(\[—\-:]+\s*/, '') // Remove leading punctuation
260
317
  .replace(/[\)\]]+$/, '') // Remove trailing brackets
261
318
  .trim();
@@ -280,45 +337,31 @@ function extractSymbol(context) {
280
337
  }
281
338
 
282
339
  /**
283
- * Find the line number where a symbol is defined
340
+ * Find the line number where a symbol is defined (strict patterns only)
341
+ * Returns null if only loose matches found — prevents healing to wrong locations
284
342
  */
285
343
  function findSymbolLine(fileContent, symbol) {
286
344
  const lines = fileContent.split('\n');
345
+ const esc = escapeRegExp(symbol);
287
346
 
288
- // Patterns to match symbol definitions
347
+ // Strict definition patterns only — no loose fallback
289
348
  const patterns = [
290
- // function name(
291
- new RegExp(`^\\s*(async\\s+)?function\\s+${escapeRegExp(symbol)}\\s*\\(`),
292
- // const/let/var name =
293
- new RegExp(`^\\s*(const|let|var)\\s+${escapeRegExp(symbol)}\\s*=`),
294
- // class name
295
- new RegExp(`^\\s*class\\s+${escapeRegExp(symbol)}\\b`),
296
- // name: function or name() {
297
- new RegExp(`^\\s*${escapeRegExp(symbol)}\\s*[:(]`),
298
- // exports.name or module.exports.name
299
- new RegExp(`exports\\.${escapeRegExp(symbol)}\\s*=`),
300
- // def name( for Python
301
- new RegExp(`^\\s*def\\s+${escapeRegExp(symbol)}\\s*\\(`),
302
- // Just the symbol on a line (loose match)
303
- new RegExp(`\\b${escapeRegExp(symbol)}\\b`)
349
+ new RegExp(`^\\s*(async\\s+)?function\\s+${esc}\\s*\\(`), // function name(
350
+ new RegExp(`^\\s*(const|let|var)\\s+${esc}\\s*=`), // const/let/var name =
351
+ new RegExp(`^\\s*class\\s+${esc}\\b`), // class name
352
+ new RegExp(`^\\s*${esc}\\s*[:(]`), // name: or name(
353
+ new RegExp(`exports\\.${esc}\\s*=`), // exports.name =
354
+ new RegExp(`^\\s*def\\s+${esc}\\s*\\(`), // def name( (Python)
304
355
  ];
305
356
 
306
- // Try strict patterns first
307
- for (let i = 0; i < patterns.length - 1; i++) {
357
+ for (const pattern of patterns) {
308
358
  for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
309
- if (patterns[i].test(lines[lineIdx])) {
310
- return lineIdx + 1; // 1-indexed
359
+ if (pattern.test(lines[lineIdx])) {
360
+ return lineIdx + 1;
311
361
  }
312
362
  }
313
363
  }
314
364
 
315
- // Fallback: loose match (just contains the symbol)
316
- for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
317
- if (patterns[patterns.length - 1].test(lines[lineIdx])) {
318
- return lineIdx + 1;
319
- }
320
- }
321
-
322
365
  return null;
323
366
  }
324
367