atris 3.58.8 → 3.59.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 (40) hide show
  1. package/AGENTS.md +3 -2
  2. package/atris/skills/engines/SKILL.md +28 -1
  3. package/bin/atris.js +3 -0
  4. package/commands/autopilot.js +75 -5
  5. package/commands/clean.js +17 -0
  6. package/commands/doc-health.js +11 -6
  7. package/commands/engine.js +384 -4
  8. package/commands/host.js +53 -0
  9. package/commands/member.js +57 -6
  10. package/commands/mission.js +72 -8
  11. package/commands/one-lap.js +58 -3
  12. package/commands/run.js +4 -1
  13. package/commands/scout.js +1 -1
  14. package/commands/search.js +5 -3
  15. package/commands/unknowns.js +5 -0
  16. package/commands/verify.js +12 -4
  17. package/commands/visualize.js +20 -3
  18. package/commands/workflow.js +17 -6
  19. package/lib/dispatch-scout.js +31 -22
  20. package/lib/engine-registry.js +1482 -13
  21. package/lib/feedback-triage.js +2 -0
  22. package/lib/fleet.js +107 -11
  23. package/lib/host.js +336 -0
  24. package/lib/known-commands.js +1 -1
  25. package/lib/map-refs.js +44 -1
  26. package/lib/member-engine.js +253 -0
  27. package/lib/roster-markdown.js +206 -0
  28. package/lib/roster-models.js +179 -0
  29. package/lib/runner-command.js +94 -14
  30. package/lib/self-drive.js +9 -1
  31. package/lib/wish-audit.js +10 -6
  32. package/lib/wish-delegate.js +10 -0
  33. package/package.json +1 -1
  34. package/templates/members/host/MEMBER.md +23 -0
  35. package/templates/members/host/MISSION.md +11 -0
  36. package/templates/members/host/SOUL.md +7 -0
  37. package/templates/members/host/context/why.md +3 -0
  38. package/templates/members/host/goals.json +5 -0
  39. package/templates/members/host/goals.md +3 -0
  40. package/templates/members/host/skills/host/SKILL.md +13 -0
@@ -21,6 +21,7 @@ const {
21
21
  } = require('../lib/brief-ledger');
22
22
  const {
23
23
  resolveClaudeRunnerModel,
24
+ resolvePinnedRunnerModel,
24
25
  resolveClaudeRunnerBin,
25
26
  resolveClaudeRunnerCommandTemplate,
26
27
  buildRunnerCommand,
@@ -385,13 +386,59 @@ function resolveMissionRunnerSelection(value, options = {}) {
385
386
  exitMissionError(`Unknown ${noun} "${raw}". ${knownMissionRunnerText()}.`, 2, asJson);
386
387
  }
387
388
 
388
- function resolveMissionTickRunner(mission, root = process.cwd()) {
389
+ // The roster line's effort and time cap for this tick, when it sets them.
390
+ function rosterTickPins(engine) {
391
+ return {
392
+ ...(engine && engine.roster_effort ? { roster_effort: engine.roster_effort } : {}),
393
+ ...(engine && engine.roster_max_seconds ? { roster_max_seconds: engine.roster_max_seconds } : {}),
394
+ };
395
+ }
396
+
397
+ // A tick's time limit: the time left on the mission, capped by the roster
398
+ // line's "max 20 min" when it sets one, else by the default tick cap.
399
+ function missionTickTimeoutMs(mission, remainingMs) {
400
+ const cap = Number(mission && mission.roster_max_seconds) > 0
401
+ ? Number(mission.roster_max_seconds) * 1000
402
+ : MISSION_RUN_DEFAULTS.claudeTimeoutMs;
403
+ return clampTimeoutMs(remainingMs, cap);
404
+ }
405
+
406
+ function resolveMissionTickRunner(mission, root = process.cwd(), options = {}) {
389
407
  if (String(mission && mission.runner || '').trim().toLowerCase() !== MISSION_AUTO_RUNNER) {
390
408
  return { mission, engine_id: null, requested_engine: null, engine_fallback_reason: null };
391
409
  }
392
- const resolved = resolveEngineForRoleWithPreference('executor', root, mission.preferred_engine);
410
+ // A mission owned by a team member runs on that member's roster pick. An
411
+ // explicit preferred engine (or a run's --engine) still wins, and with no
412
+ // roster line behind the member, routing stays exactly as it was.
413
+ if (!String(mission.preferred_engine || '').trim() && mission.owner) {
414
+ const { memberRosterEngine } = require('../lib/member-engine');
415
+ const member = memberRosterEngine(mission.owner, root, options);
416
+ if (member) {
417
+ if (process.env.ATRIS_ROUTER_EXPLAIN !== '0') console.error(member.reason);
418
+ return {
419
+ mission: {
420
+ ...mission,
421
+ runner: member.engine.id,
422
+ runner_kind: 'engine',
423
+ ...(!mission.model && member.model ? { model: member.model } : {}),
424
+ ...rosterTickPins(member.engine),
425
+ },
426
+ engine_id: member.engine.id,
427
+ requested_engine: null,
428
+ engine_fallback_reason: null,
429
+ member_engine_reason: member.reason,
430
+ };
431
+ }
432
+ }
433
+ const resolved = resolveEngineForRoleWithPreference('executor', root, mission.preferred_engine, options);
393
434
  return {
394
- mission: resolved.engine ? { ...mission, runner: resolved.engine.id, runner_kind: 'engine' } : mission,
435
+ mission: resolved.engine ? {
436
+ ...mission,
437
+ runner: resolved.engine.id,
438
+ runner_kind: 'engine',
439
+ ...(!mission.model && resolved.engine.roster_model ? { model: resolved.engine.roster_model } : {}),
440
+ ...rosterTickPins(resolved.engine),
441
+ } : mission,
395
442
  engine_id: resolved.engine ? resolved.engine.id : null,
396
443
  requested_engine: resolved.requested_engine,
397
444
  engine_fallback_reason: resolved.engine_fallback_reason,
@@ -444,6 +491,20 @@ function applyMissionRunnerProfile(runner) {
444
491
  };
445
492
  }
446
493
 
494
+ // Claude-family engines share the claude CLI, so they keep the resolved
495
+ // default model pin. Every other engine's CLI rejects a claude model name, so
496
+ // it only ever takes an explicit pin (the mission's model or the roster
497
+ // line's); '' lets the engine ride its own default.
498
+ const CLAUDE_FAMILY_RUNNER_ENGINES = new Set(['claude', 'fable', 'haiku']);
499
+
500
+ function resolveMissionTickRunnerModel(mission) {
501
+ const engine = canonicalEngineName(mission && mission.runner);
502
+ if (!engine || CLAUDE_FAMILY_RUNNER_ENGINES.has(engine)) {
503
+ return resolveClaudeRunnerModel(mission);
504
+ }
505
+ return resolvePinnedRunnerModel(mission);
506
+ }
507
+
447
508
  function exitMissionError(message, code = 1, asJson = false) {
448
509
  if (asJson) {
449
510
  printCliJson(
@@ -8745,7 +8806,7 @@ function spawnGenericRunnerTick(mission, opts) {
8745
8806
  let briefId = null;
8746
8807
  try {
8747
8808
  promptFile = writeRunnerPromptFile(cwd, mission.id, prompt);
8748
- cmd = buildRunnerCommand({ promptFile, model });
8809
+ cmd = buildRunnerCommand({ promptFile, model, ...(mission.roster_effort ? { effort: mission.roster_effort } : {}) });
8749
8810
  const engine = canonicalEngineName(mission.runner);
8750
8811
  if (engine) {
8751
8812
  const record = appendBriefRecord(cwd, {
@@ -8922,6 +8983,7 @@ function spawnClaudeTick(mission, opts) {
8922
8983
  '--include-partial-messages',
8923
8984
  ];
8924
8985
  if (model) args.push('--model', model);
8986
+ if (mission.roster_effort) args.push('--effort', mission.roster_effort);
8925
8987
  if (sessionMode === 'set') args.push('--session-id', sessionId);
8926
8988
  else if (sessionMode === 'resume') args.push('--resume', sessionId);
8927
8989
 
@@ -9088,7 +9150,7 @@ async function runEngineVerifier(mission, options = {}) {
9088
9150
  signal: options.signal,
9089
9151
  timeoutMs: ENGINE_VERIFY_TIMEOUT_MS,
9090
9152
  prompt: buildEngineVerifyPrompt(mission, options.tickIndex || 1),
9091
- model: resolveClaudeRunnerModel(verifyMission),
9153
+ model: resolveMissionTickRunnerModel(verifyMission),
9092
9154
  });
9093
9155
  } catch (error) {
9094
9156
  engineResult = { ok: false, error: error.message };
@@ -9668,12 +9730,12 @@ async function executeMissionRunTicksPhase(context) {
9668
9730
  const runClaudeSession = () => spawnClaudeTick(tickRuntimeMission, {
9669
9731
  sessionMode, sessionId: useId, cwd, signal: controller.signal,
9670
9732
  missionLock: lock,
9671
- timeoutMs: clampTimeoutMs(
9733
+ timeoutMs: missionTickTimeoutMs(
9734
+ tickRuntimeMission,
9672
9735
  (maxWallSeconds - ((Date.now() - startedAt) / 1000)) * 1000,
9673
- MISSION_RUN_DEFAULTS.claudeTimeoutMs,
9674
9736
  ),
9675
9737
  prompt,
9676
- model: resolveClaudeRunnerModel(tickRuntimeMission),
9738
+ model: resolveMissionTickRunnerModel(tickRuntimeMission),
9677
9739
  });
9678
9740
  try {
9679
9741
  claudeResult = await runClaudeSession();
@@ -11726,6 +11788,8 @@ module.exports = {
11726
11788
  missionHumanStatusText,
11727
11789
  resolveMissionRunnerSelection,
11728
11790
  resolveMissionTickRunner,
11791
+ resolveMissionTickRunnerModel,
11792
+ missionTickTimeoutMs,
11729
11793
  engineFailureHealthStatus,
11730
11794
  recordMissionEngineTickOutcome,
11731
11795
  tickMadeProgress,
@@ -9,7 +9,10 @@ const { readWishes } = require('../lib/wish-store');
9
9
  const {
10
10
  engineRegistryView,
11
11
  resolveEngineForRole,
12
+ resolveEngineForRoleRanked,
12
13
  resolveRegisteredEngine,
14
+ rosterPinFor,
15
+ rosterDecided,
13
16
  } = require('../lib/engine-registry');
14
17
  const { parseVerifyCommand } = require('../lib/auto-accept-certified');
15
18
  const fleet = require('../lib/fleet');
@@ -155,7 +158,8 @@ function readyExecutor(root, preferred = '') {
155
158
  if (!selected.roles.includes('executor')) throw new Error(`engine ${selected.id} is not an executor`);
156
159
  if (!selected.health || selected.health.status !== 'ready') throw new Error(`engine ${selected.id} is not ready`);
157
160
  if (!fleet.FLEET_CAPABLE.includes(selected.id)) throw new Error(`engine ${selected.id} cannot build headlessly`);
158
- return selected;
161
+ const pin = rosterPinFor('executor', selected.id, root);
162
+ return Object.keys(pin).length ? { ...selected, ...pin } : selected;
159
163
  }
160
164
  const routed = resolveEngineForRole('executor', root);
161
165
  if (routed && fleet.FLEET_CAPABLE.includes(routed.id)) return routed;
@@ -168,16 +172,30 @@ function readyExecutor(root, preferred = '') {
168
172
 
169
173
  function readyValidators(root, preferred = '', exclude = '') {
170
174
  const blocked = String(exclude || '').trim();
175
+ const rosterRanked = resolveEngineForRoleRanked('validator', root);
176
+ const fromRoster = rosterDecided(rosterRanked.source);
177
+ const rosterOrder = !preferred && fromRoster
178
+ ? rosterRanked.ranked.map((engine) => engine.id)
179
+ : [];
180
+ const pinned = fromRoster && rosterRanked.engine
181
+ && (rosterRanked.engine.roster_model || rosterRanked.engine.roster_effort || rosterRanked.engine.roster_max_seconds)
182
+ ? rosterRanked.engine
183
+ : null;
184
+ // A roster pick can name an engine that reviews only when picked (codex),
185
+ // so the roster's list joins even when an engine lacks the role.
186
+ const rosterPicked = new Set(fromRoster ? rosterRanked.ranked.map((engine) => engine.id) : []);
171
187
  const candidates = engineRegistryView(root)
172
- .filter((engine) => engine.roles.includes('validator'))
188
+ .filter((engine) => engine.roles.includes('validator') || rosterPicked.has(engine.id))
173
189
  .filter((engine) => engine.id !== blocked)
174
190
  .sort((a, b) => {
175
191
  const aPreferred = preferred && a.id === preferred ? 0 : 1;
176
192
  const bPreferred = preferred && b.id === preferred ? 0 : 1;
177
193
  return aPreferred - bPreferred
194
+ || (rosterOrder.length ? (rosterOrder.indexOf(a.id) - rosterOrder.indexOf(b.id)) : 0)
178
195
  || Number(a.fallback_order) - Number(b.fallback_order)
179
196
  || String(a.id).localeCompare(String(b.id));
180
- });
197
+ })
198
+ .map((engine) => (pinned && engine.id === pinned.id ? { ...engine, ...pinFields(pinned) } : engine));
181
199
  const ready = candidates.filter((engine) => engine.health && engine.health.status === 'ready');
182
200
  // Route-time determinism: which validator binaries exist on this machine
183
201
  // must not change the lap's route. When none are ready, dispatch with the
@@ -187,6 +205,39 @@ function readyValidators(root, preferred = '', exclude = '') {
187
205
  return ready.length ? ready : candidates;
188
206
  }
189
207
 
208
+ function pinFields(engine) {
209
+ const pin = {};
210
+ for (const field of ['roster_model', 'roster_effort', 'roster_max_seconds']) {
211
+ if (engine && engine[field]) pin[field] = engine[field];
212
+ }
213
+ return pin;
214
+ }
215
+
216
+ // The roster's pins ride into the flight: the builder's model, effort, and
217
+ // time cap for the builder, and each reviewer's for that reviewer only. A
218
+ // reviewer with only a model keeps the plain model string.
219
+ function lapModelPins(executor, validators = []) {
220
+ const validatorModels = {};
221
+ for (const engine of validators) {
222
+ if (!engine) continue;
223
+ if (engine.roster_effort || engine.roster_max_seconds) {
224
+ validatorModels[engine.id] = {
225
+ model: engine.roster_model || '',
226
+ effort: engine.roster_effort || '',
227
+ max_seconds: engine.roster_max_seconds || 0,
228
+ };
229
+ } else if (engine.roster_model) {
230
+ validatorModels[engine.id] = engine.roster_model;
231
+ }
232
+ }
233
+ return {
234
+ model: executor && executor.roster_model || '',
235
+ ...(executor && executor.roster_effort ? { effort: executor.roster_effort } : {}),
236
+ ...(executor && executor.roster_max_seconds ? { maxSeconds: executor.roster_max_seconds } : {}),
237
+ validatorModels: Object.keys(validatorModels).length ? validatorModels : null,
238
+ };
239
+ }
240
+
190
241
  function taskById(runCli, taskId) {
191
242
  const result = runCli(['task', 'show', taskId, '--json']);
192
243
  if (!result || result.status !== 0) return null;
@@ -656,6 +707,7 @@ async function runOneLap(ask, options = {}) {
656
707
  reviewOnly: true,
657
708
  verifierCommand: verifier,
658
709
  validatorEngines: validators.map((entry) => entry.id),
710
+ ...lapModelPins(executor, validators),
659
711
  actor: mission.owner || 'mission-lead',
660
712
  installedEngines: engineRegistryView(root)
661
713
  .filter((entry) => entry.health && entry.health.status === 'ready')
@@ -773,4 +825,7 @@ async function runOneLap(ask, options = {}) {
773
825
  module.exports = {
774
826
  oneLapSafetyIssue,
775
827
  runOneLap,
828
+ readyExecutor,
829
+ readyValidators,
830
+ lapModelPins,
776
831
  };
package/commands/run.js CHANGED
@@ -9,6 +9,7 @@
9
9
 
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
+ const { mapNotesPath } = require('../lib/map-refs');
12
13
  const { execSync, spawnSync } = require('child_process');
13
14
  const { getLogPath, ensureLogDirectory, createLogFile } = require('../lib/journal');
14
15
  const { parseTodo } = require('../lib/todo');
@@ -118,11 +119,12 @@ function execPhaseCommandSync(cmd, opts = {}) {
118
119
  * what the validator found - closing the try → notice → adjust loop.
119
120
  */
120
121
  function buildRunPrompt(phase, context, priorCycleReview) {
121
- const { mapPath, todoPath, personaPath, lessonsPath, journalPath } = context;
122
+ const { mapPath, notesPath, todoPath, personaPath, lessonsPath, journalPath } = context;
122
123
 
123
124
  const readFiles = [
124
125
  personaPath && `- ${personaPath}`,
125
126
  mapPath && `- ${mapPath}`,
127
+ notesPath && `- ${notesPath}`,
126
128
  todoPath && `- ${todoPath}`,
127
129
  lessonsPath && `- ${lessonsPath}`,
128
130
  journalPath && `- ${journalPath}`,
@@ -413,6 +415,7 @@ async function runAtris(options = {}) {
413
415
  // Build context paths
414
416
  const context = {
415
417
  mapPath: fs.existsSync(path.join(atrisDir, 'MAP.md')) ? 'atris/MAP.md' : null,
418
+ notesPath: mapNotesPath(path.dirname(atrisDir)),
416
419
  todoPath: fs.existsSync(path.join(atrisDir, 'TODO.md')) ? 'atris/TODO.md' : null,
417
420
  personaPath: fs.existsSync(path.join(atrisDir, 'PERSONA.md')) ? 'atris/PERSONA.md' : null,
418
421
  lessonsPath: fs.existsSync(path.join(atrisDir, 'lessons.md')) ? 'atris/lessons.md' : null,
package/commands/scout.js CHANGED
@@ -53,7 +53,7 @@ const ENGINES = {
53
53
  function scoutPrompt(question, dir) {
54
54
  return [
55
55
  `You are a read-only scout in ${dir}. You NEVER write, edit, or run mutating commands.`,
56
- `Search order: index files first (atris/MAP.md, atris/TODO.md, README) via targeted reads, then grep. Cheap reads only.`,
56
+ `Search order: index files first (atris/MAP.md, atris/refs/MAP-NOTES.md if present for exact file:line refs, atris/TODO.md, README) via targeted reads, then grep. Cheap reads only.`,
57
57
  ``,
58
58
  `Question: ${question}`,
59
59
  ``,
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const { existingMapDocs } = require('../lib/map-refs');
5
6
 
6
7
  const MEMORY_LAYERS = [
7
8
  { key: 'features', dir: path.join('atris', 'features') },
@@ -226,10 +227,11 @@ function scanLayer(root, layer, terms) {
226
227
  return scanFiles(root, layer.key, layer.dir.split(path.sep).join('/'), listMarkdownFiles(dir), terms);
227
228
  }
228
229
 
230
+ // The map layer covers the boot map and the notes file that holds its deep refs.
229
231
  function scanMap(root, terms) {
230
- const mapPath = path.join(root, 'atris', 'MAP.md');
231
- if (!fs.existsSync(mapPath)) return emptyLayerResult('map', 'atris/MAP.md');
232
- return scanFiles(root, 'map', 'atris/MAP.md', [mapPath], terms);
232
+ const files = existingMapDocs(root).map((doc) => path.join(root, doc));
233
+ if (!files.length) return emptyLayerResult('map', 'atris/MAP.md');
234
+ return scanFiles(root, 'map', 'atris/MAP.md', files, terms);
233
235
  }
234
236
 
235
237
  function scanSource(root, terms) {
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const { mapNotesPath } = require('../lib/map-refs');
5
6
  const { spawnSync } = require('child_process');
6
7
  const taskDb = require('../lib/task-db');
7
8
  const {
@@ -167,6 +168,8 @@ function formatTaskEvents(rows) {
167
168
 
168
169
  function gatherTerritoryContext(root, db) {
169
170
  const map = readFileBestEffort(path.join(root, 'atris', 'MAP.md'), { lines: 100 });
171
+ const notesPath = mapNotesPath(root);
172
+ const notes = notesPath ? readFileBestEffort(path.join(root, notesPath), { lines: 100 }) : '';
170
173
  const lessonsMd = readFileBestEffort(path.join(root, 'atris', 'lessons.md'), { maxChars: READ_LIMIT });
171
174
  const lessonsJson = readFileBestEffort(path.join(root, 'atris', 'lessons.json'), { maxChars: READ_LIMIT });
172
175
  const journals = readRecentJournals(root);
@@ -176,6 +179,7 @@ function gatherTerritoryContext(root, db) {
176
179
  return [
177
180
  section('workspace_root', root),
178
181
  section('atris/MAP.md first 100 lines', map),
182
+ ...(notesPath ? [section(`${notesPath} first 100 lines`, notes)] : []),
179
183
  section('atris/lessons.md', lessonsMd),
180
184
  section('atris/lessons.json', lessonsJson),
181
185
  section('last 3 daily journals', journals),
@@ -614,4 +618,5 @@ module.exports = {
614
618
  unknownsCommand,
615
619
  showHelp,
616
620
  fallbackPayload,
621
+ gatherTerritoryContext,
617
622
  };
@@ -2,6 +2,7 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { spawnSync } = require('child_process');
4
4
  const escapeRegExp = require('../lib/escape-regexp');
5
+ const { existingMapDocs } = require('../lib/map-refs');
5
6
 
6
7
  /**
7
8
  * atris verify [task] - Validate work is actually done
@@ -397,13 +398,13 @@ function checkDocsVsChanges(cwd, atrisDir) {
397
398
  return result;
398
399
  }
399
400
 
400
- // Check if significant files are in MAP.md
401
+ // Check if significant files are in MAP.md or the notes file it points to
401
402
  const mapFile = path.join(atrisDir, 'MAP.md');
402
403
  if (!fs.existsSync(mapFile)) {
403
404
  return result;
404
405
  }
405
406
 
406
- const mapContent = fs.readFileSync(mapFile, 'utf8');
407
+ const mapContent = mapDocsText(atrisDir);
407
408
 
408
409
  const significantExtensions = ['.js', '.ts', '.py', '.go', '.rs', '.rb', '.java'];
409
410
  const significantChanges = changedFiles.filter(f =>
@@ -547,6 +548,12 @@ function verifyChange(cwd, change) {
547
548
  };
548
549
  }
549
550
 
551
+ // A file counts as documented when the boot map or atris/refs/MAP-NOTES.md names it.
552
+ function mapDocsText(atrisDir) {
553
+ const root = path.dirname(atrisDir);
554
+ return existingMapDocs(root).map((doc) => fs.readFileSync(path.join(root, doc), 'utf8')).join('\n');
555
+ }
556
+
550
557
  /**
551
558
  * Check if MAP.md documents the expected files
552
559
  */
@@ -556,7 +563,7 @@ function checkMapForFiles(atrisDir, changes) {
556
563
  return { documented: false };
557
564
  }
558
565
 
559
- const content = fs.readFileSync(mapFile, 'utf8');
566
+ const content = mapDocsText(atrisDir);
560
567
  const fileChanges = changes.filter(c => c.type === 'file');
561
568
 
562
569
  if (fileChanges.length === 0) {
@@ -725,5 +732,6 @@ module.exports = {
725
732
  escapeRegExp,
726
733
  mapCoverage,
727
734
  isPathCovered,
728
- normalizeMapPath
735
+ normalizeMapPath,
736
+ checkMapForFiles,
729
737
  };
@@ -1,5 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
+ const { mapNotesPath } = require('../lib/map-refs');
3
4
  const { spawnSync } = require('child_process');
4
5
  const { getLogPath } = require('../lib/journal');
5
6
  const { ensureValidCredentials } = require('../utils/auth');
@@ -153,14 +154,16 @@ function readBusinessMeta(cwd = process.cwd()) {
153
154
  }
154
155
  }
155
156
 
157
+ function promptWords(prompt) {
158
+ return new Set(String(prompt || '').toLowerCase().split(/[^a-z0-9]+/).filter(w => w.length >= 4));
159
+ }
160
+
156
161
  function findRelevantContextFiles(cwd, prompt) {
157
162
  const roots = [
158
163
  path.join(cwd, 'atris', 'context'),
159
164
  path.join(cwd, 'atris', 'wiki'),
160
165
  ];
161
- const words = new Set(
162
- prompt.toLowerCase().split(/[^a-z0-9]+/).filter(w => w.length >= 4)
163
- );
166
+ const words = promptWords(prompt);
164
167
  const files = [];
165
168
 
166
169
  function walk(dir) {
@@ -192,6 +195,17 @@ function findRelevantContextFiles(cwd, prompt) {
192
195
  .slice(0, 4);
193
196
  }
194
197
 
198
+ // The deep map is large: keep only the lines that share a word with the prompt.
199
+ function mapNotesExcerpt(cwd, prompt, maxChars) {
200
+ const notesPath = mapNotesPath(cwd);
201
+ if (!notesPath) return '';
202
+ const words = promptWords(prompt);
203
+ if (!words.size) return '';
204
+ const lines = fs.readFileSync(path.join(cwd, notesPath), 'utf8').split(/\r?\n/)
205
+ .filter(line => [...words].some(word => line.toLowerCase().includes(word)));
206
+ return lines.join('\n').slice(0, maxChars);
207
+ }
208
+
195
209
  function collectWorkspaceContext(prompt, cwd = process.cwd()) {
196
210
  const business = readBusinessMeta(cwd);
197
211
  const chunks = [];
@@ -201,6 +215,8 @@ function collectWorkspaceContext(prompt, cwd = process.cwd()) {
201
215
 
202
216
  const mapSnippet = readTextIfExists(path.join(cwd, 'atris', 'MAP.md'), 1400);
203
217
  if (mapSnippet) chunks.push(`MAP excerpt:\n${mapSnippet}`);
218
+ const notesSnippet = mapNotesExcerpt(cwd, prompt, 1400);
219
+ if (notesSnippet) chunks.push(`MAP notes excerpt:\n${notesSnippet}`);
204
220
 
205
221
  const todoSnippet = readTextIfExists(path.join(cwd, 'atris', 'TODO.md'), 1000);
206
222
  if (todoSnippet) chunks.push(`TODO excerpt:\n${todoSnippet}`);
@@ -389,4 +405,5 @@ async function visualizeAtris(args = process.argv.slice(3)) {
389
405
 
390
406
  module.exports = {
391
407
  visualizeAtris,
408
+ collectWorkspaceContext,
392
409
  };
@@ -1,5 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
+ const { mapNotesPath } = require('../lib/map-refs');
3
4
  const { getLogPath } = require('../lib/journal');
4
5
  const {
5
6
  buildFirstMinute,
@@ -491,6 +492,19 @@ async function runAtris2Local(userInput, atris2Mode) {
491
492
  }
492
493
  }
493
494
 
495
+ // Where the agent finds code. When the short routing map has a notes file,
496
+ // point line lookups there so the agent does not hunt for refs the map lacks.
497
+ function mapPromptSection(targetDir, cwd = process.cwd()) {
498
+ const mapFile = path.join(targetDir, 'MAP.md');
499
+ if (!fs.existsSync(mapFile)) return '';
500
+ const mapRef = path.relative(cwd, mapFile);
501
+ const notes = mapNotesPath(path.dirname(targetDir));
502
+ if (!notes) return `## MAP.md\nRead this file for file:line references: ${mapRef}\n\n`;
503
+ const notesRef = path.relative(cwd, path.join(path.dirname(targetDir), notes));
504
+ return `## MAP.md\nRead this file to find where work lives: ${mapRef}\n\n`
505
+ + `## MAP-NOTES.md\nRead this file for exact file:line references: ${notesRef}\n\n`;
506
+ }
507
+
494
508
  async function planAtris(userInput = null) {
495
509
  const { loadConfig } = require('../utils/config');
496
510
  const { loadCredentials, ensureValidCredentials } = require('../utils/auth');
@@ -773,9 +787,7 @@ async function planAtris(userInput = null) {
773
787
  systemPrompt += '## PERSONA.md\n' + fs.readFileSync(personaPath, 'utf8') + '\n\n';
774
788
  }
775
789
 
776
- if (mapFileRef) {
777
- systemPrompt += `## MAP.md\nRead this file for file:line references: ${mapFileRef}\n\n`;
778
- }
790
+ systemPrompt += mapPromptSection(targetDir);
779
791
 
780
792
  // Build user prompt with context
781
793
  let userPrompt = `You are the Navigator. Take ideas from Inbox → break them down into perfect, manageable tasks.\n\n`;
@@ -1253,9 +1265,7 @@ async function doAtris() {
1253
1265
  if (persona) {
1254
1266
  systemPrompt += '## PERSONA.md\n' + persona + '\n\n';
1255
1267
  }
1256
- if (mapPath) {
1257
- systemPrompt += `## MAP.md\nRead this file for file:line references: ${mapPath}\n\n`;
1258
- }
1268
+ systemPrompt += mapPromptSection(targetDir);
1259
1269
  if (profile) {
1260
1270
  systemPrompt += `## PROJECT CONTEXT\nType: ${context}\nProfile: ${JSON.stringify(profile, null, 2)}\n\n`;
1261
1271
  }
@@ -1898,6 +1908,7 @@ async function executeAgentSDKFast(userInput) {
1898
1908
  }
1899
1909
 
1900
1910
  module.exports = {
1911
+ mapPromptSection,
1901
1912
  planAtris,
1902
1913
  doAtris,
1903
1914
  reviewAtris,
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { spawnSync } = require('child_process');
6
+ const { MAP_REF_DOCS, existingMapDocs } = require('./map-refs');
6
7
  const {
7
8
  buildReadOnlyEngineInvocation,
8
9
  runAskProcess,
@@ -91,9 +92,8 @@ function seedScoutContext({ task, worktreePath }) {
91
92
  const trackedList = trackedFiles(worktreePath);
92
93
  const tracked = new Set(trackedList);
93
94
  const keywords = titleKeywords(task && task.title);
94
- const mapPath = 'atris/MAP.md';
95
- let mapText = '';
96
- try { mapText = fs.readFileSync(path.join(worktreePath, mapPath), 'utf8'); } catch {}
95
+ const mapPath = MAP_REF_DOCS[0];
96
+ const mapTexts = readMapTexts(worktreePath);
97
97
 
98
98
  const scores = new Map();
99
99
  const gotchaCandidates = [];
@@ -103,18 +103,21 @@ function seedScoutContext({ task, worktreePath }) {
103
103
  };
104
104
  if (tracked.has(mapPath)) addScore(mapPath, 1);
105
105
 
106
- const mapLines = mapText.split(/\r?\n/);
107
- for (let index = 0; index < mapLines.length; index += 1) {
108
- const line = mapLines[index];
109
- const lower = line.toLowerCase();
110
- const matches = keywords.filter((keyword) => lower.includes(keyword));
111
- const refs = mapPathRefs(line, tracked);
112
- for (const ref of refs) {
113
- const pathMatches = keywords.filter((keyword) => ref.toLowerCase().includes(keyword)).length;
114
- if (matches.length || pathMatches) addScore(ref, 80 + (matches.length * 5) + (pathMatches * 8));
115
- }
116
- if (matches.length && line.trim() && line.trim().length <= 300 && gotchaCandidates.length < 16) {
117
- gotchaCandidates.push({ line: index + 1, text: line.trim() });
106
+ // The boot map routes; the notes file holds the deep file:line refs. Read both.
107
+ for (const [doc, mapText] of mapTexts) {
108
+ const mapLines = mapText.split(/\r?\n/);
109
+ for (let index = 0; index < mapLines.length; index += 1) {
110
+ const line = mapLines[index];
111
+ const lower = line.toLowerCase();
112
+ const matches = keywords.filter((keyword) => lower.includes(keyword));
113
+ const refs = mapPathRefs(line, tracked);
114
+ for (const ref of refs) {
115
+ const pathMatches = keywords.filter((keyword) => ref.toLowerCase().includes(keyword)).length;
116
+ if (matches.length || pathMatches) addScore(ref, 80 + (matches.length * 5) + (pathMatches * 8));
117
+ }
118
+ if (matches.length && line.trim() && line.trim().length <= 300 && gotchaCandidates.length < 16) {
119
+ gotchaCandidates.push({ ...(doc === mapPath ? {} : { file: doc }), line: index + 1, text: line.trim() });
120
+ }
118
121
  }
119
122
  }
120
123
 
@@ -268,15 +271,22 @@ function verifyHit(hit, { worktreePath, tracked, allowed }) {
268
271
  return { path: citedPath, line: nearest.line, excerpt, why };
269
272
  }
270
273
 
271
- function verifyMapGotcha(gotcha, mapText) {
274
+ // Map text by doc, boot map first. A missing doc is simply absent.
275
+ function readMapTexts(root) {
276
+ return new Map(existingMapDocs(root).map((doc) => [doc, fs.readFileSync(path.join(root, doc), 'utf8')]));
277
+ }
278
+
279
+ function verifyMapGotcha(gotcha, mapTexts) {
272
280
  if (!gotcha || typeof gotcha !== 'object' || Array.isArray(gotcha)) return null;
273
281
  const text = String(gotcha.text || '').trim();
274
282
  const citedLine = Number(gotcha.line);
283
+ const file = gotcha.file === undefined ? MAP_REF_DOCS[0] : String(gotcha.file);
275
284
  if (!text || text.length > 300 || !Number.isInteger(citedLine) || citedLine < 1) return null;
276
- const lines = mapText.split(/\r?\n/);
285
+ if (!mapTexts.has(file)) return null;
286
+ const lines = mapTexts.get(file).split(/\r?\n/);
277
287
  const actualLine = lines.findIndex((line) => line.trim() === text) + 1;
278
288
  if (!actualLine) return null;
279
- return { line: actualLine, text };
289
+ return { ...(file === MAP_REF_DOCS[0] ? {} : { file }), line: actualLine, text };
280
290
  }
281
291
 
282
292
  function verifyScoutPack(rawPack, { task, worktreePath, allowedFiles, expectedCommit = '' }) {
@@ -301,11 +311,10 @@ function verifyScoutPack(rawPack, { task, worktreePath, allowedFiles, expectedCo
301
311
  });
302
312
  if (hits.length < 2) return null;
303
313
 
304
- let mapText = '';
305
- try { mapText = fs.readFileSync(path.join(worktreePath, 'atris', 'MAP.md'), 'utf8'); } catch {}
314
+ const mapTexts = readMapTexts(worktreePath);
306
315
  const mapGotchas = (Array.isArray(rawPack.map_gotchas) ? rawPack.map_gotchas : [])
307
316
  .slice(0, SCOUT_MAX_MAP_GOTCHAS)
308
- .map((gotcha) => verifyMapGotcha(gotcha, mapText))
317
+ .map((gotcha) => verifyMapGotcha(gotcha, mapTexts))
309
318
  .filter(Boolean);
310
319
  const notChecked = (Array.isArray(rawPack.not_checked) ? rawPack.not_checked : [])
311
320
  .map((entry) => String(entry || '').trim())
@@ -360,7 +369,7 @@ function renderVerifiedScoutBlock(pack, { worktreePath }) {
360
369
  }
361
370
  if (pack.map_gotchas.length) {
362
371
  lines.push('MAP gotchas:');
363
- lines.push(...pack.map_gotchas.map((gotcha) => `- MAP line ${gotcha.line}: ${gotcha.text}`));
372
+ lines.push(...pack.map_gotchas.map((gotcha) => `- ${gotcha.file || 'MAP'} line ${gotcha.line}: ${gotcha.text}`));
364
373
  }
365
374
  if (pack.not_checked.length) lines.push(`Not checked: ${pack.not_checked.join('; ')}`);
366
375
  return lines.join('\n');