atris 3.80.0 → 3.82.1

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 (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +10 -0
  3. package/atris/atris.md +14 -0
  4. package/atris/features/README.md +15 -0
  5. package/atris/policies/README.md +24 -0
  6. package/atris/skills/README.md +61 -45
  7. package/atris/skills/design/SKILL.md +6 -5
  8. package/atris/skills/taste/SKILL.md +39 -0
  9. package/atris/skills/taste/notes.md +29 -0
  10. package/atris/skills/taste/principles.md +23 -0
  11. package/atris/skills/wiki/SKILL.md +14 -2
  12. package/atris/skills/wiki/index-lines.md +30 -0
  13. package/atris/skills/wiki/measure.md +20 -0
  14. package/atris/skills/wiki/note-shape.md +16 -0
  15. package/atris/skills/wiki/upkeep.md +16 -0
  16. package/atris/team/README.md +38 -0
  17. package/atris.md +14 -0
  18. package/bin/atris.js +1 -1
  19. package/commands/activate.js +1 -1
  20. package/commands/auth.js +2 -2
  21. package/commands/autoland.js +25 -26
  22. package/commands/autopilot-front.js +46 -1
  23. package/commands/autopilot.js +55 -25
  24. package/commands/clean.js +22 -11
  25. package/commands/computer.js +166 -2413
  26. package/commands/doc-health.js +48 -1
  27. package/commands/improve.js +16 -3
  28. package/commands/init.js +4 -0
  29. package/commands/integrations.js +69 -1616
  30. package/commands/mission.js +109 -13
  31. package/commands/release.js +41 -10
  32. package/commands/security-review.js +5 -1
  33. package/commands/site-deploy.js +5 -1
  34. package/commands/status.js +1 -1
  35. package/commands/sync.js +12 -8
  36. package/commands/task.js +144 -118
  37. package/commands/unknowns.js +9 -5
  38. package/commands/visualize.js +1 -12
  39. package/commands/workflow.js +4 -3
  40. package/commands/worktree.js +68 -1602
  41. package/commands/xp.js +23 -2113
  42. package/commands/youtube.js +192 -3110
  43. package/lib/business-core.js +3 -4
  44. package/lib/campaign-run-lock.js +12 -1
  45. package/lib/computer-core.js +2447 -0
  46. package/lib/engine-ask.js +1 -1
  47. package/lib/engine-receipt-sweep.js +64 -0
  48. package/lib/file-lock.js +175 -0
  49. package/lib/fleet.js +1 -1
  50. package/lib/integrations-core.js +1695 -0
  51. package/lib/json-file.js +22 -1
  52. package/lib/loop-doctor.js +36 -2
  53. package/lib/map-refs.js +68 -8
  54. package/lib/member-engine.js +1 -1
  55. package/lib/member-map-fold.js +49 -11
  56. package/lib/member-map-inbox.js +2 -1
  57. package/lib/mission-core.js +9 -2
  58. package/lib/recall-exam.js +171 -0
  59. package/lib/roster-markdown.js +2 -2
  60. package/lib/runner-command.js +5 -5
  61. package/lib/security-audit-classes.js +20 -2
  62. package/lib/security-audit.js +31 -1
  63. package/lib/task-db.js +105 -37
  64. package/lib/taste-notes.js +35 -0
  65. package/lib/team-lineup.js +3 -3
  66. package/lib/todo.js +2 -0
  67. package/lib/wiki.js +11 -3
  68. package/lib/worktree-core.js +1517 -0
  69. package/lib/xp-core.js +2172 -0
  70. package/lib/youtube-core.js +3178 -0
  71. package/lib/zen-apply.js +3 -1
  72. package/package.json +10 -3
  73. package/utils/auth.js +18 -4
package/lib/json-file.js CHANGED
@@ -34,4 +34,25 @@ function writeJson(filePath, value, { indent = 2 } = {}) {
34
34
  return filePath;
35
35
  }
36
36
 
37
- module.exports = { readJson, writeJson };
37
+ /**
38
+ * Write text to a file by way of a temp file in the same folder and a rename, so a
39
+ * reader never sees half a file and two writers never interleave: the last rename wins
40
+ * whole. Creates parent dirs. The temp file is removed if the write fails.
41
+ * @param {string} filePath
42
+ * @param {string} text
43
+ * @returns {string} the path written
44
+ */
45
+ function writeFileAtomic(filePath, text) {
46
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
47
+ const tmp = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`);
48
+ try {
49
+ fs.writeFileSync(tmp, text, 'utf8');
50
+ fs.renameSync(tmp, filePath);
51
+ } catch (err) {
52
+ try { fs.rmSync(tmp, { force: true }); } catch { /* nothing left to clean */ }
53
+ throw err;
54
+ }
55
+ return filePath;
56
+ }
57
+
58
+ module.exports = { readJson, writeJson, writeFileAtomic };
@@ -127,6 +127,20 @@ function walk(value, visit, trail = []) {
127
127
  });
128
128
  }
129
129
 
130
+ function tickSignalIdentity(receipt, value) {
131
+ if (!value || typeof value !== 'object') return null;
132
+ const missionId = String((receipt && receipt.mission_id) || value.mission_id || '').trim();
133
+ const briefId = String((value.claude && value.claude.brief_id) || value.brief_id || '').trim();
134
+ if (briefId) return `brief:${missionId}:${briefId}`;
135
+ const hasTickIndex = value.tick_index != null && value.tick_index !== '';
136
+ const startedAt = typeof value.started_at === 'string' ? value.started_at.trim() : '';
137
+ const finishedAt = typeof value.finished_at === 'string' ? value.finished_at.trim() : '';
138
+ if (hasTickIndex && (startedAt || finishedAt)) {
139
+ return `tick:${missionId}:${value.tick_index}:${startedAt}:${finishedAt}`;
140
+ }
141
+ return null;
142
+ }
143
+
130
144
  function receiptFailureSignals(entry) {
131
145
  const signals = [];
132
146
  walk(entry.receipt, (value, trail) => {
@@ -137,7 +151,7 @@ function receiptFailureSignals(entry) {
137
151
  // node carries none.
138
152
  const signalTs = objectTimestamp(value) || entry.ts;
139
153
  if (typeof value.pause_reason === 'string' && value.pause_reason.trim()) {
140
- signals.push({ type: 'pause_reason', value: value.pause_reason.trim(), trail, ts: signalTs });
154
+ signals.push({ type: 'pause_reason', value: value.pause_reason.trim(), trail, ts: signalTs, signalId: null });
141
155
  }
142
156
  const status = String(value.status || '').toLowerCase();
143
157
  const tickContext = trail.some((part) => part === 'tick' || part === 'ticks');
@@ -148,7 +162,15 @@ function receiptFailureSignals(entry) {
148
162
  if (!errored) return;
149
163
  const detail = value.error || value.error_message || value.reason || value.message || status;
150
164
  const text = typeof detail === 'string' ? detail.trim() : JSON.stringify(detail);
151
- if (text) signals.push({ type: 'tick_error', value: text, trail, ts: signalTs });
165
+ if (text) {
166
+ signals.push({
167
+ type: 'tick_error',
168
+ value: text,
169
+ trail,
170
+ ts: signalTs,
171
+ signalId: tickSignalIdentity(entry.receipt, value),
172
+ });
173
+ }
152
174
  });
153
175
  const unique = new Map();
154
176
  for (const signal of signals) unique.set(`${signal.type}\0${signal.value}`, signal);
@@ -157,10 +179,16 @@ function receiptFailureSignals(entry) {
157
179
 
158
180
  function repeatedFailureFindings(receipts, nowIso, freshnessMs) {
159
181
  const groups = new Map();
182
+ const seenSignalIds = new Map();
160
183
  for (const entry of receipts) {
161
184
  for (const signal of receiptFailureSignals(entry)) {
162
185
  const key = `${signal.type}\0${signal.value}`;
163
186
  if (!groups.has(key)) groups.set(key, []);
187
+ if (signal.signalId) {
188
+ const dedupKey = `${key}\0${signal.signalId}`;
189
+ if (seenSignalIds.has(dedupKey)) continue;
190
+ seenSignalIds.set(dedupKey, true);
191
+ }
164
192
  groups.get(key).push({ ts: signal.ts || entry.ts, receipt: path.basename(entry.file), trail: signal.trail });
165
193
  }
166
194
  }
@@ -267,10 +295,16 @@ function isOpenMission(mission) {
267
295
  return mission && !TERMINAL_MISSION_STATUSES.has(String(mission.status || 'planning').toLowerCase());
268
296
  }
269
297
 
298
+ function isLoopDoctorRepairMission(mission) {
299
+ const objective = String(mission && mission.objective || '').trim();
300
+ return /^\[loop-doctor:[^\]]+\]/i.test(objective);
301
+ }
302
+
270
303
  function isDueCapableMission(mission) {
271
304
  const cadence = String(mission && mission.cadence || 'manual').trim().toLowerCase();
272
305
  const runner = String(mission && mission.runner || 'manual').trim().toLowerCase();
273
306
  return isOpenMission(mission)
307
+ && !isLoopDoctorRepairMission(mission)
274
308
  && cadence !== 'manual'
275
309
  && cadence !== ''
276
310
  && mission.always_on === true
package/lib/map-refs.js CHANGED
@@ -194,16 +194,32 @@ function fixMapRefs(root, mapFile = 'atris/MAP.md') {
194
194
  }
195
195
 
196
196
  // The short routing map loads at boot; exact line refs live in the notes file.
197
- // Both are checked and fixed, so moving a ref out of the boot map never hides it.
197
+ // A workspace may split the notes into one file per area under atris/refs/map/,
198
+ // with MAP-NOTES.md left as their index. Every one is checked and fixed, so
199
+ // moving a ref out of the boot map or into an area note never hides it.
198
200
  const MAP_REF_DOCS = ['atris/MAP.md', 'atris/refs/MAP-NOTES.md'];
201
+ const MAP_NOTES_DIR = 'atris/refs/map';
202
+
203
+ function isFile(file) {
204
+ try { return fs.statSync(file).isFile(); } catch (error) {
205
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
206
+ throw error;
207
+ }
208
+ }
209
+
210
+ function areaNoteDocs(root) {
211
+ let names;
212
+ try { names = fs.readdirSync(path.resolve(root, MAP_NOTES_DIR)); } catch (error) {
213
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return [];
214
+ throw error;
215
+ }
216
+ return names.filter(name => name.endsWith('.md')).sort()
217
+ .map(name => `${MAP_NOTES_DIR}/${name}`)
218
+ .filter(doc => isFile(path.resolve(root, doc)));
219
+ }
199
220
 
200
221
  function existingMapDocs(root) {
201
- return MAP_REF_DOCS.filter(doc => {
202
- try { return fs.statSync(path.resolve(root, doc)).isFile(); } catch (error) {
203
- if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
204
- throw error;
205
- }
206
- });
222
+ return [...MAP_REF_DOCS.filter(doc => isFile(path.resolve(root, doc))), ...areaNoteDocs(root)];
207
223
  }
208
224
 
209
225
  // Boot scores the docs every session, and checking every ref means reading
@@ -284,9 +300,53 @@ function fixMapDocs(root) {
284
300
  return { changes, left };
285
301
  }
286
302
 
303
+ // Lines from the deep map notes that share words with a query, for prompts that
304
+ // cannot open files themselves. With area notes, the index only describes them,
305
+ // so the notes are read instead. Notes are ranked by how many query words they
306
+ // match, and the budget fills from the best note first, its best lines first.
307
+ function mapNotesExcerpt(root, query, maxChars) {
308
+ const words = [...new Set(String(query || '').toLowerCase().split(/[^a-z0-9]+/).filter(w => w.length >= 4))];
309
+ if (!words.length || !(maxChars > 0)) return '';
310
+ // The notes file is read too while it still holds real notes, so a project
311
+ // that has moved only some of them into area notes keeps the rest.
312
+ const notes = existingMapDocs(root).filter(doc => doc !== MAP_REF_DOCS[0])
313
+ .filter(doc => doc !== MAP_REF_DOCS[1] || !areaNoteDocs(root).length || holdsRealNotes(fs.readFileSync(path.resolve(root, doc), 'utf8')));
314
+ const ranked = notes.map((doc, order) => {
315
+ const lines = fs.readFileSync(path.resolve(root, doc), 'utf8').split(/\r?\n/)
316
+ .map((line, index) => ({ line, index, hits: words.filter(w => line.toLowerCase().includes(w)).length }))
317
+ .filter(row => row.hits > 0)
318
+ .sort((a, b) => b.hits - a.hits || a.index - b.index);
319
+ const matched = new Set(words.filter(w => lines.some(row => row.line.toLowerCase().includes(w))));
320
+ return { doc, order, lines, matched: matched.size, total: lines.reduce((sum, row) => sum + row.hits, 0) };
321
+ }).filter(note => note.lines.length)
322
+ .sort((a, b) => b.matched - a.matched || b.total - a.total || a.order - b.order);
323
+ const out = [];
324
+ let used = 0;
325
+ for (const note of ranked) {
326
+ for (const row of note.lines) {
327
+ // Lines are joined by one newline, so only lines after the first pay for it.
328
+ const room = maxChars - used - (out.length ? 1 : 0);
329
+ if (room <= 0) return out.join('\n');
330
+ if (row.line.length > room) {
331
+ out.push(row.line.slice(0, room));
332
+ return out.join('\n');
333
+ }
334
+ out.push(row.line);
335
+ used += row.line.length + (out.length > 1 ? 1 : 0);
336
+ }
337
+ }
338
+ return out.join('\n');
339
+ }
340
+
341
+ // An index line links into atris/refs/map/; a real note names code or a search.
342
+ function holdsRealNotes(text) {
343
+ return text.split(/\r?\n/).some(line => !line.includes('](map/')
344
+ && (/`[\w./-]+\.(?:js|mjs|cjs|ts|py|sh|json)(?::\d+(?:-\d+)?)?`/.test(line) || /^\s*rg "/.test(line)));
345
+ }
346
+
287
347
  // Repo-relative path of the notes file when this workspace has one, else null.
288
348
  function mapNotesPath(root) {
289
349
  return existingMapDocs(root).includes(MAP_REF_DOCS[1]) ? MAP_REF_DOCS[1] : null;
290
350
  }
291
351
 
292
- module.exports = { MAP_REF_DOCS, existingMapDocs, mapNotesPath, checkMapRefs, checkMapDocs, fixMapRefs, fixMapDocs };
352
+ module.exports = { MAP_REF_DOCS, MAP_NOTES_DIR, existingMapDocs, mapNotesPath, mapNotesExcerpt, checkMapRefs, checkMapDocs, fixMapRefs, fixMapDocs };
@@ -5,7 +5,7 @@
5
5
  // validators, reviewers, judges, critics, and verifiers review; everyone else
6
6
  // builds. A line under "## team" in ROSTER.md overrides that, either with a
7
7
  // job ("researcher: search", "fixer: small build") or with engine or model
8
- // words ("judge: opus 5.5"). The job's pick then comes from the roster.
8
+ // words ("judge: opus"). The job's pick then comes from the roster.
9
9
 
10
10
  const fs = require('fs');
11
11
  const { memberRefusalReason } = require('./member-type');
@@ -4,7 +4,9 @@
4
4
  // atris/refs/map-notes.d/ instead of editing atris/refs/MAP-NOTES.md, so two
5
5
  // changes never clash on the shared map. Once an hour the autoland tick folds
6
6
  // those files into MAP-NOTES.md on the main branch and removes them, in one
7
- // commit built without touching any checkout. A push that loses a race with
7
+ // commit built without touching any checkout. When the notes are split into
8
+ // area notes under atris/refs/map/, a drop whose heading an area note already
9
+ // has folds into that note; any other drop folds into the member notes file. A push that loses a race with
8
10
  // another landing is dropped; the next hour tries again from the new main.
9
11
 
10
12
  const fs = require('fs');
@@ -12,8 +14,12 @@ const os = require('os');
12
14
  const path = require('path');
13
15
  const { spawnSync } = require('child_process');
14
16
 
17
+ const { MAP_NOTES_DIR } = require('./map-refs');
18
+
15
19
  const NOTES_FILE = 'atris/refs/MAP-NOTES.md';
16
20
  const DROP_DIR = 'atris/refs/map-notes.d';
21
+ const MEMBER_NOTES_FILE = `${MAP_NOTES_DIR}/member-notes.md`;
22
+ const MEMBER_NOTES_HEAD = '# Member notes\n\nMap notes that member changes added and no area has claimed yet. Move each one into the area note that owns its code. Back to [MAP-NOTES.md](../MAP-NOTES.md).\n';
17
23
  const FOLD_EVERY_MS = 60 * 60 * 1000;
18
24
 
19
25
  function git(args, cwd, extraEnv = {}) {
@@ -71,6 +77,34 @@ function foldNotesText(notes, drops) {
71
77
  return { text: lines.length ? `${lines.join('\n')}\n` : '', added };
72
78
  }
73
79
 
80
+ // Which notes file each drop folds into. Without area notes every drop goes to
81
+ // MAP-NOTES.md as before.
82
+ function foldTargets(root, baseSha, drops) {
83
+ const read = (file) => {
84
+ const blob = git(['cat-file', 'blob', `${baseSha}:${file}`], root);
85
+ return blob.ok ? blob.stdout : null;
86
+ };
87
+ const listed = git(['ls-tree', '--name-only', baseSha, '--', `${MAP_NOTES_DIR}/`], root).stdout
88
+ .split('\n').filter((f) => f.endsWith('.md')).sort();
89
+ const targets = new Map();
90
+ const put = (file, text, drop) => {
91
+ if (!targets.has(file)) targets.set(file, { text, drops: [] });
92
+ targets.get(file).drops.push(drop);
93
+ };
94
+ if (!listed.length) {
95
+ for (const drop of drops) put(NOTES_FILE, read(NOTES_FILE) || '', drop);
96
+ return targets;
97
+ }
98
+ const notes = listed.map((file) => ({ file, text: read(file) || '' }));
99
+ for (const drop of drops) {
100
+ const first = trimEnd(drop.text).split('\n').find((l) => l.trim()) || '';
101
+ const owner = isHeading(first) && notes.find((note) => note.text.split('\n').some((l) => l.trim() === first.trim()));
102
+ if (owner) put(owner.file, owner.text, drop);
103
+ else put(MEMBER_NOTES_FILE, read(MEMBER_NOTES_FILE) || MEMBER_NOTES_HEAD, drop);
104
+ }
105
+ return targets;
106
+ }
107
+
74
108
  function mainBranch(root, remote) {
75
109
  const head = git(['symbolic-ref', '--short', `refs/remotes/${remote}/HEAD`], root).stdout.trim();
76
110
  return head.startsWith(`${remote}/`) ? head.slice(remote.length + 1) : 'master';
@@ -96,19 +130,23 @@ function foldMapNotesOnMain(root, { base = '', remote = 'origin', push = true }
96
130
  if (!blob.ok) return { folded: 0, reason: 'drop_unreadable', detail: file };
97
131
  drops.push({ file, text: blob.stdout });
98
132
  }
99
- const current = git(['cat-file', 'blob', `${baseSha}:${NOTES_FILE}`], root);
100
- const { text, added } = foldNotesText(current.ok ? current.stdout : '', drops);
101
-
133
+ const targets = foldTargets(root, baseSha, drops);
134
+ let added = 0;
102
135
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-map-fold-'));
103
136
  try {
104
137
  const index = { GIT_INDEX_FILE: path.join(tmp, 'index') };
105
- const notesPath = path.join(tmp, 'notes');
106
- fs.writeFileSync(notesPath, text);
107
- const blob = git(['hash-object', '-w', notesPath], root).stdout.trim();
108
- const built = git(['read-tree', baseSha], root, index).ok
109
- && /^[0-9a-f]{40,64}$/.test(blob)
110
- && git(['update-index', '--add', '--cacheinfo', `100644,${blob},${NOTES_FILE}`], root, index).ok
111
- && git(['update-index', '--force-remove', '--', ...files], root, index).ok;
138
+ let built = git(['read-tree', baseSha], root, index).ok;
139
+ for (const [target, { text: currentText, drops: targetDrops }] of targets) {
140
+ if (!built) break;
141
+ const folded = foldNotesText(currentText, targetDrops);
142
+ added += folded.added;
143
+ const notesPath = path.join(tmp, 'notes');
144
+ fs.writeFileSync(notesPath, folded.text);
145
+ const blob = git(['hash-object', '-w', notesPath], root).stdout.trim();
146
+ built = /^[0-9a-f]{40,64}$/.test(blob)
147
+ && git(['update-index', '--add', '--cacheinfo', `100644,${blob},${target}`], root, index).ok;
148
+ }
149
+ built = built && git(['update-index', '--force-remove', '--', ...files], root, index).ok;
112
150
  const tree = built ? git(['write-tree'], root, index).stdout.trim() : '';
113
151
  const message = `Fold ${files.length} map ${files.length === 1 ? 'note' : 'notes'} from member changes into the shared map`;
114
152
  const commit = tree ? git(['commit-tree', tree, '-p', baseSha, '-m', message], root).stdout.trim() : '';
@@ -13,7 +13,8 @@ const path = require('path');
13
13
  const { spawnSync } = require('child_process');
14
14
 
15
15
  const INBOX_DIR = 'atris/refs/map-inbox';
16
- const SHARED_MAP = /(?:^|\/)MAP(?:-NOTES)?\.md$/;
16
+ // The area notes that MAP-NOTES.md may index are shared map files too.
17
+ const SHARED_MAP = /(?:^|\/)MAP(?:-NOTES)?\.md$|^atris\/refs\/map\/[^/]+\.md$/;
17
18
 
18
19
  function run(args, cwd, extraEnv = {}, { raw = false } = {}) {
19
20
  const env = { ...process.env, ...extraEnv, GIT_NO_REPLACE_OBJECTS: '1' };
@@ -38,7 +38,7 @@ const {
38
38
  shortRecordLabel,
39
39
  shortRecordRef,
40
40
  } = require('./short-name');
41
- const { readJson, writeJson } = require('./json-file');
41
+ const { readJson, writeJson, writeFileAtomic } = require('./json-file');
42
42
 
43
43
  const VALID_STATUSES = new Set(['planning', 'running', 'ready', 'paused', 'blocked', 'stopped', 'complete']);
44
44
  const TERMINAL_STATUSES = new Set(['stopped', 'complete']);
@@ -827,10 +827,17 @@ function loadTaskDb(asJson = false) {
827
827
  }
828
828
  }
829
829
 
830
+ // The projection has one writer and one shape: `atris task`'s writeDefaultProjection, which
831
+ // enriches the rows and writes through a temp file and a rename. Writing the bare rows here
832
+ // left readers a different shape depending on which command ran last. The fallback (only if
833
+ // the task command cannot load) still writes atomically.
830
834
  function writeMissionTaskProjection(taskDb, db, workspaceRoot) {
835
+ let writer = null;
836
+ try { writer = require('../commands/task').writeDefaultProjection; } catch { writer = null; }
837
+ if (typeof writer === 'function') return writer(taskDb, db, { root: workspaceRoot });
831
838
  const projection = taskDb.taskProjection(db, { workspaceRoot, limit: 500 });
832
839
  const outPath = path.join(workspaceRoot, '.atris', 'state', 'tasks.projection.json');
833
- writeJson(outPath, projection);
840
+ writeFileAtomic(outPath, JSON.stringify(projection, null, 2) + '\n');
834
841
  return { projection, outPath };
835
842
  }
836
843
 
@@ -0,0 +1,171 @@
1
+ 'use strict';
2
+
3
+ // Recall exam: how fast can an agent find the right note by following links?
4
+ //
5
+ // The reader starts with the boot files, then keeps opening the linked
6
+ // markdown file whose link text and line best match the question, the way an
7
+ // agent scans an index and follows the line that says when to open it. It never
8
+ // greps the whole tree. A question is found when an opened file contains the
9
+ // question's needle, an exact answer phrase. Needles survive a file move, so the
10
+ // same exam scores a layout before and after notes are split or merged.
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const STOP_WORDS = new Set(('the and for are was were where what which who how does did can could should would this that these those '
16
+ + 'with from into about find have has had there here when why our you your its it is be do to of in on at or an a i im me my we us '
17
+ + 'if not no any all one get got make need needs want am will just then than so but by as up out over use using used new before after '
18
+ + 'first next way thing things something someone mid work working about while also only very much more most some same each every').split(' '));
19
+ const DEFAULT_MAX_OPENS = 6;
20
+ // A real file reader stops after this many lines unless asked for more.
21
+ const READ_LINE_CAP = 2000;
22
+
23
+ function stem(word) {
24
+ return word.replace(/(?:ing|ed|es|s)$/u, '') || word;
25
+ }
26
+
27
+ function keywords(text) {
28
+ return [...new Set((String(text).toLowerCase().match(/[\p{L}\p{N}]+/gu) || [])
29
+ .filter(word => word.length >= 3 && !STOP_WORDS.has(word)).map(stem))];
30
+ }
31
+
32
+ function normalize(text) {
33
+ return String(text).toLowerCase().replace(/\s+/g, ' ');
34
+ }
35
+
36
+ function readCapped(file) {
37
+ let text;
38
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return null; }
39
+ const lines = text.split('\n');
40
+ return lines.length > READ_LINE_CAP ? lines.slice(0, READ_LINE_CAP).join('\n') : text;
41
+ }
42
+
43
+ // Every markdown file a line points at, with the words an agent sees beside it.
44
+ function linksIn(text, fromFile, root) {
45
+ const links = [];
46
+ const fromDir = path.dirname(fromFile);
47
+ let heading = '';
48
+ for (const line of text.split('\n')) {
49
+ const head = line.match(/^#{1,6}\s+(.*)/);
50
+ if (head) heading = head[1];
51
+ const targets = [];
52
+ for (const match of line.matchAll(/\[([^\]]*)\]\(([^)\s]+)\)/g)) targets.push({ label: match[1], target: match[2] });
53
+ for (const match of line.matchAll(/\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]/g)) targets.push({ label: '', target: match[1] });
54
+ for (const match of line.matchAll(/`([^`\s]+\.md)(?:#[^`]*)?`/g)) targets.push({ label: '', target: match[1] });
55
+ for (const { label, target } of targets) {
56
+ const clean = target.replace(/#.*$/, '').replace(/:\d+(?:-\d+)?$/, '');
57
+ if (!/\.md$/i.test(clean) || clean.includes('://')) continue;
58
+ const candidates = [path.resolve(fromDir, clean), path.resolve(root, clean)];
59
+ const resolved = candidates.find(file => file.startsWith(root + path.sep) && fs.existsSync(file));
60
+ if (!resolved) continue;
61
+ links.push({ file: resolved, context: `${label} ${line} ${heading} ${path.basename(clean, '.md')}` });
62
+ }
63
+ }
64
+ return links;
65
+ }
66
+
67
+ function scoreContext(context, words) {
68
+ const have = new Set(keywords(context));
69
+ return words.filter(word => have.has(word)).length;
70
+ }
71
+
72
+ function examineQuestion(root, question, { bootFiles, maxOpens = DEFAULT_MAX_OPENS }) {
73
+ const words = keywords(question.q);
74
+ const needle = normalize(question.needle);
75
+ const opened = [];
76
+ let charsRead = 0;
77
+ let foundIn = null;
78
+ const seen = new Set();
79
+ const frontier = new Map();
80
+ // The file whose link led to each opened file, so the path to the answer is known.
81
+ const via = new Map();
82
+ const absorb = (file, text) => {
83
+ seen.add(file);
84
+ for (const link of linksIn(text, file, root)) {
85
+ if (seen.has(link.file)) continue;
86
+ const score = scoreContext(link.context, words);
87
+ const prior = frontier.get(link.file);
88
+ if (!prior || score > prior.score) frontier.set(link.file, { score, order: prior ? prior.order : frontier.size, from: file });
89
+ }
90
+ if (!foundIn && normalize(text).includes(needle)) foundIn = path.relative(root, file);
91
+ };
92
+ for (const rel of bootFiles) {
93
+ const file = path.resolve(root, rel);
94
+ const text = readCapped(file);
95
+ if (text === null) continue;
96
+ absorb(file, text);
97
+ }
98
+ while (!foundIn && opened.length < maxOpens) {
99
+ let best = null;
100
+ for (const [file, entry] of frontier) {
101
+ if (seen.has(file) || entry.score === 0) continue;
102
+ if (!best || entry.score > best.score || (entry.score === best.score && entry.order < best.order)) best = { file, ...entry };
103
+ }
104
+ if (!best) break;
105
+ frontier.delete(best.file);
106
+ via.set(best.file, best.from);
107
+ const text = readCapped(best.file) || '';
108
+ opened.push(path.relative(root, best.file));
109
+ charsRead += text.length;
110
+ absorb(best.file, text);
111
+ }
112
+ // An index opened on the way to the answer was needed; every other open was not.
113
+ const needed = new Set();
114
+ for (let at = foundIn && path.resolve(root, foundIn); at && via.has(at); at = via.get(at)) needed.add(path.relative(root, at));
115
+ return {
116
+ id: question.id, area: question.area || null, found: Boolean(foundIn), found_in: foundIn,
117
+ files_opened: opened.length, opened, tokens_opened: Math.round(charsRead / 4),
118
+ wrong_opens: opened.filter(rel => !needed.has(rel)).length,
119
+ };
120
+ }
121
+
122
+ function readQuestions(file) {
123
+ const questions = [];
124
+ const invalid = [];
125
+ const text = fs.readFileSync(file, 'utf8');
126
+ text.split(/\r?\n/).forEach((line, index) => {
127
+ if (!line.trim()) return;
128
+ let row;
129
+ try { row = JSON.parse(line); } catch { row = null; }
130
+ if (!row || typeof row.q !== 'string' || typeof row.needle !== 'string' || !row.needle.trim()) invalid.push(index + 1);
131
+ else questions.push(row);
132
+ });
133
+ return { questions, invalid };
134
+ }
135
+
136
+ function median(values) {
137
+ if (!values.length) return null;
138
+ const sorted = [...values].sort((a, b) => a - b);
139
+ const mid = Math.floor(sorted.length / 2);
140
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
141
+ }
142
+
143
+ function runRecallExam(root, questionsFile, { bootFiles, maxOpens = DEFAULT_MAX_OPENS } = {}) {
144
+ const { questions, invalid } = readQuestions(questionsFile);
145
+ const bootChars = bootFiles.reduce((sum, rel) => sum + (readCapped(path.resolve(root, rel)) || '').length, 0);
146
+ const results = questions.map(question => examineQuestion(root, question, { bootFiles, maxOpens }));
147
+ const found = results.filter(result => result.found);
148
+ const opens = results.map(result => result.files_opened);
149
+ const tokens = results.map(result => result.tokens_opened);
150
+ const wrong = results.map(result => result.wrong_opens);
151
+ const byArea = {};
152
+ for (const result of results) {
153
+ const area = result.area || 'none';
154
+ byArea[area] = byArea[area] || { total: 0, found: 0 };
155
+ byArea[area].total++;
156
+ if (result.found) byArea[area].found++;
157
+ }
158
+ return {
159
+ questions: results.length, invalid_lines: invalid, max_opens: maxOpens,
160
+ boot_tokens: Math.round(bootChars / 4),
161
+ found: found.length, found_share: results.length ? found.length / results.length : null,
162
+ files_opened: { total: opens.reduce((a, b) => a + b, 0), median: median(opens), mean: results.length ? opens.reduce((a, b) => a + b, 0) / results.length : null },
163
+ wrong_opens: { total: wrong.reduce((a, b) => a + b, 0), median: median(wrong), mean: results.length ? wrong.reduce((a, b) => a + b, 0) / results.length : null },
164
+ tokens_opened: { total: tokens.reduce((a, b) => a + b, 0), median: median(tokens), mean: results.length ? Math.round(tokens.reduce((a, b) => a + b, 0) / results.length) : null },
165
+ tokens_opened_when_found: { median: median(found.map(result => result.tokens_opened)) },
166
+ by_area: byArea,
167
+ results,
168
+ };
169
+ }
170
+
171
+ module.exports = { runRecallExam, keywords };
@@ -8,10 +8,10 @@
8
8
  //
9
9
  // Two shapes read side by side. The sectioned shape, which assign writes:
10
10
  // ## build
11
- // - claude code, model: opus 5.5
11
+ // - claude code, model: opus
12
12
  // - codex, effort: medium
13
13
  // and the older one-line shape, still read as before:
14
- // build: claude opus 5.5, backup codex
14
+ // build: claude opus, backup codex
15
15
 
16
16
  const OLD_ROSTER_NOTE = '> edit any time. each line is job: engine and model, an optional effort (low, medium, high, xhigh, max), then optional "backup <engine and model>", "max 20 min", and "until 2026-10-24" (the year is required).';
17
17
  const ROSTER_NOTE = '> edit any time. each "## job" lists its workers in order, one per line: "- tool, model: name", then optional "effort: medium", "max: 20 min", "prep: search" (that job reads the task first and hands this worker a short brief), and "until 2026-10-24" (the year is required). the first ready worker leads and the rest back it up.';
@@ -95,7 +95,7 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
95
95
  }),
96
96
  haiku: Object.freeze({
97
97
  bin: 'claude',
98
- model: 'claude-haiku-5-5',
98
+ model: DEFAULT_CLAUDE_RUNNER_MODEL,
99
99
  commandTemplate: '',
100
100
  effortFlag: '--effort',
101
101
  efforts: CLAUDE_EFFORTS,
@@ -109,8 +109,8 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
109
109
  model: '',
110
110
  commandTemplate: '{bin} -p {pinnedModelFlag} -- {prompt}',
111
111
  }),
112
- // No hard pin: a pinned grok-4.6 went stale when the grok CLI moved its
113
- // own default to grok-4.7-build-fast, so an unpinned run rides the CLI.
112
+ // No hard pin: a pinned grok version went stale when the grok CLI moved its
113
+ // own default to a newer build, so an unpinned run rides the CLI.
114
114
  // Grok runs only xAI models, so a claude model name never reaches it.
115
115
  grok: Object.freeze({
116
116
  bin: 'grok',
@@ -123,7 +123,7 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
123
123
  agy: Object.freeze({
124
124
  bin: 'agy',
125
125
  // No hard pin: the agy CLI keeps its own session model, so an unpinned
126
- // run rides that and a roster line names gemini 3.8 flash (the 2026-09-04
126
+ // run rides that and a roster line names its own gemini model (the 2026-09-04
127
127
  // bake-off pick) when it wants it. --add-dir or agy edits its own scratch
128
128
  // folder; the headless note or gemini stops to ask "may I edit?" and
129
129
  // exits 0 having changed nothing.
@@ -319,7 +319,7 @@ function runnerModelFor(engineId, model) {
319
319
  // Required here, not at the top: engine-registry requires this file.
320
320
  const { isClaudeFamilyModel } = require('./engine-registry');
321
321
  const name = String(engineId || '').trim().toLowerCase();
322
- // A display name like "opus 5.5" only becomes an id for the claude cli (or
322
+ // A spaced display name only becomes an id for the claude cli (or
323
323
  // an unnamed engine). Any other engine's model name is handed over as is.
324
324
  const canonical = RUNNER_PROFILE_ALIASES[name] || name;
325
325
  const claudeCli = !canonical || canonical === 'claude' || canonical === 'fable' || canonical === 'haiku';