atris 3.35.0 → 3.36.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.
Files changed (133) hide show
  1. package/AGENTS.md +37 -0
  2. package/README.md +5 -3
  3. package/atris/GETTING_STARTED.md +1 -1
  4. package/atris/atris.md +3 -0
  5. package/atris/policies/day-loop-voice.md +102 -0
  6. package/atris/policies/outbound-artifact-gate.md +2 -0
  7. package/atris/skills/design/SKILL.md +56 -32
  8. package/atris/skills/endgame/SKILL.md +12 -6
  9. package/atris/skills/engines/SKILL.md +22 -4
  10. package/atris/skills/fable-method/SKILL.md +66 -0
  11. package/atris/skills/improve/SKILL.md +65 -45
  12. package/atris/skills/youtube/SKILL.md +10 -1
  13. package/atris.md +2 -0
  14. package/ax +147 -19
  15. package/bin/atris.js +565 -265
  16. package/commands/activate.js +194 -88
  17. package/commands/agents.js +166 -0
  18. package/commands/autoland.js +459 -107
  19. package/commands/autopilot-front.js +20 -2
  20. package/commands/autopilot.js +118 -2
  21. package/commands/avail.js +407 -0
  22. package/commands/bench.js +188 -0
  23. package/commands/brain.js +3 -0
  24. package/commands/brief.js +651 -0
  25. package/commands/business-sync.js +192 -6
  26. package/commands/clean.js +50 -24
  27. package/commands/close.js +1083 -0
  28. package/commands/cloud.js +245 -0
  29. package/commands/compile.js +292 -1
  30. package/commands/computer.js +150 -3
  31. package/commands/dream.js +365 -0
  32. package/commands/drill.js +371 -0
  33. package/commands/engine.js +993 -32
  34. package/commands/experiments.js +28 -0
  35. package/commands/feedback.js +34 -12
  36. package/commands/fleet-report.js +206 -0
  37. package/commands/gm.js +23 -0
  38. package/commands/goal.js +247 -0
  39. package/commands/improve.js +642 -26
  40. package/commands/init.js +72 -44
  41. package/commands/interview.js +67 -1
  42. package/commands/land.js +152 -52
  43. package/commands/lifecycle.js +39 -3
  44. package/commands/log.js +84 -1
  45. package/commands/loops.js +220 -16
  46. package/commands/meet.js +220 -0
  47. package/commands/member.js +511 -34
  48. package/commands/mission.js +3029 -339
  49. package/commands/next.js +137 -0
  50. package/commands/now.js +220 -25
  51. package/commands/one-lap.js +776 -0
  52. package/commands/orb.js +314 -0
  53. package/commands/pack-craft.js +179 -0
  54. package/commands/pack.js +823 -0
  55. package/commands/play.js +3 -2
  56. package/commands/probe.js +30 -3
  57. package/commands/pulse.js +241 -46
  58. package/commands/push.js +260 -82
  59. package/commands/rainmaker.js +49 -0
  60. package/commands/report.js +415 -0
  61. package/commands/scout.js +147 -0
  62. package/commands/search.js +363 -0
  63. package/commands/skill.js +47 -3
  64. package/commands/slop.js +50 -2
  65. package/commands/soul.js +1 -1
  66. package/commands/stream.js +861 -0
  67. package/commands/study.js +693 -0
  68. package/commands/sync.js +67 -54
  69. package/commands/task.js +1346 -117
  70. package/commands/team.js +73 -0
  71. package/commands/verify.js +96 -0
  72. package/commands/watch.js +303 -0
  73. package/commands/wish.js +500 -0
  74. package/commands/workflow.js +11 -5
  75. package/commands/worktree.js +234 -13
  76. package/commands/xp.js +29 -11
  77. package/lib/auto-accept-certified.js +331 -34
  78. package/lib/autoland.js +319 -54
  79. package/lib/ax-auto-lane.js +79 -0
  80. package/lib/bench/context.js +147 -0
  81. package/lib/bench/engines.js +141 -0
  82. package/lib/bench/report.js +140 -0
  83. package/lib/bench/runner.js +512 -0
  84. package/lib/brief-ledger.js +350 -0
  85. package/lib/cloud-mission.js +259 -0
  86. package/lib/codex-flight.js +154 -0
  87. package/lib/default-runner.js +45 -0
  88. package/lib/default-verifier.js +70 -0
  89. package/lib/engine-registry.js +232 -0
  90. package/lib/experiments/daily.js +640 -0
  91. package/lib/fleet.js +2219 -67
  92. package/lib/improve-vitals-html.js +171 -0
  93. package/lib/known-commands.js +58 -0
  94. package/lib/loop-doctor.js +416 -0
  95. package/lib/member-switches.js +144 -0
  96. package/lib/mission-room.js +1 -0
  97. package/lib/mission-root.js +52 -0
  98. package/lib/next-moves.js +327 -10
  99. package/lib/one-lap-validator.js +60 -0
  100. package/lib/orb-context.js +477 -0
  101. package/lib/orb-scorecard.js +224 -0
  102. package/lib/policy-lessons.js +52 -1
  103. package/lib/pulse.js +277 -3
  104. package/lib/receipt-block.js +168 -0
  105. package/lib/receipt-evidence.js +65 -4
  106. package/lib/router-brain.js +352 -0
  107. package/lib/runner-command.js +10 -0
  108. package/lib/self-drive.js +258 -0
  109. package/lib/short-name.js +103 -0
  110. package/lib/spawn-env.js +18 -0
  111. package/lib/state-detection.js +56 -1
  112. package/lib/sync-status.js +59 -0
  113. package/lib/task-db.js +108 -29
  114. package/lib/task-proof.js +23 -1
  115. package/lib/team-presence.js +260 -0
  116. package/lib/tool-result-encode.js +7 -0
  117. package/lib/trust-tiers.js +90 -0
  118. package/lib/usage.js +107 -0
  119. package/lib/voice-gate.js +163 -0
  120. package/lib/wish-audit.js +1368 -0
  121. package/lib/wish-delegate.js +1840 -0
  122. package/lib/wish-design.js +110 -0
  123. package/lib/wish-stats.js +183 -0
  124. package/lib/wish-store.js +354 -0
  125. package/lib/zip.js +221 -0
  126. package/package.json +3 -1
  127. package/templates/loops/atris/loops/LOOPS.md +55 -0
  128. package/templates/loops/atris/loops/TICK.md +24 -0
  129. package/templates/loops/atris/loops/feedback.md +22 -0
  130. package/templates/loops/atris/loops/quality.md +22 -0
  131. package/templates/loops/atris/wiki/systems/loops.md +41 -0
  132. package/utils/api.js +5 -1
  133. package/utils/auth.js +57 -21
@@ -3,6 +3,7 @@ const fs = require('fs');
3
3
  const path = require('path');
4
4
  const { loadManifest, saveManifest } = require('../lib/manifest');
5
5
  const { removeBaseContents, writeBaseContents } = require('../lib/company-brain-sync');
6
+ const { collectCloudOrphans } = require('../commands/cloud');
6
7
 
7
8
  const WATCH_IGNORED_DIRS = new Set([
8
9
  '.git', '.atris', '.claude', '.cursor', '.next', '.cache',
@@ -31,6 +32,8 @@ function parseBusinessSyncArgs(args = []) {
31
32
  const resolveFlag = parseFlagValue(args, '--resolve', null);
32
33
  const resolve = resolveFlag || (resolveIdx !== -1 ? positional[resolveIdx + 1] : null);
33
34
  const resolvePath = parseFlagValue(args, '--path', parseFlagValue(args, '--file', null));
35
+ const takeRaw = parseFlagValue(args, '--take', null);
36
+ const take = takeRaw ? String(takeRaw).trim().toLowerCase() : null;
34
37
  const commandWords = new Set(['status', 'doctor', 'review', 'resolve', 'local', 'cloud', 'both', 'merge']);
35
38
  const slug = positional.find((arg) => !commandWords.has(arg)) || null;
36
39
  const dryRun = args.includes('--dry-run');
@@ -39,9 +42,10 @@ function parseBusinessSyncArgs(args = []) {
39
42
  const watch = args.includes('--watch');
40
43
  const intervalSec = Number.parseInt(parseFlagValue(args, '--interval', '60'), 10);
41
44
  const debounceSec = Number.parseInt(parseFlagValue(args, '--debounce', '5'), 10);
45
+ const showOrphans = args.includes('--show-orphans');
42
46
  const help = args.includes('--help') || args.includes('-h') || positional[0] === 'help';
43
47
 
44
- return { slug, dryRun, timeout, allowDelete, watch, intervalSec, debounceSec, status, review, resolve, resolvePath, help };
48
+ return { slug, dryRun, timeout, allowDelete, watch, intervalSec, debounceSec, status, review, resolve, resolvePath, take, showOrphans, help };
45
49
  }
46
50
 
47
51
  function readBusinessSlug(cwd = process.cwd()) {
@@ -322,6 +326,20 @@ function renderLocalSyncStatus(status) {
322
326
  } else {
323
327
  lines.push(' warnings: none');
324
328
  }
329
+ if (status.orphanError !== undefined) {
330
+ lines.push(` orphans: ${status.orphanError}`);
331
+ } else if (status.orphanPaths !== undefined) {
332
+ if (status.orphanPaths.length === 0) {
333
+ lines.push(' orphans: none');
334
+ } else {
335
+ const orphanLabel = status.orphanPaths.length === 1 ? 'file' : 'files';
336
+ lines.push(` orphans: ${status.orphanPaths.length} cloud ${orphanLabel} not present locally`);
337
+ status.orphanPaths.slice(0, 20).forEach((p) => lines.push(` - ${p.replace(/^\//, '')}`));
338
+ if (status.orphanPaths.length > 20) {
339
+ lines.push(` ... +${status.orphanPaths.length - 20} more`);
340
+ }
341
+ }
342
+ }
325
343
  if (status.heartbeat && status.heartbeat.updated_at) {
326
344
  lines.push(` watcher: last heartbeat ${status.heartbeat.updated_at} (${status.heartbeat.state || 'unknown'})`);
327
345
  } else {
@@ -334,15 +352,18 @@ function renderLocalSyncStatus(status) {
334
352
 
335
353
  function renderBusinessSyncHelp() {
336
354
  return [
337
- 'Usage: atris sync [business] [--dry-run] [--watch] [--status] [--review] [--resolve local|cloud|both|merge] [--path <file>] [--timeout 120]',
355
+ 'Usage: atris sync [business] [--dry-run] [--watch] [--status] [--show-orphans] [--review] [--take local|cloud] [--resolve local|cloud|both|merge] [--path <file>] [--timeout 120]',
338
356
  '',
339
357
  'Safe loop:',
340
358
  ' Pull -> Review -> Publish',
341
359
  '',
342
360
  'Commands:',
343
361
  ' atris sync --status Show local sync health',
362
+ ' atris sync --status --show-orphans Show local sync health plus cloud files not present locally',
344
363
  ' atris sync --dry-run Preview pull and publish plans without writing cloud',
345
- ' atris sync --review Read the latest conflict packet',
364
+ ' atris sync --review List conflicts; pick local / cloud / merge per file',
365
+ ' atris sync --review --take local|cloud',
366
+ ' Non-interactive: take local or cloud for every conflict',
346
367
  ' atris sync --resolve cloud|local|merge [--path <file>]',
347
368
  ' atris sync --watch Keep the workspace live with the same safety gates',
348
369
  '',
@@ -353,6 +374,147 @@ function renderBusinessSyncHelp() {
353
374
  ].join('\n');
354
375
  }
355
376
 
377
+ function readTextIfExists(filePath) {
378
+ try {
379
+ return fs.readFileSync(filePath, 'utf8');
380
+ } catch {
381
+ return '';
382
+ }
383
+ }
384
+
385
+ function buildConflictMarkers(localContent = '', remoteContent = '') {
386
+ const local = String(localContent);
387
+ const remote = String(remoteContent);
388
+ const localBody = local.endsWith('\n') || local === '' ? local : `${local}\n`;
389
+ const remoteBody = remote.endsWith('\n') || remote === '' ? remote : `${remote}\n`;
390
+ return `<<<<<<< local\n${localBody}=======\n${remoteBody}>>>>>>> cloud\n`;
391
+ }
392
+
393
+ function listConflictFiles(entries = []) {
394
+ const lines = [];
395
+ const count = entries.length;
396
+ if (count === 0) {
397
+ lines.push('no sync conflicts need review.');
398
+ return `${lines.join('\n')}\n`;
399
+ }
400
+ lines.push(`${count} file${count === 1 ? '' : 's'} conflict`);
401
+ entries.forEach((entry, index) => {
402
+ lines.push(` ${index + 1}. ${entry.targetRel}`);
403
+ });
404
+ lines.push('');
405
+ lines.push('pick local / cloud / merge per file (interactive), or run:');
406
+ lines.push(' atris sync --review --take local');
407
+ lines.push(' atris sync --review --take cloud');
408
+ lines.push('merge writes standard conflict markers into the file (no .remote dump).');
409
+ return `${lines.join('\n')}\n`;
410
+ }
411
+
412
+ function applyReviewChoice(cwd, entry, choice) {
413
+ const strategy = String(choice || '').trim().toLowerCase();
414
+ if (!['local', 'cloud', 'merge'].includes(strategy)) {
415
+ throw new Error(`unknown choice "${choice}". use local, cloud, or merge.`);
416
+ }
417
+
418
+ const targetPath = assertWorkspaceTarget(cwd, entry.targetRel);
419
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
420
+
421
+ if (strategy === 'merge') {
422
+ const localContent = readTextIfExists(entry.localPath);
423
+ const remoteContent = readTextIfExists(entry.remotePath);
424
+ fs.writeFileSync(targetPath, buildConflictMarkers(localContent, remoteContent), 'utf8');
425
+ } else {
426
+ const sourcePath = strategy === 'local' ? entry.localPath : entry.remotePath;
427
+ if (!fs.existsSync(sourcePath)) {
428
+ throw new Error(`missing ${strategy} artifact for ${entry.targetRel}`);
429
+ }
430
+ fs.copyFileSync(sourcePath, targetPath);
431
+ }
432
+
433
+ // Review path never leaves sibling .remote dumps next to workspace files.
434
+ cleanupResolvedConflictSidecars(cwd, entry.targetRel, { keepCloud: false });
435
+ cleanupConflictPacketArtifacts(entry);
436
+ return { path: entry.targetRel, choice: strategy };
437
+ }
438
+
439
+ async function promptReviewChoice(entry, ask) {
440
+ const answer = await ask(`${entry.targetRel}: local / cloud / merge? `);
441
+ const choice = String(answer || '').trim().toLowerCase();
442
+ if (!['local', 'cloud', 'merge'].includes(choice)) {
443
+ throw new Error(`invalid choice "${answer}". use local, cloud, or merge.`);
444
+ }
445
+ return choice;
446
+ }
447
+
448
+ /**
449
+ * S2 conflict handler for `atris sync --review`.
450
+ * Lists each conflicting file, then resolves via interactive prompt or `--take local|cloud`.
451
+ * Merge writes standard conflict markers into the target file (no workspace .remote dump).
452
+ */
453
+ async function runSyncReview(cwd = process.cwd(), options = {}) {
454
+ const take = options.take ? String(options.take).trim().toLowerCase() : null;
455
+ if (take && !['local', 'cloud'].includes(take)) {
456
+ return {
457
+ resolved: [],
458
+ choices: [],
459
+ message: 'use --take local or --take cloud (merge is interactive only).\n',
460
+ exitCode: 1,
461
+ };
462
+ }
463
+
464
+ const wantedTarget = normalizeResolvePath(options.path || options.resolvePath || null);
465
+ const entries = collectConflictResolutionEntries(cwd)
466
+ .filter((entry) => !wantedTarget || normalizeResolvePath(entry.targetRel) === wantedTarget);
467
+
468
+ if (entries.length === 0) {
469
+ // Fall back to packet summary when no resolvable .local artifacts exist.
470
+ return {
471
+ resolved: [],
472
+ choices: [],
473
+ message: renderLatestConflictReview(cwd),
474
+ exitCode: 0,
475
+ };
476
+ }
477
+
478
+ const lines = [];
479
+ lines.push(`${entries.length} file${entries.length === 1 ? '' : 's'} conflict`);
480
+ entries.forEach((entry, index) => {
481
+ lines.push(` ${index + 1}. ${entry.targetRel}`);
482
+ });
483
+
484
+ const interactive = typeof options.ask === 'function';
485
+ if (!take && !interactive) {
486
+ lines.push('');
487
+ lines.push('pick local / cloud / merge per file (interactive), or run:');
488
+ lines.push(' atris sync --review --take local');
489
+ lines.push(' atris sync --review --take cloud');
490
+ lines.push('merge writes standard conflict markers into the file (no .remote dump).');
491
+ lines.push('');
492
+ lines.push(renderLatestConflictReview(cwd).trimEnd());
493
+ lines.push('');
494
+ return { resolved: [], choices: [], message: `${lines.join('\n')}\n`, exitCode: 0 };
495
+ }
496
+
497
+ const choices = [];
498
+ const resolved = [];
499
+ for (const entry of entries) {
500
+ const choice = take || await promptReviewChoice(entry, options.ask);
501
+ const result = applyReviewChoice(cwd, entry, choice);
502
+ choices.push(result);
503
+ resolved.push(result.path);
504
+ lines.push(` -> ${result.path}: ${result.choice}`);
505
+ }
506
+
507
+ const finalizedManifests = cleanupEmptyConflictPackets(cwd, entries);
508
+ lines.push('');
509
+ lines.push(`resolved ${resolved.length} conflict${resolved.length === 1 ? '' : 's'}.`);
510
+ if (finalizedManifests.length) {
511
+ lines.push(`updated sync manifest for ${finalizedManifests.join(', ')}.`);
512
+ }
513
+ lines.push('next: run `atris sync --dry-run` before publishing.');
514
+ lines.push('');
515
+ return { resolved, choices, message: `${lines.join('\n')}\n`, exitCode: 0 };
516
+ }
517
+
356
518
  function renderLatestConflictReview(cwd = process.cwd()) {
357
519
  const summaries = listConflictSummaries(cwd);
358
520
  if (summaries.length === 0) {
@@ -782,7 +944,7 @@ async function runSyncCycle(plan, cwd, options = {}) {
782
944
  }
783
945
  }
784
946
 
785
- async function businessSync(args = process.argv.slice(3), cwd = process.cwd()) {
947
+ async function businessSync(args = process.argv.slice(3), cwd = process.cwd(), deps = {}) {
786
948
  const options = resolveBusinessSyncOptions(args, cwd);
787
949
 
788
950
  if (options.help) {
@@ -791,12 +953,32 @@ async function businessSync(args = process.argv.slice(3), cwd = process.cwd()) {
791
953
  }
792
954
 
793
955
  if (options.status) {
794
- process.stdout.write(renderLocalSyncStatus(collectLocalSyncStatus(cwd, options)));
956
+ const status = collectLocalSyncStatus(cwd, options);
957
+ if (options.showOrphans) {
958
+ try {
959
+ const { orphanPaths } = await collectCloudOrphans(cwd, options.slug, deps);
960
+ status.orphanPaths = orphanPaths;
961
+ } catch (err) {
962
+ status.orphanError = err.message || String(err);
963
+ }
964
+ }
965
+ process.stdout.write(renderLocalSyncStatus(status));
795
966
  return;
796
967
  }
797
968
 
798
969
  if (options.review) {
799
- process.stdout.write(renderLatestConflictReview(cwd));
970
+ let ask = null;
971
+ if (!options.take && process.stdin.isTTY) {
972
+ const { promptUser } = require('../utils/auth');
973
+ ask = (question) => promptUser(question);
974
+ }
975
+ const result = await runSyncReview(cwd, {
976
+ take: options.take,
977
+ path: options.resolvePath,
978
+ ask,
979
+ });
980
+ process.stdout.write(result.message);
981
+ if (result.exitCode && result.exitCode !== 0) process.exit(result.exitCode);
800
982
  return;
801
983
  }
802
984
 
@@ -894,8 +1076,10 @@ async function businessSync(args = process.argv.slice(3), cwd = process.cwd()) {
894
1076
  }
895
1077
 
896
1078
  module.exports = {
1079
+ applyReviewChoice,
897
1080
  businessSync,
898
1081
  buildBusinessSyncPlan,
1082
+ buildConflictMarkers,
899
1083
  canPreviewPush,
900
1084
  collectBrainSnapshot,
901
1085
  collectWorkspaceSnapshot,
@@ -903,6 +1087,7 @@ module.exports = {
903
1087
  collectLocalSyncStatus,
904
1088
  collectConflictResolutionEntries,
905
1089
  describeWatchFailure,
1090
+ listConflictFiles,
906
1091
  parseBusinessSyncArgs,
907
1092
  readBusinessSlug,
908
1093
  renderLatestConflictReview,
@@ -910,6 +1095,7 @@ module.exports = {
910
1095
  renderBusinessSyncHelp,
911
1096
  resolveLatestConflict,
912
1097
  resolveBusinessSyncOptions,
1098
+ runSyncReview,
913
1099
  safeLineMerge,
914
1100
  safeMarkdownMerge,
915
1101
  shouldIgnoreWatchPath,
package/commands/clean.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const fs = require('fs');
2
+ const os = require('os');
2
3
  const path = require('path');
3
4
  const escapeRegExp = require('../lib/escape-regexp');
4
5
 
@@ -250,7 +251,7 @@ function findStaleTasks(atrisDir) {
250
251
  * Detects both out-of-bounds AND drift (symbol moved to different line)
251
252
  * Returns { healed: number, unhealable: array }
252
253
  */
253
- function healBrokenMapRefs(cwd, atrisDir, dryRun = false) {
254
+ function healBrokenMapRefs(cwd, atrisDir, dryRun = false, homeDir = os.homedir()) {
254
255
  const mapFile = path.join(atrisDir, 'MAP.md');
255
256
  if (!fs.existsSync(mapFile)) return { healed: 0, unhealable: [] };
256
257
 
@@ -261,12 +262,12 @@ function healBrokenMapRefs(cwd, atrisDir, dryRun = false) {
261
262
  // Match both `file.js:123` and `file.js:123-456` with surrounding context
262
263
  // [^\S\n] = horizontal whitespace only (no newlines)
263
264
  // Required delimiter [(,[,—,-] prevents bleeding into adjacent refs on same line
264
- 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;
265
+ 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;
265
266
 
266
267
  // Function Inventory form: `symbolName()` → `file:line[-line]`
267
268
  // Pre-scan to build a (file:line) → symbol map so refs with the symbol BEFORE them still verify.
268
269
  const preRefSymbols = {};
269
- const preRefPattern = /`([a-zA-Z_][a-zA-Z0-9_]*)\s*\(?\s*\)?`\s*(?:→|->)\s*`([a-zA-Z0-9_\-./\\]+\.(?:js|ts|py|go|rs|rb|java|c|cpp|h|hpp|md|json|yaml|yml)):(\d+)(?:-\d+)?`/g;
270
+ const preRefPattern = /`([a-zA-Z_][a-zA-Z0-9_]*)\s*\(?\s*\)?`\s*(?:→|->)\s*`([a-zA-Z0-9_~\-./\\]+\.(?:js|ts|py|go|rs|rb|java|c|cpp|h|hpp|md|json|yaml|yml)):(\d+)(?:-\d+)?`/g;
270
271
  let preMatch;
271
272
  while ((preMatch = preRefPattern.exec(mapContent)) !== null) {
272
273
  preRefSymbols[`${preMatch[2]}:${preMatch[3]}`] = preMatch[1];
@@ -276,7 +277,10 @@ function healBrokenMapRefs(cwd, atrisDir, dryRun = false) {
276
277
  const fileCache = {};
277
278
  function readFileCached(filePath) {
278
279
  if (!fileCache[filePath]) {
279
- const fullPath = path.join(cwd, filePath);
280
+ const expandedPath = filePath.startsWith('~/') || filePath.startsWith('~\\')
281
+ ? path.join(homeDir, filePath.slice(2))
282
+ : filePath;
283
+ const fullPath = path.isAbsolute(expandedPath) ? expandedPath : path.join(cwd, expandedPath);
280
284
  if (!fs.existsSync(fullPath)) return null;
281
285
  try {
282
286
  const content = fs.readFileSync(fullPath, 'utf8');
@@ -320,7 +324,15 @@ function healBrokenMapRefs(cwd, atrisDir, dryRun = false) {
320
324
  // Find where the symbol actually is now
321
325
  const newStart = findSymbolLine(file.content, symbol);
322
326
  if (!newStart) {
323
- unhealable.push({ file: filePath, line: startLine, reason: `Symbol "${symbol}" not found` });
327
+ // A "symbol" that appears nowhere in the file is often descriptive
328
+ // prose from the annotation ("PATCH /api/...", "mission drain"), not
329
+ // a code identifier. Suppress those for in-bounds refs to existing
330
+ // files; still flag out-of-bounds refs and vanished identifiers
331
+ // (camelCase/snake_case words were plausibly real symbols).
332
+ const identifierLike = /[a-z][^\s]*[A-Z]|_/.test(symbol);
333
+ if (outOfBounds || identifierLike) {
334
+ unhealable.push({ file: filePath, line: startLine, reason: `Symbol "${symbol}" not found` });
335
+ }
324
336
  continue;
325
337
  }
326
338
 
@@ -404,28 +416,30 @@ function findFunctionEnd(lines, startLine) {
404
416
  function extractSymbol(context) {
405
417
  if (!context) return null;
406
418
 
407
- // Clean up the context
408
- const cleaned = context.trim()
409
- .replace(/`/g, '') // Strip backticks
410
- .replace(/^[\(\[—\-:]+\s*/, '') // Remove leading punctuation
411
- .replace(/[\)\]]+$/, '') // Remove trailing brackets
419
+ const original = context;
420
+
421
+ // Clean up the context without stripping parentheses from a symbol ending in ().
422
+ const unquoted = context.trim().replace(/`/g, '');
423
+ const wrapper = unquoted.match(/^([\(\[])/)?.[1];
424
+ let cleaned = unquoted
425
+ .replace(/^[\(\[—\-:]+\s*/, '')
412
426
  .trim();
427
+ if (wrapper === '(') cleaned = cleaned.replace(/\)$/, '').trim();
428
+ if (wrapper === '[') cleaned = cleaned.replace(/\]$/, '').trim();
413
429
 
414
430
  if (!cleaned) return null;
415
431
 
416
- // Try to extract a function/class/variable name
417
- // Pattern: word that looks like an identifier
418
- const identifierMatch = cleaned.match(/\b([a-zA-Z_][a-zA-Z0-9_]*(?:Atris|Entry|Handler|Cmd|Function|Class)?)\b/i);
432
+ const tokenMatch = cleaned.match(/^([a-zA-Z_][a-zA-Z0-9_]*)(\(\))?/);
433
+ if (!tokenMatch) return null;
419
434
 
420
- if (identifierMatch) {
421
- return identifierMatch[1];
422
- }
435
+ const symbol = tokenMatch[1];
436
+ const token = tokenMatch[0];
437
+ const codeShaped = symbol.includes('_') || /[a-z][A-Z]/.test(symbol) || token.endsWith('()');
438
+ if (codeShaped) return symbol;
423
439
 
424
- // Fallback: first word
425
- const firstWord = cleaned.split(/\s+/)[0];
426
- if (firstWord && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(firstWord)) {
427
- return firstWord;
428
- }
440
+ const isSingleWord = cleaned === symbol;
441
+ const isBackticked = original.includes(`\`${symbol}\``);
442
+ if (isSingleWord && isBackticked) return symbol;
429
443
 
430
444
  return null;
431
445
  }
@@ -445,7 +459,7 @@ function findSymbolLine(fileContent, symbol) {
445
459
  new RegExp(`^\\s*class\\s+${esc}\\b`), // class name
446
460
  new RegExp(`^\\s*${esc}\\s*[:(]`), // name: or name(
447
461
  new RegExp(`exports\\.${esc}\\s*=`), // exports.name =
448
- new RegExp(`^\\s*def\\s+${esc}\\s*\\(`), // def name( (Python)
462
+ new RegExp(`^\\s*(async\\s+)?def\\s+${esc}\\s*\\(`), // [async] def name( (Python)
449
463
  ];
450
464
 
451
465
  for (const pattern of patterns) {
@@ -465,15 +479,27 @@ function findSymbolLine(fileContent, symbol) {
465
479
  */
466
480
  function findStalePages(cwd, atrisDir) {
467
481
  const stalePages = [];
482
+ const skippedDirs = new Set(['archive', 'runs', 'qa', 'node_modules', '.git']);
483
+ const currentYear = new Date().getFullYear();
484
+
485
+ function shouldSkipDir(dir, name) {
486
+ if (skippedDirs.has(name)) return true;
487
+ if (/^\d{4}$/.test(name) && path.basename(path.dirname(dir)) === 'logs') {
488
+ return Number(name) < currentYear;
489
+ }
490
+ return false;
491
+ }
468
492
 
469
493
  function scanDir(dir) {
470
494
  if (!fs.existsSync(dir)) return;
471
495
  const entries = fs.readdirSync(dir, { withFileTypes: true });
472
496
  for (const entry of entries) {
473
497
  const fullPath = path.join(dir, entry.name);
474
- if (entry.isDirectory() && entry.name !== 'archive' && entry.name !== 'node_modules') {
498
+ if (entry.isDirectory()) {
499
+ if (shouldSkipDir(fullPath, entry.name)) continue;
475
500
  scanDir(fullPath);
476
- } else if (entry.isFile() && entry.name.endsWith('.md')) {
501
+ } else if (entry.isFile()) {
502
+ if (!entry.name.endsWith('.md')) continue;
477
503
  const result = checkPageStaleness(cwd, fullPath);
478
504
  if (result) stalePages.push(result);
479
505
  }