atris 3.74.3 → 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.
Files changed (117) 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/lib/campaign-keep.js +23 -8
  50. package/lib/campaign-loop.js +24 -5
  51. package/lib/claude-boot-block.js +7 -4
  52. package/lib/engine-registry.js +139 -1
  53. package/lib/fleet.js +11 -2
  54. package/lib/gm-bet.js +338 -34
  55. package/lib/gm-phone-page.js +325 -33
  56. package/lib/gm-phone.js +5 -1
  57. package/lib/gm-season.js +14 -0
  58. package/lib/headless-chrome.js +39 -0
  59. package/lib/idea-gate.js +33 -0
  60. package/lib/idea-inbox.js +220 -0
  61. package/lib/idea-ledger.js +196 -0
  62. package/lib/idea-tick.js +384 -0
  63. package/lib/known-commands.js +1 -1
  64. package/lib/member-engine.js +1 -0
  65. package/lib/ninety-nine-sync.js +156 -0
  66. package/lib/ninety-nine.js +247 -0
  67. package/lib/opening-lines.js +94 -0
  68. package/lib/roster-markdown.js +39 -1
  69. package/lib/roster-models.js +1 -1
  70. package/lib/roster-swap.js +353 -0
  71. package/lib/spawn-env.js +29 -2
  72. package/lib/wish-audit.js +14 -0
  73. package/lib/yes-lock.js +358 -241
  74. package/lib/zen-apply.js +5 -3
  75. package/lib/zen.js +13 -1
  76. package/package.json +1 -1
  77. package/templates/business-starter/CLAUDE.md +7 -7
  78. package/templates/business-starter/MAP.md +1 -1
  79. package/templates/business-starter/TODO.md +1 -1
  80. package/templates/business-starter/atris.md +2 -2
  81. package/templates/business-starter/context/README.md +6 -6
  82. package/templates/business-starter/context/live-workspace.md +1 -1
  83. package/templates/business-starter/goals.md +1 -1
  84. package/templates/business-starter/instructions.md +1 -1
  85. package/templates/business-starter/memory.md +4 -4
  86. package/templates/business-starter/persona.md +3 -3
  87. package/templates/business-starter/policies/LESSONS.md +1 -1
  88. package/templates/business-starter/reports/README.md +1 -1
  89. package/templates/business-starter/skills/README.md +1 -1
  90. package/templates/business-starter/team/README.md +1 -1
  91. package/templates/business-starter/team/START_HERE.md +1 -1
  92. package/templates/business-starter/team/_template/SOUL.md +3 -3
  93. package/templates/business-starter/team/comms/SOUL.md +2 -2
  94. package/templates/business-starter/team/ops/SOUL.md +4 -4
  95. package/templates/business-starter/team/research/SOUL.md +3 -3
  96. package/templates/business-starter/wiki/index.md +1 -1
  97. package/templates/research-canonical/CLAUDE.md +7 -7
  98. package/templates/research-canonical/MAP.md +1 -1
  99. package/templates/research-canonical/TODO.md +1 -1
  100. package/templates/research-canonical/atris.md +2 -2
  101. package/templates/research-canonical/context/README.md +8 -8
  102. package/templates/research-canonical/context/live-workspace.md +1 -1
  103. package/templates/research-canonical/goals.md +2 -2
  104. package/templates/research-canonical/instructions.md +1 -1
  105. package/templates/research-canonical/memory.md +4 -4
  106. package/templates/research-canonical/persona.md +1 -1
  107. package/templates/research-canonical/policies/LESSONS.md +1 -1
  108. package/templates/research-canonical/reports/README.md +1 -1
  109. package/templates/research-canonical/skills/README.md +1 -1
  110. package/templates/research-canonical/team/README.md +1 -1
  111. package/templates/research-canonical/team/eval/SOUL.md +1 -1
  112. package/templates/research-canonical/team/experiment/SOUL.md +2 -2
  113. package/templates/research-canonical/team/hypothesis/SOUL.md +3 -3
  114. package/templates/research-canonical/team/literature/SOUL.md +2 -2
  115. package/templates/research-canonical/wiki/briefs/research-program.md +1 -1
  116. package/templates/research-canonical/wiki/index.md +1 -1
  117. package/utils/auth.js +1 -0
@@ -5,6 +5,7 @@
5
5
 
6
6
  'use strict';
7
7
 
8
+ const fs = require('fs');
8
9
  const path = require('path');
9
10
  const crypto = require('crypto');
10
11
  const { spawn } = require('child_process');
@@ -37,6 +38,18 @@ function proposalId(objective) {
37
38
  return `m-${slug || 'mission'}-${crypto.randomBytes(3).toString('hex')}`;
38
39
  }
39
40
 
41
+ function briefPath(root, campaignId) {
42
+ return path.join(root, 'atris', 'campaigns', campaignId, 'BRIEF.md');
43
+ }
44
+
45
+ function readBrief(root, campaignId) {
46
+ try {
47
+ return fs.readFileSync(briefPath(root, campaignId), 'utf8').trim();
48
+ } catch {
49
+ return '';
50
+ }
51
+ }
52
+
40
53
  function rankCampaignMembers(view, rsiSummary = null, events = null) {
41
54
  const seats = Array.isArray(view && view.seats) ? view.seats.map((s) => s.member).filter(Boolean) : [];
42
55
  const candidates = [...new Set([view && view.owner, ...seats].filter(Boolean))];
@@ -99,7 +112,7 @@ function completionOrderedMissions(view, events) {
99
112
 
100
113
  // What the owner's engine reads before proposing: the goal, how it is judged,
101
114
  // what has been tried, and how it went.
102
- function proposalPrompt(view, count, judgeOutput = null, lessons = '', rsiSummary = null, events = null) {
115
+ function proposalPrompt(view, count, judgeOutput = null, lessons = '', rsiSummary = null, events = null, brief = '') {
103
116
  const tried = view.missions.slice(-12).map((m) => `- ${m.id}: ${m.status}${m.outcome ? ` (${m.outcome})` : ''}${m.cost_usd !== null ? `, $${m.cost_usd}` : ''}${typeof m.reward === 'number' ? `, reward ${m.reward >= 0 ? '+' : ''}${m.reward}` : ''}`).join('\n') || '- none yet';
104
117
  const ranked = rankCampaignMembers(view, rsiSummary, events);
105
118
  const memberLine = ranked.map((r) => {
@@ -113,6 +126,7 @@ function proposalPrompt(view, count, judgeOutput = null, lessons = '', rsiSummar
113
126
  `You plan the next missions for an Atris campaign. Answer with JSON only.`,
114
127
  ``,
115
128
  `Campaign: ${view.title}`,
129
+ brief ? `Brief:\n${brief}` : '',
116
130
  `Judge (the campaign is done when this command exits 0): ${view.judge}`,
117
131
  `Last judge reading: ${view.last_judge ? `${view.last_judge.passed ? 'passed' : 'failed'}${typeof view.last_judge.reading === 'number' ? ` (${view.last_judge.reading} of 1)` : ''}: ${view.answer.text || ''}` : 'never run'}`,
118
132
  judgeOutput ? `Last judge output:\n\`\`\`\n${judgeOutput.replace(/```/g, "'''")}\n\`\`\`` : '',
@@ -231,7 +245,8 @@ function makeLoop(root, options = {}, deps = {}) {
231
245
  });
232
246
  rsiSummary = summarizeStoreRsi(store);
233
247
  } catch {}
234
- const prompt = proposalPrompt(current, count, latestJudgeOutput(events), deps.lessons ? deps.lessons(id) : '', rsiSummary, events);
248
+ const brief = (deps.brief ? deps.brief(id) : readBrief(root, id)).trim();
249
+ const prompt = proposalPrompt(current, count, latestJudgeOutput(events), deps.lessons ? deps.lessons(id) : '', rsiSummary, events, brief);
235
250
  const answer = await ask(root, current, prompt);
236
251
  const proposals = parseProposals(answer.text, current, count, rsiSummary, events);
237
252
  // Planning costs money too. It rides on the first mission it produced as a
@@ -242,8 +257,10 @@ function makeLoop(root, options = {}, deps = {}) {
242
257
  if (typeof answer.cost_usd === 'number') plan.cost_usd = answer.cost_usd;
243
258
  proposals.forEach((proposal, index) => {
244
259
  const missionId = proposalId(proposal.objective);
260
+ const key = claimKey(proposal.objective);
261
+ if (brief && !proposal.objective.includes(brief)) proposal.objective = `${proposal.objective}\n\n${brief}`;
245
262
  append(id, 'mission_proposed', { mission_id: missionId, member: proposal.member, objective: proposal.objective, verify: proposal.verify, estimate_usd: proposal.estimate_usd || undefined }, proposal.member);
246
- append(id, 'mission_claimed', { mission_id: missionId, claim_key: claimKey(proposal.objective) }, proposal.member);
263
+ append(id, 'mission_claimed', { mission_id: missionId, claim_key: key }, proposal.member);
247
264
  if (index === 0) append(id, 'step', { mission_id: missionId, ...plan }, current.owner);
248
265
  });
249
266
  return proposals;
@@ -264,8 +281,10 @@ function makeLoop(root, options = {}, deps = {}) {
264
281
  async function startAndRun(id, work) {
265
282
  const current = view(id);
266
283
  const reserve = work.estimate_usd || defaultReserve(current);
284
+ const brief = (deps.brief ? deps.brief(id) : readBrief(root, id)).trim();
285
+ const objective = brief && !work.objective.includes(brief) ? `${work.objective}\n\n${brief}` : work.objective;
267
286
  const started = await cli([
268
- 'mission', 'start', work.objective, '--mission-id', work.mission_id, '--owner', work.member,
287
+ 'mission', 'start', objective, '--mission-id', work.mission_id, '--owner', work.member,
269
288
  '--runner', runner, '--verify', work.verify || current.judge, '--campaign', id, '--reserve', String(reserve),
270
289
  '--max-wall', String(missionMinutes * 60), '--json',
271
290
  ]);
@@ -419,4 +438,4 @@ function makeLoop(root, options = {}, deps = {}) {
419
438
  return { step, tick, run, propose };
420
439
  }
421
440
 
422
- module.exports = { ACTIVITY_LINE, askOwner, makeLoop, parseProposals, proposalPrompt, rankCampaignMembers, readingKey, runCli };
441
+ module.exports = { ACTIVITY_LINE, askOwner, briefPath, makeLoop, parseProposals, proposalPrompt, rankCampaignMembers, readBrief, readingKey, runCli };
@@ -1,9 +1,12 @@
1
1
  'use strict';
2
2
 
3
+ const { openingLinesForRoot, renderOpeningLinesBlock } = require('./opening-lines');
4
+
3
5
  const ATRIS_START_MARKER = '<!-- ATRIS:START - Auto-generated, do not edit -->';
4
6
  const ATRIS_END_MARKER = '<!-- ATRIS:END -->';
5
7
 
6
- function renderAtrisClaudeBootBlock() {
8
+ function renderAtrisClaudeBootBlock(openingLines = openingLinesForRoot()) {
9
+ const opening = renderOpeningLinesBlock(openingLines, 'CLAUDE.md');
7
10
  return `${ATRIS_START_MARKER}
8
11
  ## Atris System
9
12
 
@@ -14,12 +17,12 @@ You are now running inside Atris, an AI workspace operating system.
14
17
  After displaying the boot output, respond to the user naturally.
15
18
 
16
19
  The person does not know atris words. Translate their plain English; when unsure run \`atris guide "<their words>"\`.
17
- ${ATRIS_END_MARKER}
20
+ ${opening ? `\n${opening}\n` : ''}${ATRIS_END_MARKER}
18
21
  `;
19
22
  }
20
23
 
21
- function upsertAtrisClaudeBootBlock(source = '') {
22
- const atrisBlock = renderAtrisClaudeBootBlock();
24
+ function upsertAtrisClaudeBootBlock(source = '', options = {}) {
25
+ const atrisBlock = renderAtrisClaudeBootBlock(options.openingLines);
23
26
  const startIdx = source.indexOf('<!-- ATRIS:START');
24
27
  const prependBlock = (preserved) => {
25
28
  const remainder = preserved.replace(/^\n+/, '');
@@ -914,6 +914,7 @@ function rosterPickFromValue(value, role, options = {}) {
914
914
  const primary = resolveRosterWords(main);
915
915
  if (!options.anyEngine && !engineCanTake(primary.engine, role)) throw new Error(`${primary.engine} cannot do ${kind} work`);
916
916
  let backup = null;
917
+ const moreBackups = [];
917
918
  let until = '';
918
919
  let untilYearless = '';
919
920
  let maxSeconds = null;
@@ -966,6 +967,11 @@ function rosterPickFromValue(value, role, options = {}) {
966
967
  } else if (backupMatch && !backup) {
967
968
  backup = resolveRosterWords(backupMatch[1]);
968
969
  if (!options.anyEngine && !engineCanTake(backup.engine, role)) throw new Error(`the backup ${backup.engine} cannot do ${kind} work`);
970
+ } else if (backupMatch) {
971
+ // every backup after the first steps in next, in written order
972
+ const more = resolveRosterWords(backupMatch[1]);
973
+ if (!options.anyEngine && !engineCanTake(more.engine, role)) throw new Error(`the backup ${more.engine} cannot do ${kind} work`);
974
+ moreBackups.push({ engine: more.engine, model: more.model || '', ...(more.effort ? { effort: more.effort } : {}) });
969
975
  } else if (untilMatch && !until) {
970
976
  const words = untilMatch[1].trim();
971
977
  until = parseRosterUntilWords(words);
@@ -989,6 +995,7 @@ function rosterPickFromValue(value, role, options = {}) {
989
995
  backup: backup ? backup.engine : '',
990
996
  ...(backup && backup.model ? { backup_model: backup.model } : {}),
991
997
  ...(backup && backup.effort ? { backup_effort: backup.effort } : {}),
998
+ ...(moreBackups.length ? { more_backups: moreBackups } : {}),
992
999
  ...(maxSeconds ? { max_seconds: maxSeconds } : {}),
993
1000
  ...(prep ? { prep } : {}),
994
1001
  until,
@@ -1102,6 +1109,19 @@ function pickWorkers(pick) {
1102
1109
  ...(pick.line ? { line: pick.line } : {}),
1103
1110
  });
1104
1111
  }
1112
+ for (const more of Array.isArray(pick.more_backups) ? pick.more_backups : []) {
1113
+ list.push({
1114
+ engine: more.engine,
1115
+ model: more.model || '',
1116
+ ...(more.effort ? { effort: more.effort } : {}),
1117
+ ...cap,
1118
+ until: '',
1119
+ never_expires: true,
1120
+ lenient_model: true,
1121
+ backup: true,
1122
+ ...(pick.line ? { line: pick.line } : {}),
1123
+ });
1124
+ }
1105
1125
  return list;
1106
1126
  }
1107
1127
 
@@ -1114,6 +1134,7 @@ function parseRosterMarkdown(text, file, options = {}) {
1114
1134
  const display = rosterFileLabel(file, options.root || process.cwd());
1115
1135
  const picks = {};
1116
1136
  const team = [];
1137
+ const shortlist = [];
1117
1138
  const warnings = [];
1118
1139
  const warn = (entry, message) => warnings.push({ file: display, line: entry.lineNumber, text: entry.raw, message });
1119
1140
  const scan = scanRosterMarkdown(text);
@@ -1171,6 +1192,27 @@ function parseRosterMarkdown(text, file, options = {}) {
1171
1192
  }
1172
1193
  continue;
1173
1194
  }
1195
+ if (entry.section === 'picks') {
1196
+ // "- claude · <model>": the tool, a middle dot, then the model as the
1197
+ // owner writes it. Without the dot the words read like a roster line.
1198
+ const dot = entry.value.indexOf('\u00b7');
1199
+ const toolWords = dot === -1 ? '' : entry.value.slice(0, dot).trim();
1200
+ const modelWords = dot === -1 ? '' : entry.value.slice(dot + 1).trim();
1201
+ let engine = toolWords ? rosterToolName(toolWords) : '';
1202
+ let model = modelWords;
1203
+ if (dot === -1) {
1204
+ try {
1205
+ const read = resolveRosterWords(entry.value);
1206
+ engine = read.engine;
1207
+ model = read.model ? workerModelText(read.engine, read.model) : '';
1208
+ } catch {
1209
+ engine = '';
1210
+ }
1211
+ }
1212
+ if (!engine) warn(entry, 'is not "tool · model", so the pick is skipped');
1213
+ else shortlist.push({ engine, model, line: entry.lineNumber, text: entry.raw, file: display });
1214
+ continue;
1215
+ }
1174
1216
  if (entry.malformed) {
1175
1217
  warn(entry, entry.section === 'team' ? 'is not "member: job or engine", so it is skipped' : 'is not "job: engine", so it is skipped');
1176
1218
  continue;
@@ -1217,7 +1259,7 @@ function parseRosterMarkdown(text, file, options = {}) {
1217
1259
  warn(entry, `${error.message}, so ${label} uses the next pick in line`);
1218
1260
  }
1219
1261
  }
1220
- return { picks, team, warnings };
1262
+ return { picks, team, warnings, ...(shortlist.length ? { shortlist } : {}) };
1221
1263
  }
1222
1264
 
1223
1265
  function readRosterMarkdownLayer(scope, file, options = {}) {
@@ -1602,6 +1644,25 @@ function assignedWorkerLines(key, engineName, options, context) {
1602
1644
  });
1603
1645
  const heading = jobHeadingText(key, builtIn ? '' : kind);
1604
1646
  if (options.add) return { lines: [...currentLines, line], heading };
1647
+ // keepLead (roster set): the new lead goes on top and every old worker
1648
+ // stays below it as written, the old lead first, so it becomes the first
1649
+ // backup. A line that already names this tool and model moves up as
1650
+ // written instead. A new line takes the old lead's time cap and prep: those
1651
+ // belong to the job, not the tool.
1652
+ if (options.keepLead) {
1653
+ const found = currentWorkers.findIndex((worker) => worker.engine === engine.id && (worker.model || '') === model);
1654
+ if (found !== -1) return { lines: [currentLines[found], ...currentLines.filter((text, index) => index !== found)], heading };
1655
+ const old = currentWorkers[0] || {};
1656
+ const carried = workerLineText({
1657
+ engine: engine.id,
1658
+ model,
1659
+ ...(effort ? { effort } : {}),
1660
+ ...(maxSeconds || old.max_seconds ? { max_seconds: maxSeconds || old.max_seconds } : {}),
1661
+ ...(prep || (old.prep && old.prep !== key) ? { prep: prep || old.prep } : {}),
1662
+ until: days ? rosterUntil(now, days) : '',
1663
+ });
1664
+ return { lines: [carried, ...currentLines], heading };
1665
+ }
1605
1666
  // A note the person left at the end of the old lead line stays with the lead.
1606
1667
  const leadNote = currentLines.length ? /\s*(<!--.*?-->)\s*$/.exec(currentLines[0]) : null;
1607
1668
  const lead = leadNote ? `${line} ${leadNote[1]}` : line;
@@ -1690,6 +1751,79 @@ function setRosterPick(jobName, engineName, options = {}, root = process.cwd())
1690
1751
  return target.scope === 'machine' ? readMachineRoster(options) : readProjectRoster(root, options).picks;
1691
1752
  }
1692
1753
 
1754
+ // Give one member its own line under "## team" ("- builder: codex, model:
1755
+ // <name>"), replacing that member's line in place or adding it. Same files
1756
+ // and layers as setRosterPick (session, everywhere, or this project), and the
1757
+ // same rewrite into sections first. role is the member's kind of work; the
1758
+ // line must read back as a pick the engine can do, or nothing is written.
1759
+ // A member's one-line pick with only its lead changed: the old lead (its
1760
+ // tool, model, and effort) goes to the front of the backups, a backup that
1761
+ // already names the new lead goes, and every other part stays as written:
1762
+ // backups, max, prep, until, verified. null when the old value is not a pick
1763
+ // (a job name, or a line that does not read), so the whole value is replaced.
1764
+ function teamValueWithLead(oldValue, engineId, model, role, options = {}) {
1765
+ let old;
1766
+ try {
1767
+ old = rosterPickFromValue(oldValue, role, options);
1768
+ } catch {
1769
+ return null;
1770
+ }
1771
+ const parts = String(oldValue || '').split(',').map((part) => part.trim()).filter(Boolean);
1772
+ for (let i = parts.length - 1; i > 0; i -= 1) {
1773
+ if (/^\d{4}$/.test(parts[i])) parts.splice(i - 1, 2, `${parts[i - 1]} ${parts[i]}`);
1774
+ }
1775
+ const [main, ...rest] = parts;
1776
+ const inlineMax = /\s+(max\s+\d+(?:\.\d+)?\s*[a-z]+)$/i.exec(main || '');
1777
+ const extras = inlineMax ? [inlineMax[1], ...rest] : rest;
1778
+ const words = (engine, pinned, effort) => [rosterToolLabel(engine), pinned ? workerModelText(engine, pinned) : '', effort || ''].filter(Boolean).join(' ');
1779
+ const namesNewLead = (text) => {
1780
+ try {
1781
+ const read = resolveRosterWords(text);
1782
+ return read.engine === engineId && (read.model || '') === model;
1783
+ } catch {
1784
+ return false;
1785
+ }
1786
+ };
1787
+ // model and effort parts belong to the old lead, so they go with it
1788
+ const kept = extras
1789
+ .filter((part) => !/^(model|effort)\s*(?::|\s)/i.test(part))
1790
+ .filter((part) => {
1791
+ const named = /^backup\s+(.+)$/i.exec(part);
1792
+ return !(named && namesNewLead(named[1]));
1793
+ });
1794
+ if (!(old.engine === engineId && (old.model || '') === model)) {
1795
+ const at = kept.findIndex((part) => /^backup\s+/i.test(part));
1796
+ kept.splice(at === -1 ? 0 : at, 0, `backup ${words(old.engine, old.model, old.effort)}`);
1797
+ }
1798
+ const lead = [rosterToolLabel(engineId), ...(model ? [`model: ${workerModelText(engineId, model)}`] : [])];
1799
+ return [...lead, ...kept].join(', ');
1800
+ }
1801
+
1802
+ function setTeamPick(memberName, engineName, options = {}, root = process.cwd()) {
1803
+ const { replaceTeamLine, scanRosterMarkdown } = require('./roster-markdown');
1804
+ const member = String(memberName || '').trim().toLowerCase();
1805
+ if (!/^[a-z0-9._-]+$/.test(member)) throw new Error(`"${memberName}" is not a member name`);
1806
+ const registry = readEngineRegistry(root, { persist: false });
1807
+ const engineId = rosterToolName(engineName);
1808
+ if (!registry.engines.some((entry) => entry.id === engineId)) throw new Error(`unknown engine "${engineName}"`);
1809
+ const model = normalizeRosterModel(engineId, options.model);
1810
+ if (model && !engineTakesModel(engineId)) throw new Error(`${engineId} runs one fixed model and cannot take "${model}"`);
1811
+ const role = options.role || 'executor';
1812
+ const target = rosterWriteTarget(options, root);
1813
+ const layer = target.layer || readProjectRoster(root, { ...options, registry });
1814
+ const file = target.file;
1815
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
1816
+ const base = existing !== null ? sectionedRosterText(existing, file, { ...options, root }) : rosterMarkdownFromPicks(layer.picks);
1817
+ // a swap changes only the lead of the member's line already in this file
1818
+ const line = scanRosterMarkdown(base).entries.find((entry) => entry.section === 'team' && !entry.malformed && entry.name.trim().toLowerCase() === member);
1819
+ const value = (line && teamValueWithLead(line.value, engineId, model, role, { root }))
1820
+ || workerLineText({ engine: engineId, model }).replace(/^-\s+/, '');
1821
+ rosterPickFromValue(value, role, { root });
1822
+ writeAtomic(file, replaceTeamLine(base, member, value));
1823
+ if (target.scope === 'session') sweepStaleSessionRosters(options);
1824
+ return { member, engine: engineId, model, value, scope: target.scope, file };
1825
+ }
1826
+
1693
1827
  function renewRoster(roster, date) {
1694
1828
  const next = { ...(roster || {}) };
1695
1829
  for (const key of [...ENGINE_ROLES, ...customRosterJobKeys(next)]) {
@@ -2317,7 +2451,11 @@ module.exports = {
2317
2451
  rosterChoiceForPick,
2318
2452
  customRosterJobKeys,
2319
2453
  setRosterPick,
2454
+ setTeamPick,
2320
2455
  confirmRoster,
2456
+ engineHasRole,
2457
+ engineHosted,
2458
+ rosterToolName,
2321
2459
  normalizeRosterModel,
2322
2460
  currentClaudeModel,
2323
2461
  claudeModelId,
package/lib/fleet.js CHANGED
@@ -1922,6 +1922,10 @@ function reviewOnlyEngineEnvironment(worktreePath, options = {}) {
1922
1922
  GIT_CONFIG_NOSYSTEM: '1',
1923
1923
  CODEX_HOME: path.join(runtimeHome, '.codex'),
1924
1924
  CLAUDE_CONFIG_DIR: path.join(runtimeHome, '.claude'),
1925
+ // Claude Code keeps its own temp files under /tmp/claude-<uid> unless this
1926
+ // is set. The sealed profile cannot read /tmp, so without it every sealed
1927
+ // claude build died at start ("EEXIST: mkdir /tmp/claude-501").
1928
+ CLAUDE_CODE_TMPDIR: runtimeTmp,
1925
1929
  ATRIS_ONE_LAP_SANDBOX_PROFILE: sandboxProfile,
1926
1930
  ATRIS_ONE_LAP_RUNTIME_DIR: runtimeTmp,
1927
1931
  ATRIS_ONE_LAP_CONTROL_DIR: controlTmp,
@@ -3442,6 +3446,9 @@ async function runDispatchFlight({
3442
3446
  // search" on its roster line), and an injectable ask for that prep pass.
3443
3447
  prep = '',
3444
3448
  prepAsk = undefined,
3449
+ // Extra house rules a caller appends to the builder's brief (atris idea
3450
+ // tick sends the four values and the no-force-push, no-hook-skip rules).
3451
+ brief = '',
3445
3452
  } = {}) {
3446
3453
  if (!engine) throw new Error('runDispatchFlight: engine is required');
3447
3454
  if (!DISPATCH_CAPABLE.includes(engine)) {
@@ -3563,13 +3570,15 @@ async function runDispatchFlight({
3563
3570
  }
3564
3571
  const worktreePath = enforceRemoteBoundary ? remoteBoundary.worktreePath : landingWorktreePath;
3565
3572
  const startCommit = yolo ? readStartCommit({ worktreePath }) : '';
3566
- const basePrompt = promptOverride || buildFleetPrompt(task, { worktreePath, yolo: yolo && !localOnly });
3573
+ const briefNote = String(brief || '').trim();
3574
+ const builtPrompt = promptOverride || buildFleetPrompt(task, { worktreePath, yolo: yolo && !localOnly });
3575
+ const basePrompt = briefNote ? `${builtPrompt}\n\n${briefNote}` : builtPrompt;
3567
3576
  const safetyPrompt = proofOnly
3568
3577
  ? `${basePrompt}\n\nSafety boundary: edit and test this checkout only. Do not push, merge, deploy, publish, send messages, change cloud state, install dependencies, or use credentials.`
3569
3578
  : basePrompt;
3570
3579
  const trustedPrompt = trustedVerifier
3571
3580
  ? `${safetyPrompt}\n\nTrusted verifier (run it bare before reporting done): ${trustedVerifier}`
3572
- : (localOnly ? safetyPrompt : promptOverride);
3581
+ : (localOnly ? safetyPrompt : (briefNote ? basePrompt : promptOverride));
3573
3582
  prepared.push({
3574
3583
  task,
3575
3584
  taskId,