atris 3.74.4 → 3.74.6

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 (124) hide show
  1. package/FOR_AGENTS.md +12 -12
  2. package/atris/AGENTS.md +7 -7
  3. package/atris/CLAUDE.md +10 -10
  4. package/atris/policies/atris-backend.md +1 -1
  5. package/atris/policies/atris-design.md +71 -71
  6. package/atris/policies/design-seed.md +28 -28
  7. package/atris/policies/writing.md +1 -1
  8. package/atris/skills/apps/SKILL.md +3 -3
  9. package/atris/skills/atris-feedback/SKILL.md +2 -2
  10. package/atris/skills/autopilot/SKILL.md +10 -10
  11. package/atris/skills/blocks/SKILL.md +16 -16
  12. package/atris/skills/calendar/SKILL.md +1 -1
  13. package/atris/skills/clawhub/atris/SKILL.md +4 -4
  14. package/atris/skills/clawhub/philosophy-of-work/SKILL.md +7 -7
  15. package/atris/skills/create-app/SKILL.md +18 -18
  16. package/atris/skills/create-member/SKILL.md +11 -11
  17. package/atris/skills/design/SKILL.md +1 -1
  18. package/atris/skills/drive/SKILL.md +25 -25
  19. package/atris/skills/email-agent/SKILL.md +11 -11
  20. package/atris/skills/endgame/SKILL.md +33 -33
  21. package/atris/skills/magic-inbox/SKILL.md +15 -15
  22. package/atris/skills/notion/SKILL.md +1 -1
  23. package/atris/skills/ramp/SKILL.md +7 -7
  24. package/atris/skills/research-search/SKILL.md +7 -7
  25. package/atris/skills/slides/SKILL.md +7 -7
  26. package/atris/skills/tidy/SKILL.md +11 -11
  27. package/atris/skills/wake/SKILL.md +11 -11
  28. package/atris/skills/writing/SKILL.md +5 -5
  29. package/atris/team/_template/SOUL.md +3 -3
  30. package/atris/team/brainstormer/MEMBER.md +2 -2
  31. package/atris/team/launcher/MEMBER.md +2 -2
  32. package/atris/team/mission-lead/MISSION.md +7 -7
  33. package/atris/team/opus-overnight/MISSION.md +16 -16
  34. package/atris/team/opus-overnight/STEERING.md +5 -5
  35. package/atris.md +9 -0
  36. package/bin/atris.js +16 -3
  37. package/commands/campaign.js +17 -2
  38. package/commands/card.js +19 -12
  39. package/commands/engine.js +92 -17
  40. package/commands/gm.js +30 -0
  41. package/commands/idea.js +263 -0
  42. package/commands/init.js +11 -2
  43. package/commands/land.js +39 -18
  44. package/commands/mission.js +4 -3
  45. package/commands/ninety-nine.js +288 -0
  46. package/commands/one-lap.js +46 -27
  47. package/commands/reel.js +12 -7
  48. package/commands/sync.js +10 -2
  49. package/commands/task.js +4 -2
  50. package/commands/xp.js +68 -15
  51. package/commands/zen.js +10 -3
  52. package/lib/campaign-keep.js +23 -8
  53. package/lib/campaign-loop.js +24 -5
  54. package/lib/career-xp-lock.js +61 -0
  55. package/lib/career-xp-root.js +61 -0
  56. package/lib/claude-boot-block.js +7 -4
  57. package/lib/engine-registry.js +139 -1
  58. package/lib/fleet.js +11 -2
  59. package/lib/gm-bet.js +734 -42
  60. package/lib/gm-card.js +431 -0
  61. package/lib/gm-phone-page.js +486 -717
  62. package/lib/gm-phone.js +33 -34
  63. package/lib/gm-season.js +14 -0
  64. package/lib/headless-chrome.js +39 -0
  65. package/lib/idea-gate.js +33 -0
  66. package/lib/idea-inbox.js +220 -0
  67. package/lib/idea-ledger.js +196 -0
  68. package/lib/idea-tick.js +384 -0
  69. package/lib/known-commands.js +1 -1
  70. package/lib/member-engine.js +1 -0
  71. package/lib/ninety-nine-sync.js +156 -0
  72. package/lib/ninety-nine.js +247 -0
  73. package/lib/opening-lines.js +94 -0
  74. package/lib/roster-markdown.js +39 -1
  75. package/lib/roster-models.js +1 -1
  76. package/lib/roster-swap.js +353 -0
  77. package/lib/spawn-env.js +29 -2
  78. package/lib/task-db.js +8 -4
  79. package/lib/wish-audit.js +14 -0
  80. package/lib/yes-lock.js +365 -256
  81. package/lib/zen-apply.js +6 -3
  82. package/lib/zen.js +28 -1
  83. package/package.json +1 -1
  84. package/templates/business-starter/CLAUDE.md +7 -7
  85. package/templates/business-starter/MAP.md +1 -1
  86. package/templates/business-starter/TODO.md +1 -1
  87. package/templates/business-starter/atris.md +2 -2
  88. package/templates/business-starter/context/README.md +6 -6
  89. package/templates/business-starter/context/live-workspace.md +1 -1
  90. package/templates/business-starter/goals.md +1 -1
  91. package/templates/business-starter/instructions.md +1 -1
  92. package/templates/business-starter/memory.md +4 -4
  93. package/templates/business-starter/persona.md +3 -3
  94. package/templates/business-starter/policies/LESSONS.md +1 -1
  95. package/templates/business-starter/reports/README.md +1 -1
  96. package/templates/business-starter/skills/README.md +1 -1
  97. package/templates/business-starter/team/README.md +1 -1
  98. package/templates/business-starter/team/START_HERE.md +1 -1
  99. package/templates/business-starter/team/_template/SOUL.md +3 -3
  100. package/templates/business-starter/team/comms/SOUL.md +2 -2
  101. package/templates/business-starter/team/ops/SOUL.md +4 -4
  102. package/templates/business-starter/team/research/SOUL.md +3 -3
  103. package/templates/business-starter/wiki/index.md +1 -1
  104. package/templates/research-canonical/CLAUDE.md +7 -7
  105. package/templates/research-canonical/MAP.md +1 -1
  106. package/templates/research-canonical/TODO.md +1 -1
  107. package/templates/research-canonical/atris.md +2 -2
  108. package/templates/research-canonical/context/README.md +8 -8
  109. package/templates/research-canonical/context/live-workspace.md +1 -1
  110. package/templates/research-canonical/goals.md +2 -2
  111. package/templates/research-canonical/instructions.md +1 -1
  112. package/templates/research-canonical/memory.md +4 -4
  113. package/templates/research-canonical/persona.md +1 -1
  114. package/templates/research-canonical/policies/LESSONS.md +1 -1
  115. package/templates/research-canonical/reports/README.md +1 -1
  116. package/templates/research-canonical/skills/README.md +1 -1
  117. package/templates/research-canonical/team/README.md +1 -1
  118. package/templates/research-canonical/team/eval/SOUL.md +1 -1
  119. package/templates/research-canonical/team/experiment/SOUL.md +2 -2
  120. package/templates/research-canonical/team/hypothesis/SOUL.md +3 -3
  121. package/templates/research-canonical/team/literature/SOUL.md +2 -2
  122. package/templates/research-canonical/wiki/briefs/research-program.md +1 -1
  123. package/templates/research-canonical/wiki/index.md +1 -1
  124. package/utils/auth.js +1 -0
@@ -0,0 +1,353 @@
1
+ 'use strict';
2
+
3
+ // Swap the model behind a job or one member. `atris engine roster set` and
4
+ // the phone's engine card both come through here, so a tap and a typed
5
+ // command check the same things and write ROSTER.md the same way.
6
+ //
7
+ // What counts as a real choice is what this machine offers, read from the
8
+ // same local source as `atris engine roster --available` (the engine
9
+ // registry, codex's models cache, the known names). No list of models lives
10
+ // here, and nothing here goes online.
11
+
12
+ const reg = require('./engine-registry');
13
+ const { HIDDEN_PROFILE_NAMES, engineTakesModel } = require('./runner-command');
14
+ const { availableModels } = require('./roster-models');
15
+ const { readRosterRuns, workerRuns, summarizeRuns, DEFAULT_DAYS } = require('./roster-runs');
16
+
17
+ // Installed tools and the models each offers, with the registry they came
18
+ // from (callers check roles against it). A model the owner wrote under
19
+ // "## picks" in ROSTER.md counts as offered by its tool too, added after the
20
+ // tool's own list: model names live in the owner's file, not in code. The
21
+ // short list comes from the first layer that has one (this session, this
22
+ // project, then all projects).
23
+ function machineOffer(root = process.cwd(), options = {}) {
24
+ const registry = reg.readEngineRegistry(root, { persist: false });
25
+ const engines = registry.engines
26
+ .filter((engine) => !HIDDEN_PROFILE_NAMES.includes(engine.id))
27
+ .filter((engine) => engine.installed || (engine.health && engine.health.status === 'ready'));
28
+ const tools = availableModels(engines, options).map((row) => ({ ...row, models: [...row.models], tool: reg.rosterToolLabel(row.engine) }));
29
+ let state = null;
30
+ try {
31
+ state = options.state || reg.readRosterState(root, options);
32
+ } catch {
33
+ state = null;
34
+ }
35
+ const layer = state ? reg.ROSTER_LAYERS.map((scope) => state[scope]).find((item) => item && Array.isArray(item.shortlist) && item.shortlist.length) : null;
36
+ const shortlist = layer ? layer.shortlist : [];
37
+ for (const pick of shortlist) {
38
+ const row = tools.find((item) => item.engine === pick.engine);
39
+ if (row && pick.model && !row.models.some((name) => sameModel(row.engine, name, pick.model))) row.models.push(pick.model);
40
+ }
41
+ return { registry, tools, shortlist, shortlistFile: layer ? layer.file : '' };
42
+ }
43
+
44
+ // One swap option: the tool, the model to post ('' for the tool's own
45
+ // model), and the model as it reads.
46
+ function optionFor(row, name) {
47
+ const pin = name ? pinFor(row.engine, name) : '';
48
+ return { engine: row.engine, tool: row.tool, model: pin ? name : '', label: !name ? 'its own model' : pin ? modelText(pin) || name : name };
49
+ }
50
+
51
+ function fullList(tools) {
52
+ return tools.flatMap((row) => (row.models.length ? row.models : ['']).map((name) => optionFor(row, name)));
53
+ }
54
+
55
+ // A model the seed marks "(test model)" never goes on top.
56
+ const TEST_MODEL = /\(test model\)/i;
57
+
58
+ // The ordered swap list that --available and the phone's engine card both
59
+ // read. With a "## picks" section: the Atris engines first (hosted on
60
+ // Atris's own servers, test models left out), then each pick in file order
61
+ // (a pick for a tool not on this machine is left out, and one already shown
62
+ // is not shown twice), then one "more models" entry carrying the full list.
63
+ // With no picks: the full list, as before.
64
+ function swapList(root = process.cwd(), options = {}) {
65
+ const offer = options.offer || machineOffer(root, options);
66
+ const full = fullList(offer.tools);
67
+ if (!offer.shortlist.length) return { picked: false, options: full, full, tools: offer.tools };
68
+ const seen = new Set();
69
+ const take = (option, flag) => {
70
+ const key = `${option.engine}\u0000${option.label.toLowerCase()}`;
71
+ if (seen.has(key)) return null;
72
+ seen.add(key);
73
+ return { ...option, [flag]: true };
74
+ };
75
+ const atris = full
76
+ .filter((option) => reg.engineHosted({ id: option.engine }) && !TEST_MODEL.test(option.label))
77
+ .map((option) => take(option, 'atris'))
78
+ .filter(Boolean);
79
+ const picks = offer.shortlist
80
+ .map((pick) => {
81
+ const row = offer.tools.find((item) => item.engine === pick.engine);
82
+ return row ? take(optionFor(row, pick.model), 'pick') : null;
83
+ })
84
+ .filter(Boolean);
85
+ return {
86
+ picked: true,
87
+ file: offer.shortlistFile,
88
+ options: [...atris, ...picks, { more: true, label: 'more models', options: full }],
89
+ full,
90
+ tools: offer.tools,
91
+ };
92
+ }
93
+
94
+ // The saved id for a model name, or the name as written when it has none.
95
+ function modelId(engine, name) {
96
+ try {
97
+ return reg.normalizeRosterModel(engine, name) || '';
98
+ } catch {
99
+ return String(name || '').trim();
100
+ }
101
+ }
102
+
103
+ // A name --available lists is a real model only when the engine takes a
104
+ // model and the name saves as an id a --model flag can carry: no spaces or
105
+ // brackets (a trailing long-context mark aside), and not just the tool's own
106
+ // name. Anything else (a router label, a tool listing itself) is a display
107
+ // label, and picking it means no pin: the line names the tool alone, the
108
+ // way the roster writes any worker with no model.
109
+ function pinFor(engine, name) {
110
+ if (!engineTakesModel(engine)) return '';
111
+ const id = modelId(engine, name);
112
+ const bare = id.replace(/\[1m\]$/i, '').toLowerCase();
113
+ if (!bare || /[\s()[\]]/.test(bare)) return '';
114
+ if (bare === String(engine).toLowerCase() || bare === reg.rosterToolLabel(engine).toLowerCase()) return '';
115
+ return id;
116
+ }
117
+
118
+ function sameModel(engine, a, b) {
119
+ return modelId(engine, a).toLowerCase() === modelId(engine, b).toLowerCase();
120
+ }
121
+
122
+ function modelText(model) {
123
+ return model ? String(reg.rosterModelLabel(model) || model).trim() : '';
124
+ }
125
+
126
+ // The tool then the model's label, the way the team table says it (never
127
+ // the tool twice when the label already starts with it), or the tool alone
128
+ // when no model is pinned.
129
+ function workerText(engine, model = '') {
130
+ const label = modelText(model);
131
+ if (!label) return String(engine || '');
132
+ return label.toLowerCase().startsWith(`${String(engine).toLowerCase()} `) ? label : `${engine} ${label}`;
133
+ }
134
+
135
+ // One engine and model checked against what this machine offers. Throws a
136
+ // plain sentence that lists the valid choices.
137
+ function checkChoice(root, engineWords, modelWords, options = {}) {
138
+ const { registry, tools } = options.offer || machineOffer(root, options);
139
+ const engine = reg.rosterToolName(engineWords);
140
+ const row = tools.find((item) => item.engine === engine);
141
+ if (!row) {
142
+ const names = tools.map((item) => item.tool);
143
+ throw new Error(`${String(engineWords || '').trim() || 'that'} is not on this machine. pick one of: ${names.join(', ') || 'none found, run atris engine doctor'}`);
144
+ }
145
+ const words = String(modelWords || '').trim();
146
+ if (!row.models.length) {
147
+ if (words) throw new Error(`${row.tool} picks its own model here, so leave "${words}" off`);
148
+ return { engine, model: '', name: '', tool: row.tool, registry };
149
+ }
150
+ // a display label on the list stands for the tool's own model
151
+ if (!words) {
152
+ if (!row.models.some((item) => !pinFor(engine, item))) throw new Error(`pick a model for ${row.tool}: ${row.models.join(', ')}`);
153
+ return { engine, model: '', name: '', tool: row.tool, registry };
154
+ }
155
+ const name = row.models.find((item) => sameModel(engine, item, words));
156
+ if (!name) throw new Error(`${row.tool} has no model "${words}" on this machine. pick one of: ${row.models.join(', ')}`);
157
+ const pin = pinFor(engine, name);
158
+ return { engine, model: pin, name: pin ? name : '', tool: row.tool, registry };
159
+ }
160
+
161
+ function layerOptions(scope) {
162
+ if (scope === 'session') return { session: true };
163
+ if (scope === 'machine') return { everywhere: true };
164
+ return {};
165
+ }
166
+
167
+ // Write where the job's pick already lives, so the swap is the one that
168
+ // counts; a job no file names yet goes in the all-projects roster
169
+ // (~/.atris/ROSTER.md), or this project's when that one is off.
170
+ function jobScope(state, key) {
171
+ for (const scope of reg.ROSTER_LAYERS) {
172
+ if (state[scope] && state[scope].picks && state[scope].picks[key]) return scope;
173
+ }
174
+ return state.machine && state.machine.path ? 'machine' : 'project';
175
+ }
176
+
177
+ function teamScope(state, member) {
178
+ for (const scope of reg.ROSTER_LAYERS) {
179
+ const team = state[scope] && Array.isArray(state[scope].team) ? state[scope].team : [];
180
+ if (team.some((line) => line.member === member)) return scope;
181
+ }
182
+ return state.machine && state.machine.path ? 'machine' : 'project';
183
+ }
184
+
185
+ // The first good worker of a job's pick in one layer, or null.
186
+ function jobLead(state, scope, key) {
187
+ const pick = state[scope] && state[scope].picks ? state[scope].picks[key] : null;
188
+ if (!pick) return null;
189
+ const workers = Array.isArray(pick.workers) ? pick.workers.filter((worker) => !worker.error) : [pick];
190
+ return workers[0] || null;
191
+ }
192
+
193
+ // The command a person would type for this swap.
194
+ function setCommand({ job = '', member = '', engine, name }) {
195
+ const quote = (text) => (/\s/.test(text) ? `"${text}"` : text);
196
+ const who = member ? `--member ${member}` : quote(job);
197
+ return ['atris engine roster set', who, engine, name].filter(Boolean).join(' ');
198
+ }
199
+
200
+ // Swap a job's lead. The old lead stays as the first backup. Returns the
201
+ // one plain line to show.
202
+ function swapJob(root, jobWords, engineWords, modelWords, options = {}) {
203
+ const key = reg.rosterJobKey(jobWords);
204
+ if (!key) throw new Error(reg.rosterJobNameError(jobWords));
205
+ const label = reg.rosterJobLabel(key);
206
+ const choice = checkChoice(root, engineWords, modelWords, options);
207
+ const state = reg.readRosterState(root, options);
208
+ const scope = jobScope(state, key);
209
+ const lead = jobLead(state, scope, key);
210
+ const now = workerText(choice.engine, choice.model);
211
+ const command = setCommand({ job: label, engine: choice.engine, name: choice.name });
212
+ if (lead && lead.engine === choice.engine && sameModel(choice.engine, lead.model || '', choice.model)) {
213
+ return { changed: false, job: label, key, engine: choice.engine, model: choice.model, scope, command, line: `${label} already runs on ${now}.` };
214
+ }
215
+ reg.setRosterPick(jobWords, choice.engine, { ...layerOptions(scope), model: choice.name || undefined, keepLead: true }, root);
216
+ const backup = lead ? workerText(lead.engine, lead.model || '') : '';
217
+ return {
218
+ changed: true,
219
+ job: label,
220
+ key,
221
+ engine: choice.engine,
222
+ model: choice.model,
223
+ backup,
224
+ scope,
225
+ command,
226
+ line: backup ? `${label} now runs on ${now}. ${backup} is the backup.` : `${label} now runs on ${now}.`,
227
+ };
228
+ }
229
+
230
+ // A member on this team: a card in atris/team or a team line.
231
+ function teamMember(root, memberName, options = {}) {
232
+ const { resolveEngineForMember, readMemberCard, autoJobForMember } = require('./member-engine');
233
+ const member = String(memberName || '').trim().toLowerCase();
234
+ if (!/^[a-z0-9._-]+$/.test(member)) throw new Error(`"${memberName}" is not a member name`);
235
+ const picked = resolveEngineForMember(member, root, options);
236
+ if (!picked.found) throw new Error(`there is no team member named ${member}`);
237
+ const kind = autoJobForMember(member, readMemberCard(root, member).role);
238
+ return { member, picked, kind, role: reg.ENGINE_JOBS[kind] };
239
+ }
240
+
241
+ // Give one member its own engine and model; every other job stays put.
242
+ function swapMember(root, memberName, engineWords, modelWords, options = {}) {
243
+ const { member, picked, kind, role } = teamMember(root, memberName, options);
244
+ const choice = checkChoice(root, engineWords, modelWords, options);
245
+ const now = workerText(choice.engine, choice.model);
246
+ const command = setCommand({ member, engine: choice.engine, name: choice.name });
247
+ const state = reg.readRosterState(root, options);
248
+ const scope = teamScope(state, member);
249
+ const current = picked.engine && picked.engine.id === choice.engine && sameModel(choice.engine, picked.model || '', choice.model);
250
+ if (current && picked.pick_source === 'team') {
251
+ return { changed: false, member, engine: choice.engine, model: choice.model, scope, command, line: `${member} already runs on ${now}.` };
252
+ }
253
+ reg.setTeamPick(member, choice.engine, { ...layerOptions(scope), model: choice.name || undefined, role }, root);
254
+ const key = role;
255
+ const lead = jobLead(state, jobScope(state, key), key);
256
+ const rest = lead ? ` every other ${kind} job stays on ${workerText(lead.engine, lead.model || '')}.` : '';
257
+ return { changed: true, member, engine: choice.engine, model: choice.model, scope, command, line: `${member} now runs on ${now}.${rest}` };
258
+ }
259
+
260
+ // The phone's engine card for one member: what it runs on now, and every
261
+ // model this machine offers that can do the member's work, grouped by tool.
262
+ // The current model is on top; models with a record in the last 7 days come
263
+ // next, most landed first; the rest keep --available's order.
264
+ function memberEngineChoices(root, memberName, options = {}) {
265
+ const { member, picked } = teamMember(root, memberName, options);
266
+ const { registry, tools, shortlist, shortlistFile } = machineOffer(root, options);
267
+ const jobKey = picked.job_key;
268
+ const role = picked.role;
269
+ const runs = readRosterRuns(root, { now: options.now, days: DEFAULT_DAYS });
270
+ const record = (engine, model) => summarizeRuns(workerRuns(runs, { job: jobKey, engine, model }), { days: DEFAULT_DAYS });
271
+ const curEngine = picked.engine ? picked.engine.id : '';
272
+ const curModel = picked.model || '';
273
+ const fits = (engineId) => reg.jobTakesAnyEngine(jobKey) || reg.engineHasRole(registry.engines.find((entry) => entry.id === engineId), role);
274
+ const groups = [];
275
+ tools.forEach((row, order) => {
276
+ if (!fits(row.engine) && row.engine !== curEngine) return;
277
+ const names = row.models.length ? row.models : [''];
278
+ const models = names.map((name, index) => {
279
+ // a display label posts no model, so the swap pins none
280
+ const id = name ? pinFor(row.engine, name) : '';
281
+ const seen = record(row.engine, id);
282
+ return {
283
+ engine: row.engine,
284
+ model: id ? name : '',
285
+ label: !name ? 'its own model' : id ? modelText(id) || name : name,
286
+ current: row.engine === curEngine && sameModel(row.engine, id, curModel),
287
+ record: seen ? `${seen.landed} of ${seen.runs} landed` : null,
288
+ landed: seen ? seen.landed : -1,
289
+ runs: seen ? seen.runs : 0,
290
+ index,
291
+ };
292
+ });
293
+ if (row.engine === curEngine && curModel && !models.some((item) => item.current)) {
294
+ models.push({ engine: row.engine, model: curModel, label: modelText(curModel), current: true, record: null, landed: -1, runs: 0, index: -1 });
295
+ }
296
+ models.sort((a, b) => (b.current - a.current) || (b.landed - a.landed) || (b.runs - a.runs) || (a.index - b.index));
297
+ groups.push({
298
+ engine: row.engine,
299
+ tool: row.tool,
300
+ models: models.map(({ landed, runs: count, index, ...item }) => item),
301
+ current: models.some((item) => item.current),
302
+ recorded: models.some((item) => item.record),
303
+ order,
304
+ });
305
+ });
306
+ if (curEngine && !groups.some((group) => group.engine === curEngine)) {
307
+ groups.push({ engine: curEngine, tool: reg.rosterToolLabel(curEngine), models: [{ engine: curEngine, model: curModel, label: modelText(curModel) || 'its own model', current: true, record: null }], current: true, recorded: false, order: -1 });
308
+ }
309
+ groups.sort((a, b) => (b.current - a.current) || (b.recorded - a.recorded) || (a.order - b.order));
310
+ // options: the same ordered list --available prints (swapList), keeping
311
+ // only what this member can do, each with current and its 7-day record.
312
+ // groups stays the full list by tool for the page that draws it today.
313
+ const mark = (option) => {
314
+ const pin = option.model ? pinFor(option.engine, option.model) : '';
315
+ const seen = record(option.engine, pin);
316
+ return { ...option, current: option.engine === curEngine && sameModel(option.engine, pin, curModel), record: seen ? `${seen.landed} of ${seen.runs} landed` : null };
317
+ };
318
+ const list = swapList(root, { ...options, offer: { registry, tools, shortlist, shortlistFile } });
319
+ const choices = list.options
320
+ .filter((option) => option.more || fits(option.engine))
321
+ .map((option) => (option.more ? { ...option, options: option.options.filter((item) => fits(item.engine)).map(mark) } : mark(option)));
322
+ return {
323
+ member,
324
+ job: picked.job,
325
+ job_key: jobKey,
326
+ line: curEngine ? `${member} runs on ${workerText(curEngine, curModel)}.` : `${member} has no engine yet.`,
327
+ scopes: [{ id: 'member', label: `just ${member}` }, { id: 'job', label: `for every ${picked.job} job` }],
328
+ groups: groups.map(({ current, recorded, order, ...group }) => group),
329
+ picked: list.picked,
330
+ options: choices,
331
+ };
332
+ }
333
+
334
+ // One tap from the engine card: scope member gives only this member the
335
+ // model; scope job swaps the lead of the member's job.
336
+ function swapFromCard(root, memberName, engineWords, modelWords, scope, options = {}) {
337
+ if (scope !== 'job') return swapMember(root, memberName, engineWords, modelWords, options);
338
+ const { member, picked } = teamMember(root, memberName, options);
339
+ const out = swapJob(root, picked.job, engineWords, modelWords, options);
340
+ const { resolveEngineForMember } = require('./member-engine');
341
+ const after = resolveEngineForMember(member, root, options);
342
+ const moved = after.engine && after.engine.id === out.engine && sameModel(out.engine, after.model || '', out.model);
343
+ if (!moved && after.pick_source === 'team') return { ...out, line: `${out.line} ${member} keeps its own line.` };
344
+ return out;
345
+ }
346
+
347
+ module.exports = {
348
+ swapList,
349
+ memberEngineChoices,
350
+ swapFromCard,
351
+ swapJob,
352
+ swapMember,
353
+ };
package/lib/spawn-env.js CHANGED
@@ -52,9 +52,16 @@ function scriptShellSource(dir) {
52
52
  const q = `'${String(dir).replace(/'/g, `'\\''`)}'`;
53
53
  return [
54
54
  '#!/bin/sh',
55
+ // every guard folder on PATH goes back first, in order, so nested guards
56
+ // each still check (a guarded job whose tests make their own guard)
55
57
  `G=${q}`,
58
+ 'OWN="$G"; G=""',
59
+ 'IFS=:; for d in $PATH; do case "${d##*/}" in atris-mission-git-*) G="${G:+$G:}$d" ;; esac; done; unset IFS',
60
+ '[ -z "$OWN" ] || case ":$G:" in *":$OWN:"*) ;; *) G="$OWN${G:+:$G}" ;; esac',
56
61
  'PATH="$G:$PATH"; export PATH',
57
62
  'shell="${ATRIS_SCRIPT_SHELL:-/bin/sh}"',
63
+ // a guard's own script shell is never the configured one
64
+ 'case "$shell" in */atris-mission-git-*/script-shell) shell=/bin/sh ;; esac',
58
65
  'if [ "$1" = "-c" ] && [ $# -ge 2 ]; then',
59
66
  ' case "${shell##*/}" in',
60
67
  ' sh|bash|zsh|dash|ksh|mksh|ash)',
@@ -90,6 +97,22 @@ function configFlags(tool, args = []) {
90
97
  return out;
91
98
  }
92
99
 
100
+ const inGuard = p => path.basename(path.dirname(String(p || ''))).startsWith(GUARD_DIR_PREFIX);
101
+ const isGuardShell = p => path.basename(String(p || '')) === 'script-shell' && inGuard(p);
102
+
103
+ // The real tool behind a wrapper, and behind a wrapper inside another guard:
104
+ // each wrapper records the tool it runs.
105
+ function realTool(bin) {
106
+ let b = String(bin || '');
107
+ for (let i = 0; i < 5 && inGuard(b); i += 1) {
108
+ let next = null;
109
+ try { const m = require('fs').readFileSync(b, 'utf8').match(/^const config = (.*);$/m); next = m && JSON.parse(m[1]).realBin; } catch { /* not a wrapper */ }
110
+ if (!next) break;
111
+ b = next;
112
+ }
113
+ return b;
114
+ }
115
+
93
116
  function packageScriptEnv({ tool, realBin, scriptShell }, env = process.env, cwd = process.cwd(), args = []) {
94
117
  const out = { ...env, npm_config_script_shell: scriptShell };
95
118
  // .yarnrc beats npm_config_*, and YARN_SCRIPT_SHELL beats .yarnrc
@@ -97,9 +120,13 @@ function packageScriptEnv({ tool, realBin, scriptShell }, env = process.env, cwd
97
120
  if (tool === 'pnpm') out.npm_config_shell_emulator = 'false';
98
121
  if (env.npm_config_script_shell === scriptShell) return out;
99
122
  const { spawnSync } = require('child_process');
100
- const res = spawnSync(realBin, [...configFlags(tool, args), 'config', 'get', 'script-shell'], { cwd, env, encoding: 'utf8', timeout: 10000 });
123
+ // asked of the real tool, with any guard's shell out of the way, so the
124
+ // project's or user's own setting shows through
125
+ const askEnv = { ...env };
126
+ for (const k of ['npm_config_script_shell', 'YARN_SCRIPT_SHELL']) if (isGuardShell(askEnv[k])) delete askEnv[k];
127
+ const res = spawnSync(realTool(realBin), [...configFlags(tool, args), 'config', 'get', 'script-shell'], { cwd, env: askEnv, encoding: 'utf8', timeout: 10000 });
101
128
  const value = res.status === 0 ? String(res.stdout || '').trim().split('\n').pop().trim() : '';
102
- if (value && !/^(null|undefined)$/.test(value) && value !== scriptShell) out.ATRIS_SCRIPT_SHELL = value;
129
+ if (value && !/^(null|undefined)$/.test(value) && value !== scriptShell && !isGuardShell(value)) out.ATRIS_SCRIPT_SHELL = value;
103
130
  else delete out.ATRIS_SCRIPT_SHELL;
104
131
  return out;
105
132
  }
package/lib/task-db.js CHANGED
@@ -35,6 +35,8 @@ const { explicitDoneLooksLike, taskExplanation } = require('./task-explanation')
35
35
  const { treeHashFor } = require('./tree-hash');
36
36
  const { sleepSync } = require('./sleep-sync');
37
37
  const { atrisHome } = require('./state-home');
38
+ const { careerXpRoot } = require('./career-xp-root');
39
+ const { withCareerXpLock } = require('./career-xp-lock');
38
40
 
39
41
  const TASK_EPISODES_FILE = path.join('.atris', 'state', 'task_episodes.jsonl');
40
42
  const TODO_RENDER_DONE_LIMIT = 8;
@@ -2054,10 +2056,12 @@ function taskEpisodeFromRevision(row, event, payload) {
2054
2056
  }
2055
2057
 
2056
2058
  function appendTaskEpisode(workspaceRoot, episode) {
2057
- const filePath = path.join(workspaceRoot, TASK_EPISODES_FILE);
2058
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
2059
- fs.appendFileSync(filePath, JSON.stringify(episode) + '\n', 'utf8');
2060
- return filePath;
2059
+ const workspace = careerXpRoot(workspaceRoot);
2060
+ return withCareerXpLock(workspace, () => {
2061
+ const filePath = path.join(workspace, TASK_EPISODES_FILE);
2062
+ fs.appendFileSync(filePath, JSON.stringify(episode) + '\n', 'utf8');
2063
+ return filePath;
2064
+ });
2061
2065
  }
2062
2066
 
2063
2067
  function clipProjectionText(value, max = PROJECTION_PAYLOAD_TEXT_LIMIT) {
package/lib/wish-audit.js CHANGED
@@ -593,6 +593,20 @@ function withWishAnswerAuditContext(context, fn) {
593
593
  function namedThingExists(root, value) {
594
594
  const raw = cleanToken(value);
595
595
  if (!raw) return false;
596
+ try {
597
+ const { findAllMembers } = require('../commands/member');
598
+ const members = findAllMembers(path.join(root, 'atris', 'team'));
599
+ const name = raw.toLowerCase();
600
+ const member = members.find((entry) => String(entry.name).toLowerCase() === name)
601
+ || members.find((entry) => [
602
+ entry.frontmatter.id,
603
+ entry.frontmatter.slug,
604
+ entry.format === 'directory' ? path.basename(entry.dir) : path.basename(entry.path, '.md'),
605
+ ].some((id) => String(id || '').toLowerCase() === name));
606
+ if (member) return true;
607
+ } catch {
608
+ // An unreadable team still falls through to the existing name checks.
609
+ }
596
610
  if (fileExists(root, raw)) return true;
597
611
  const slug = slugify(raw);
598
612
  const base = path.basename(root).toLowerCase();