atris 3.74.4 → 3.74.5
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.
- package/FOR_AGENTS.md +12 -12
- package/atris/AGENTS.md +7 -7
- package/atris/CLAUDE.md +10 -10
- package/atris/policies/atris-backend.md +1 -1
- package/atris/policies/atris-design.md +71 -71
- package/atris/policies/design-seed.md +28 -28
- package/atris/policies/writing.md +1 -1
- package/atris/skills/apps/SKILL.md +3 -3
- package/atris/skills/atris-feedback/SKILL.md +2 -2
- package/atris/skills/autopilot/SKILL.md +10 -10
- package/atris/skills/blocks/SKILL.md +16 -16
- package/atris/skills/calendar/SKILL.md +1 -1
- package/atris/skills/clawhub/atris/SKILL.md +4 -4
- package/atris/skills/clawhub/philosophy-of-work/SKILL.md +7 -7
- package/atris/skills/create-app/SKILL.md +18 -18
- package/atris/skills/create-member/SKILL.md +11 -11
- package/atris/skills/design/SKILL.md +1 -1
- package/atris/skills/drive/SKILL.md +25 -25
- package/atris/skills/email-agent/SKILL.md +11 -11
- package/atris/skills/endgame/SKILL.md +33 -33
- package/atris/skills/magic-inbox/SKILL.md +15 -15
- package/atris/skills/notion/SKILL.md +1 -1
- package/atris/skills/ramp/SKILL.md +7 -7
- package/atris/skills/research-search/SKILL.md +7 -7
- package/atris/skills/slides/SKILL.md +7 -7
- package/atris/skills/tidy/SKILL.md +11 -11
- package/atris/skills/wake/SKILL.md +11 -11
- package/atris/skills/writing/SKILL.md +5 -5
- package/atris/team/_template/SOUL.md +3 -3
- package/atris/team/brainstormer/MEMBER.md +2 -2
- package/atris/team/launcher/MEMBER.md +2 -2
- package/atris/team/mission-lead/MISSION.md +7 -7
- package/atris/team/opus-overnight/MISSION.md +16 -16
- package/atris/team/opus-overnight/STEERING.md +5 -5
- package/atris.md +9 -0
- package/bin/atris.js +16 -3
- package/commands/campaign.js +17 -2
- package/commands/card.js +19 -12
- package/commands/engine.js +92 -17
- package/commands/gm.js +30 -0
- package/commands/idea.js +263 -0
- package/commands/init.js +11 -2
- package/commands/land.js +39 -18
- package/commands/mission.js +4 -3
- package/commands/ninety-nine.js +288 -0
- package/commands/one-lap.js +46 -27
- package/commands/reel.js +12 -7
- package/commands/sync.js +10 -2
- package/lib/campaign-keep.js +23 -8
- package/lib/campaign-loop.js +24 -5
- package/lib/claude-boot-block.js +7 -4
- package/lib/engine-registry.js +139 -1
- package/lib/fleet.js +11 -2
- package/lib/gm-bet.js +338 -34
- package/lib/gm-phone-page.js +325 -33
- package/lib/gm-phone.js +5 -1
- package/lib/gm-season.js +14 -0
- package/lib/headless-chrome.js +39 -0
- package/lib/idea-gate.js +33 -0
- package/lib/idea-inbox.js +220 -0
- package/lib/idea-ledger.js +196 -0
- package/lib/idea-tick.js +384 -0
- package/lib/known-commands.js +1 -1
- package/lib/member-engine.js +1 -0
- package/lib/ninety-nine-sync.js +156 -0
- package/lib/ninety-nine.js +247 -0
- package/lib/opening-lines.js +94 -0
- package/lib/roster-markdown.js +39 -1
- package/lib/roster-models.js +1 -1
- package/lib/roster-swap.js +353 -0
- package/lib/spawn-env.js +29 -2
- package/lib/wish-audit.js +14 -0
- package/lib/yes-lock.js +343 -253
- package/lib/zen-apply.js +5 -3
- package/lib/zen.js +13 -1
- package/package.json +1 -1
- package/templates/business-starter/CLAUDE.md +7 -7
- package/templates/business-starter/MAP.md +1 -1
- package/templates/business-starter/TODO.md +1 -1
- package/templates/business-starter/atris.md +2 -2
- package/templates/business-starter/context/README.md +6 -6
- package/templates/business-starter/context/live-workspace.md +1 -1
- package/templates/business-starter/goals.md +1 -1
- package/templates/business-starter/instructions.md +1 -1
- package/templates/business-starter/memory.md +4 -4
- package/templates/business-starter/persona.md +3 -3
- package/templates/business-starter/policies/LESSONS.md +1 -1
- package/templates/business-starter/reports/README.md +1 -1
- package/templates/business-starter/skills/README.md +1 -1
- package/templates/business-starter/team/README.md +1 -1
- package/templates/business-starter/team/START_HERE.md +1 -1
- package/templates/business-starter/team/_template/SOUL.md +3 -3
- package/templates/business-starter/team/comms/SOUL.md +2 -2
- package/templates/business-starter/team/ops/SOUL.md +4 -4
- package/templates/business-starter/team/research/SOUL.md +3 -3
- package/templates/business-starter/wiki/index.md +1 -1
- package/templates/research-canonical/CLAUDE.md +7 -7
- package/templates/research-canonical/MAP.md +1 -1
- package/templates/research-canonical/TODO.md +1 -1
- package/templates/research-canonical/atris.md +2 -2
- package/templates/research-canonical/context/README.md +8 -8
- package/templates/research-canonical/context/live-workspace.md +1 -1
- package/templates/research-canonical/goals.md +2 -2
- package/templates/research-canonical/instructions.md +1 -1
- package/templates/research-canonical/memory.md +4 -4
- package/templates/research-canonical/persona.md +1 -1
- package/templates/research-canonical/policies/LESSONS.md +1 -1
- package/templates/research-canonical/reports/README.md +1 -1
- package/templates/research-canonical/skills/README.md +1 -1
- package/templates/research-canonical/team/README.md +1 -1
- package/templates/research-canonical/team/eval/SOUL.md +1 -1
- package/templates/research-canonical/team/experiment/SOUL.md +2 -2
- package/templates/research-canonical/team/hypothesis/SOUL.md +3 -3
- package/templates/research-canonical/team/literature/SOUL.md +2 -2
- package/templates/research-canonical/wiki/briefs/research-program.md +1 -1
- package/templates/research-canonical/wiki/index.md +1 -1
- 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
|
-
|
|
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/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();
|