@ours.network/fleet 2.3.0 → 2.3.2

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 (113) hide show
  1. package/README.md +1 -1
  2. package/dist/application/harness-capabilities.d.ts +60 -0
  3. package/dist/application/harness-capabilities.js +96 -0
  4. package/dist/application/model-catalog.d.ts +17 -0
  5. package/dist/application/model-catalog.js +32 -9
  6. package/dist/application/preset-provenance.d.ts +1 -1
  7. package/dist/application/preset-provenance.js +18 -3
  8. package/dist/application/role-creation-service.d.ts +3 -1
  9. package/dist/application/role-creation-service.js +4 -1
  10. package/dist/application/task-room-service.d.ts +1 -1
  11. package/dist/application/task-room-service.js +2 -2
  12. package/dist/build-info.json +4 -4
  13. package/dist/harness/claude-code.js +7 -0
  14. package/dist/harness/codex.js +7 -0
  15. package/dist/harness/hermes.js +2 -0
  16. package/dist/harness/types.d.ts +20 -0
  17. package/dist/init-wizard.d.ts +19 -12
  18. package/dist/init-wizard.js +122 -104
  19. package/dist/rooms-tasks/add-member.d.ts +15 -1
  20. package/dist/rooms-tasks/add-member.js +27 -4
  21. package/dist/rooms-tasks/task-state.js +7 -2
  22. package/dist/web/fleet-config-service.d.ts +8 -0
  23. package/dist/web/fleet-config-service.js +23 -8
  24. package/dist/web/resource-views.d.ts +2 -0
  25. package/dist/web/resource-views.js +26 -15
  26. package/dist/web/server.js +58 -24
  27. package/package.json +1 -1
  28. package/presets/README.md +38 -18
  29. package/presets/brain-catalog.json +19 -19
  30. package/presets/legacy/generated-brains-rev4.json +497 -0
  31. package/presets/manifest.json +1 -1
  32. package/presets/fleet/brains/claude-fable-5-1-high.yaml +0 -5
  33. package/presets/fleet/brains/claude-fable-5-1-low.yaml +0 -5
  34. package/presets/fleet/brains/claude-fable-5-1-max.yaml +0 -5
  35. package/presets/fleet/brains/claude-fable-5-1-medium.yaml +0 -5
  36. package/presets/fleet/brains/claude-fable-5-1-xhigh.yaml +0 -5
  37. package/presets/fleet/brains/claude-fable-5-high.yaml +0 -5
  38. package/presets/fleet/brains/claude-fable-5-low.yaml +0 -5
  39. package/presets/fleet/brains/claude-fable-5-max.yaml +0 -5
  40. package/presets/fleet/brains/claude-fable-5-medium.yaml +0 -5
  41. package/presets/fleet/brains/claude-fable-5-xhigh.yaml +0 -5
  42. package/presets/fleet/brains/claude-haiku-4-5-20251001-default.yaml +0 -4
  43. package/presets/fleet/brains/claude-opus-5-5-high.yaml +0 -5
  44. package/presets/fleet/brains/claude-opus-5-5-low.yaml +0 -5
  45. package/presets/fleet/brains/claude-opus-5-5-max.yaml +0 -5
  46. package/presets/fleet/brains/claude-opus-5-5-medium.yaml +0 -5
  47. package/presets/fleet/brains/claude-opus-5-5-xhigh.yaml +0 -5
  48. package/presets/fleet/brains/claude-opus-5-high.yaml +0 -5
  49. package/presets/fleet/brains/claude-opus-5-low.yaml +0 -5
  50. package/presets/fleet/brains/claude-opus-5-max.yaml +0 -5
  51. package/presets/fleet/brains/claude-opus-5-medium.yaml +0 -5
  52. package/presets/fleet/brains/claude-opus-5-xhigh.yaml +0 -5
  53. package/presets/fleet/brains/claude-sonnet-5-high.yaml +0 -5
  54. package/presets/fleet/brains/claude-sonnet-5-low.yaml +0 -5
  55. package/presets/fleet/brains/claude-sonnet-5-max.yaml +0 -5
  56. package/presets/fleet/brains/claude-sonnet-5-medium.yaml +0 -5
  57. package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +0 -5
  58. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +0 -5
  59. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +0 -5
  60. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +0 -5
  61. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +0 -5
  62. package/presets/fleet/brains/codex-gpt-5-4-high.yaml +0 -5
  63. package/presets/fleet/brains/codex-gpt-5-4-low.yaml +0 -5
  64. package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +0 -5
  65. package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +0 -5
  66. package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +0 -5
  67. package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +0 -5
  68. package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +0 -5
  69. package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +0 -5
  70. package/presets/fleet/brains/codex-gpt-5-5-high.yaml +0 -5
  71. package/presets/fleet/brains/codex-gpt-5-5-low.yaml +0 -5
  72. package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +0 -5
  73. package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +0 -5
  74. package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +0 -5
  75. package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +0 -5
  76. package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +0 -5
  77. package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +0 -5
  78. package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +0 -5
  79. package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +0 -5
  80. package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +0 -5
  81. package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +0 -5
  82. package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +0 -5
  83. package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +0 -5
  84. package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +0 -5
  85. package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +0 -5
  86. package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +0 -5
  87. package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +0 -5
  88. package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +0 -5
  89. package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +0 -5
  90. package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +0 -5
  91. package/presets/fleet/brains/codex-gpt-6-1-sol-high.yaml +0 -5
  92. package/presets/fleet/brains/codex-gpt-6-1-sol-low.yaml +0 -5
  93. package/presets/fleet/brains/codex-gpt-6-1-sol-max.yaml +0 -5
  94. package/presets/fleet/brains/codex-gpt-6-1-sol-medium.yaml +0 -5
  95. package/presets/fleet/brains/codex-gpt-6-1-sol-ultra.yaml +0 -5
  96. package/presets/fleet/brains/codex-gpt-6-1-sol-xhigh.yaml +0 -5
  97. package/presets/fleet/brains/codex-gpt-6-astra-high.yaml +0 -5
  98. package/presets/fleet/brains/codex-gpt-6-astra-low.yaml +0 -5
  99. package/presets/fleet/brains/codex-gpt-6-astra-max.yaml +0 -5
  100. package/presets/fleet/brains/codex-gpt-6-astra-medium.yaml +0 -5
  101. package/presets/fleet/brains/codex-gpt-6-astra-ultra.yaml +0 -5
  102. package/presets/fleet/brains/codex-gpt-6-astra-xhigh.yaml +0 -5
  103. package/presets/fleet/brains/codex-gpt-6-luna-high.yaml +0 -5
  104. package/presets/fleet/brains/codex-gpt-6-luna-low.yaml +0 -5
  105. package/presets/fleet/brains/codex-gpt-6-luna-max.yaml +0 -5
  106. package/presets/fleet/brains/codex-gpt-6-luna-medium.yaml +0 -5
  107. package/presets/fleet/brains/codex-gpt-6-luna-xhigh.yaml +0 -5
  108. package/presets/fleet/brains/codex-gpt-6-sol-high.yaml +0 -5
  109. package/presets/fleet/brains/codex-gpt-6-sol-low.yaml +0 -5
  110. package/presets/fleet/brains/codex-gpt-6-sol-max.yaml +0 -5
  111. package/presets/fleet/brains/codex-gpt-6-sol-medium.yaml +0 -5
  112. package/presets/fleet/brains/codex-gpt-6-sol-ultra.yaml +0 -5
  113. package/presets/fleet/brains/codex-gpt-6-sol-xhigh.yaml +0 -5
@@ -4,58 +4,25 @@ import { basename, dirname, join, resolve } from 'node:path';
4
4
  import { replaceFileAtomically, withFileLock } from './atomic-file.js';
5
5
  import { loadConfig, splitRootFor } from './config.js';
6
6
  import { packagedPresetRoot } from './preset-bootstrap.js';
7
+ import { packagedModelCatalog } from './application/model-catalog.js';
8
+ import { parse as parseYaml } from 'yaml';
7
9
  import { listTemplates } from './rooms-tasks/templates.js';
8
10
  const WORK_KINDS = ['development', 'review', 'coordination'];
9
11
  const ROLE_WORK = {
10
12
  Developer: 'development', Engineer: 'development', Critic: 'review',
11
13
  LocalCoordinator: 'coordination', Coordinator: 'coordination', FleetCoordinator: 'coordination',
12
14
  };
13
- const REASONING_EFFORT = {
14
- quick: 'low', balanced: 'medium', thorough: 'high',
15
+ /** The three editable purpose Brains setup creates, by kind of work. */
16
+ export const PURPOSE_BRAINS = {
17
+ development: { id: 'development', label: 'Development' },
18
+ review: { id: 'critic', label: 'Critic' },
19
+ coordination: { id: 'coordination', label: 'Coordination' },
15
20
  };
16
- const MODEL_LABELS = {
17
- 'gpt-6.1-sol': 'GPT-6.1 Sol',
18
- 'gpt-6-sol': 'GPT-6 Sol',
19
- 'gpt-6-luna': 'GPT-6 Luna',
20
- 'claude-fable-5-1': 'Claude Fable 5.1',
21
- 'claude-opus-5-5': 'Claude Opus 5.5',
22
- 'claude-haiku-4-5-20251001': 'Claude Haiku 4.5',
23
- 'gpt-6-astra': 'GPT-6 Astra',
24
- 'gpt-5.6-sol': 'GPT-5.6 Sol',
25
- 'gpt-5.6-terra': 'GPT-5.6 Terra',
26
- 'gpt-5.6-luna': 'GPT-5.6 Luna',
27
- 'gpt-5.5': 'GPT-5.5',
28
- 'gpt-5.4': 'GPT-5.4',
29
- 'gpt-5.4-mini': 'GPT-5.4 Mini',
30
- 'gpt-5.3-codex-spark': 'GPT-5.3 Codex Spark',
31
- 'claude-fable-5': 'Claude Fable 5',
32
- 'claude-opus-5': 'Claude Opus 5',
33
- 'claude-sonnet-5': 'Claude Sonnet 5',
34
- };
35
- export function validateCatalog(value, path = 'supported model catalog') {
36
- if (value.schema_version !== 1 || !Array.isArray(value.models) || value.models.length === 0)
37
- throw new Error(`invalid supported model catalog: ${path}`);
38
- const tuples = new Set();
39
- for (const model of value.models) {
40
- if (!['codex', 'claude-code'].includes(model.harness) || model.session !== 'acp'
41
- || typeof model.model !== 'string' || !MODEL_LABELS[model.model]
42
- || !Array.isArray(model.efforts)
43
- || new Set(model.efforts).size !== model.efforts.length
44
- || model.efforts.some(effort => !['low', 'medium', 'high', 'xhigh', 'max', 'ultra'].includes(effort))
45
- || (model.efforts.length > 0 && !['low', 'medium', 'high'].every(effort => model.efforts.includes(effort))))
46
- throw new Error(`unsupported model state in ${path}`);
47
- const tuple = `${model.harness}\0${model.session}\0${model.model}`;
48
- if (tuples.has(tuple))
49
- throw new Error(`duplicate supported model in ${path}: ${model.model}`);
50
- tuples.add(tuple);
51
- }
52
- return value;
53
- }
54
- function catalog() {
55
- const path = join(packagedPresetRoot(), 'brain-catalog.json');
56
- return validateCatalog(JSON.parse(readFileSync(path, 'utf8')), path);
57
- }
21
+ // Legacy --settings files chose one reasoning level for every purpose.
22
+ const LEGACY_REASONING = { quick: 'low', balanced: 'medium', thorough: 'high' };
23
+ const labelOf = (choice) => packagedModelCatalog().find(model => model.harness === choice.harness && model.model === choice.model)?.label ?? choice.model;
58
24
  const hasExactKeys = (value, keys) => Object.keys(value).sort().join('\0') === [...keys].sort().join('\0');
25
+ const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
59
26
  /** Read and validate the complete public InitAnswers JSON shape without side effects. */
60
27
  export function readInitSettings(path) {
61
28
  let value;
@@ -65,21 +32,38 @@ export function readInitSettings(path) {
65
32
  catch (error) {
66
33
  throw new Error(`cannot read valid Fleet init settings ${path}: ${error.message}`, { cause: error });
67
34
  }
68
- if (!value || typeof value !== 'object' || Array.isArray(value)
69
- || !hasExactKeys(value, ['subscriptions', 'assignmentStrategy', 'models', 'reasoning']))
70
- throw new Error('Fleet init settings must contain exactly subscriptions, assignmentStrategy, models, and reasoning');
71
- const candidate = value;
72
- const models = candidate.models;
73
- if (!models || typeof models !== 'object' || Array.isArray(models)
74
- || !hasExactKeys(models, WORK_KINDS))
35
+ if (!isObject(value))
36
+ throw new Error('Fleet init settings must be a JSON object');
37
+ const legacy = hasExactKeys(value, ['subscriptions', 'assignmentStrategy', 'models', 'reasoning']);
38
+ if (!legacy && !hasExactKeys(value, ['subscriptions', 'assignmentStrategy', 'models']))
39
+ throw new Error('Fleet init settings must contain exactly subscriptions, assignmentStrategy, and models');
40
+ const models = value.models;
41
+ if (!isObject(models) || !hasExactKeys(models, WORK_KINDS))
75
42
  throw new Error('Fleet init settings models must contain exactly development, review, and coordination');
43
+ const chosen = {};
76
44
  for (const work of WORK_KINDS) {
77
45
  const model = models[work];
78
- if (!model || typeof model !== 'object' || Array.isArray(model)
79
- || !hasExactKeys(model, ['harness', 'session', 'model', 'efforts']))
80
- throw new Error(`Fleet init settings ${work} model must contain exactly harness, session, model, and efforts`);
46
+ if (legacy) {
47
+ // The earlier shape: catalog entries plus one reasoning level; each purpose gets that exact effort.
48
+ if (!isObject(model) || !hasExactKeys(model, ['harness', 'session', 'model', 'efforts']))
49
+ throw new Error(`Fleet init settings ${work} model must contain exactly harness, session, model, and efforts`);
50
+ const effort = LEGACY_REASONING[String(value.reasoning)];
51
+ if (!effort)
52
+ throw new Error(`unsupported reasoning preference: ${String(value.reasoning)}`);
53
+ chosen[work] = { harness: model.harness, session: model.session,
54
+ model: model.model, effort };
55
+ }
56
+ else {
57
+ if (!isObject(model) || !hasExactKeys(model, ['harness', 'session', 'model', 'effort']))
58
+ throw new Error(`Fleet init settings ${work} model must contain exactly harness, session, model, and effort`);
59
+ chosen[work] = model;
60
+ }
81
61
  }
82
- const answers = value;
62
+ const answers = {
63
+ subscriptions: value.subscriptions,
64
+ assignmentStrategy: value.assignmentStrategy,
65
+ models: chosen,
66
+ };
83
67
  // Generation is the single semantic/catalog validator for wizard and file input.
84
68
  generateSetup(answers);
85
69
  return answers;
@@ -96,9 +80,7 @@ export async function askInitQuestions(prompter, configuration) {
96
80
  return undefined;
97
81
  if (subscriptions.length === 0)
98
82
  throw new Error('Select at least one subscription.');
99
- // The wizard assigns low/medium/high; no-effort models remain available as named presets.
100
- const available = catalog().models.filter(model => subscriptions.includes(subscriptionFor(model))
101
- && model.efforts.length > 0);
83
+ const available = packagedModelCatalog().filter(model => subscriptions.includes(subscriptionFor(model)));
102
84
  if (!available.length)
103
85
  throw new Error('None of the selected subscriptions has a supported model.');
104
86
  prompter.note([
@@ -112,35 +94,41 @@ export async function askInitQuestions(prompter, configuration) {
112
94
  if (!assignmentStrategy)
113
95
  return undefined;
114
96
  const choices = available.map(model => ({
115
- label: `${MODEL_LABELS[model.model]} (${subscriptionFor(model) === 'codex' ? 'Codex' : 'Claude'}) — ${model.model}; supported catalog ID, not a recommendation`,
97
+ label: `${model.label} (${subscriptionFor(model) === 'codex' ? 'Codex' : 'Claude'}) — ${model.model}; supported catalog ID, not a recommendation`,
116
98
  value: model,
117
99
  }));
100
+ const askEffort = async (purpose, model) => {
101
+ if (!model.efforts.length) {
102
+ prompter.note(`${model.label} has no effort setting; the ${purpose} Brain sets none.`);
103
+ return null;
104
+ }
105
+ const initial = Math.max(0, model.efforts.indexOf('medium'));
106
+ return prompter.select(`Which effort should the ${purpose} Brain use with ${model.label}?`, model.efforts.map(effort => ({ label: effort, value: effort })), initial);
107
+ };
118
108
  const selected = {};
119
109
  if (assignmentStrategy === 'one-model') {
120
110
  const model = await prompter.select('Which model should handle every job?', choices, 0);
121
111
  if (!model)
122
112
  return undefined;
123
- for (const work of WORK_KINDS)
124
- selected[work] = model;
113
+ for (const work of WORK_KINDS) {
114
+ const effort = await askEffort(PURPOSE_BRAINS[work].label, model);
115
+ if (effort === undefined)
116
+ return undefined;
117
+ selected[work] = { harness: model.harness, session: model.session, model: model.model, effort };
118
+ }
125
119
  }
126
120
  else {
127
121
  for (const work of WORK_KINDS) {
128
- const model = await prompter.select(`Which model should handle ${work}?`, choices, 0);
122
+ const model = await prompter.select(`Which model should the ${PURPOSE_BRAINS[work].label} Brain use?`, choices, 0);
129
123
  if (!model)
130
124
  return undefined;
131
- selected[work] = model;
125
+ const effort = await askEffort(PURPOSE_BRAINS[work].label, model);
126
+ if (effort === undefined)
127
+ return undefined;
128
+ selected[work] = { harness: model.harness, session: model.session, model: model.model, effort };
132
129
  }
133
130
  }
134
- const reasoning = await prompter.select('How much reasoning should Fleet use?', [
135
- { label: 'Quick — favor speed', value: 'quick' },
136
- { label: 'Balanced — everyday work', value: 'balanced' },
137
- { label: 'Thorough — harder work', value: 'thorough' },
138
- ], 1);
139
- if (!reasoning)
140
- return undefined;
141
- const answers = {
142
- subscriptions: [...subscriptions].sort(), assignmentStrategy, models: selected, reasoning,
143
- };
131
+ const answers = { subscriptions: [...subscriptions].sort(), assignmentStrategy, models: selected };
144
132
  const summary = formatSetupSummary(answers, configuration);
145
133
  const finalized = await prompter.confirm('Create this default Fleet setup?', `${summary}\n\nNo changes have been made yet.`);
146
134
  return finalized ? answers : undefined;
@@ -153,18 +141,17 @@ export function formatSetupSummary(answers, configuration) {
153
141
  const existing = configExists && rootExists ? 'both targets exist'
154
142
  : configExists ? 'only the manifest exists'
155
143
  : rootExists ? 'only the split configuration directory exists' : 'neither target exists';
156
- const friendly = (work) => `${MODEL_LABELS[answers.models[work].model]} (${subscriptionFor(answers.models[work]) === 'codex' ? 'Codex' : 'Claude'}, ${answers.models[work].model})`;
157
- const effort = REASONING_EFFORT[answers.reasoning];
144
+ const friendly = (work) => {
145
+ const choice = answers.models[work];
146
+ return `${PURPOSE_BRAINS[work].label} Brain (${PURPOSE_BRAINS[work].id}): ${labelOf(choice)} (${subscriptionFor(choice) === 'codex' ? 'Codex' : 'Claude'}, ${choice.model}), effort ${choice.effort ?? 'none (model has no effort setting)'}`;
147
+ };
158
148
  return [
159
149
  'Your default Fleet setup:',
160
150
  ` Manifest: ${configPath}`,
161
151
  ` Split configuration: ${splitRoot}`,
162
152
  ` Existing state: ${existing}`,
163
153
  ` Model assignment: ${answers.assignmentStrategy === 'one-model' ? 'one model for every job' : 'different models by job'}`,
164
- ` Development: ${friendly('development')}`,
165
- ` Review: ${friendly('review')}`,
166
- ` Coordination: ${friendly('coordination')}`,
167
- ` Reasoning: ${answers.reasoning[0].toUpperCase()}${answers.reasoning.slice(1)} -> ${effort}`,
154
+ ...WORK_KINDS.map(work => ` ${friendly(work)}`),
168
155
  ' Availability: catalog support is not a recommendation or entitlement claim',
169
156
  ' Entitlement check: run ours-fleet doctor for Codex; Claude is validated when a role launches',
170
157
  ' Automatic model fallback: none (no model_chain is generated)',
@@ -172,8 +159,8 @@ export function formatSetupSummary(answers, configuration) {
172
159
  ' Reviewed pair: Developer + independent Critic',
173
160
  ' Team: LocalCoordinator + Developer + Critic',
174
161
  ' Gated engineering: Engineer (gate pipeline) + independent Critic',
175
- ' FleetCoordinator: coordination model',
176
- ' Brains: the packaged presets; each prepared agent references the preset of its chosen model',
162
+ ' FleetCoordinator: Coordination Brain',
163
+ ' Brains: three editable purpose Brains (development, critic, coordination); change them or add more in Settings > Brains',
177
164
  '',
178
165
  'After the final Yes, Fleet performs host service setup before publishing the complete configuration.',
179
166
  'A hard termination after that Yes can leave host integration or private stage/recovery evidence to inspect.',
@@ -182,23 +169,26 @@ export function formatSetupSummary(answers, configuration) {
182
169
  function preset(relative) {
183
170
  return readFileSync(join(packagedPresetRoot(), 'fleet', relative), 'utf8');
184
171
  }
185
- /** The packaged Brain preset for a catalog model at one effort; the same naming the presets are generated with. */
186
- export function brainPresetId(model, effort) {
187
- return `${model.harness === 'claude-code' ? 'claude' : 'codex'}-${model.model
188
- .replace(/^claude-/, '').replace(/\./g, '-').toLowerCase()}-${effort}`;
172
+ /** The YAML of one purpose Brain holding exactly the chosen triple. */
173
+ export function purposeBrainYaml(work, choice) {
174
+ return [
175
+ `# ${PURPOSE_BRAINS[work].label} Brain created by Fleet setup. Edit it in Settings > Brains.`,
176
+ `harness: ${choice.harness}`,
177
+ `session: ${choice.session}`,
178
+ `model: ${choice.model}`,
179
+ ...(choice.effort === null ? [] : [`effort: ${choice.effort}`]),
180
+ '',
181
+ ].join('\n');
189
182
  }
190
183
  /** Build the exact, deterministic default experience without writing to disk. */
191
184
  export function generateSetup(answers) {
192
- const effort = REASONING_EFFORT[answers.reasoning];
193
- if (!effort)
194
- throw new Error(`unsupported reasoning preference: ${String(answers.reasoning)}`);
195
185
  if (!['one-model', 'per-job'].includes(answers.assignmentStrategy))
196
186
  throw new Error(`unsupported model assignment strategy: ${String(answers.assignmentStrategy)}`);
197
187
  if (!Array.isArray(answers.subscriptions) || answers.subscriptions.length === 0
198
188
  || new Set(answers.subscriptions).size !== answers.subscriptions.length
199
189
  || answers.subscriptions.some(subscription => !['codex', 'claude'].includes(subscription)))
200
190
  throw new Error('subscriptions must be a non-empty unique selection of Codex and/or Claude');
201
- const supported = catalog().models;
191
+ const supported = packagedModelCatalog();
202
192
  const files = new Map();
203
193
  files.set('fleet.yaml', [
204
194
  'api_version: ours.network/fleet/v2',
@@ -207,9 +197,8 @@ export function generateSetup(answers) {
207
197
  ' permissions: { approval: ask, filesystem: workspace, unattended: deny }',
208
198
  '',
209
199
  ].join('\n'));
210
- const assigned = {};
211
200
  for (const work of WORK_KINDS) {
212
- const proposed = answers.models[work];
201
+ const proposed = answers.models?.[work];
213
202
  if (!proposed || !answers.subscriptions.includes(subscriptionFor(proposed)))
214
203
  throw new Error(`${work} uses a model outside the selected subscriptions`);
215
204
  const matches = supported.filter(model => model.harness === proposed.harness
@@ -217,18 +206,16 @@ export function generateSetup(answers) {
217
206
  if (matches.length !== 1)
218
207
  throw new Error(`${work} uses an unknown or ambiguous supported model`);
219
208
  const model = matches[0];
220
- if (JSON.stringify(proposed.efforts) !== JSON.stringify(model.efforts))
221
- throw new Error(`${work} contains tampered model capabilities`);
222
- if (!model.efforts.includes(effort))
223
- throw new Error(`${MODEL_LABELS[model.model]} does not support ${answers.reasoning} reasoning`);
224
- // The chosen model is assigned by reference to its packaged Brain preset; setup defines no Brain of its own.
225
- const id = brainPresetId(model, effort);
226
- if (!existsSync(join(packagedPresetRoot(), 'fleet', 'brains', `${id}.yaml`)))
227
- throw new Error(`no packaged Brain preset for ${MODEL_LABELS[model.model]} at ${effort} reasoning`);
228
- assigned[work] = id;
209
+ // The chosen effort is kept exactly; nothing is substituted.
210
+ if (model.efforts.length === 0) {
211
+ if (proposed.effort !== null)
212
+ throw new Error(`${model.label} has no effort setting; ${work} effort must be null`);
213
+ }
214
+ else if (typeof proposed.effort !== 'string' || !model.efforts.includes(proposed.effort)) {
215
+ throw new Error(`${model.label} does not support effort '${String(proposed.effort)}' for ${work}; choose one of: ${model.efforts.join(', ')}`);
216
+ }
217
+ files.set(`brains/${PURPOSE_BRAINS[work].id}.yaml`, purposeBrainYaml(work, proposed));
229
218
  }
230
- for (const name of readdirSync(join(packagedPresetRoot(), 'fleet', 'brains')).filter(name => name.endsWith('.yaml')).sort())
231
- files.set(`brains/${name}`, preset(`brains/${name}`));
232
219
  if (answers.assignmentStrategy === 'one-model') {
233
220
  const tuples = new Set(WORK_KINDS.map(work => {
234
221
  const model = answers.models[work];
@@ -243,7 +230,7 @@ export function generateSetup(answers) {
243
230
  continue;
244
231
  files.set(`agent_templates/${role}.yaml`, [
245
232
  `role: { ref: ${role} }`,
246
- `brain: { ref: ${assigned[ROLE_WORK[role]]} }`,
233
+ `brain: { ref: ${PURPOSE_BRAINS[ROLE_WORK[role]].id} }`,
247
234
  'coordinator: FleetCoordinator',
248
235
  // Temporary default while the product is being tested: prepared agents work without asking.
249
236
  'permissions: { approval: allow, filesystem: unrestricted, unattended: deny }',
@@ -252,13 +239,40 @@ export function generateSetup(answers) {
252
239
  ].join('\n'));
253
240
  }
254
241
  files.set('agents/FleetCoordinator.yaml', preset('agents/FleetCoordinator.yaml')
255
- .replace('brain: { ref: claude-default }', `brain: { ref: ${assigned.coordination} }`));
242
+ .replace('brain: { ref: claude-default }', `brain: { ref: ${PURPOSE_BRAINS.coordination.id} }`));
256
243
  for (const name of ['single', 'pair', 'team', 'engineering']) {
257
244
  files.set(`room_templates/${name}.yaml`, preset(`room_templates/${name}.yaml`));
258
245
  files.set(`room_layouts/${name}.yaml`, preset(`room_layouts/${name}.yaml`));
259
246
  }
260
247
  return { files, answers };
261
248
  }
249
+ /** Setup stopped because an existing purpose Brain holds other settings than the chosen ones. */
250
+ export class PurposeBrainConflictError extends Error {
251
+ }
252
+ /**
253
+ * Setup preserves existing files. A purpose Brain that already exists with a different
254
+ * harness, session, model or effort is never overwritten; setup stops and says so.
255
+ */
256
+ export function purposeBrainConflicts(configuration, answers) {
257
+ const brains = join(splitRootFor(resolve(configuration)), 'brains');
258
+ return WORK_KINDS.flatMap(work => {
259
+ const { id } = PURPOSE_BRAINS[work];
260
+ const path = join(brains, `${id}.yaml`);
261
+ if (!existsSync(path))
262
+ return [];
263
+ let value;
264
+ try {
265
+ value = (parseYaml(readFileSync(path, 'utf8')) ?? {});
266
+ }
267
+ catch {
268
+ return [`brains/${id}.yaml exists and cannot be read`];
269
+ }
270
+ const wanted = answers.models[work];
271
+ const same = value.harness === wanted.harness && (value.session ?? 'acp') === wanted.session
272
+ && value.model === wanted.model && (value.effort ?? null) === wanted.effort;
273
+ return same ? [] : [`brains/${id}.yaml already exists with different settings`];
274
+ });
275
+ }
262
276
  /** Run the mutation boundary only after the complete questionnaire is accepted. */
263
277
  export async function executeInitWizard(prompter, configuration, deps) {
264
278
  const answers = await askInitQuestions(prompter, configuration);
@@ -273,6 +287,10 @@ export async function executeInitAnswers(answers, configuration, deps) {
273
287
  // deterministically in tests.
274
288
  const setup = (deps.generate ?? generateSetup)(answers);
275
289
  (deps.preflight ?? preflightInitPaths)(configuration);
290
+ const conflicts = purposeBrainConflicts(configuration, answers);
291
+ if (conflicts.length)
292
+ throw new PurposeBrainConflictError(`Setup would not use the chosen models: ${conflicts.join('; ')}. `
293
+ + 'Existing Brains are kept; change them in Settings > Brains or choose matching models.');
276
294
  try {
277
295
  await deps.hostSetup();
278
296
  }
@@ -6,13 +6,25 @@ import type { TaskLayouts } from '../application/task-layouts.js';
6
6
  export declare class MemberAdditionRejected extends TaskStateError {
7
7
  readonly accepted = false;
8
8
  }
9
+ /** A one-off harness/model/effort for this member only; no Brain is saved. Omitted model/effort keep the harness default. */
10
+ export interface ManualBrain {
11
+ harness: string;
12
+ session?: string;
13
+ model?: string;
14
+ effort?: string;
15
+ }
16
+ /** `brain` omitted: the member uses its Agent Template's own Brain, unchanged. */
9
17
  export interface AddMemberRequest {
10
18
  requestId: string;
11
19
  slot: string;
12
20
  role: string;
13
- brain: string;
21
+ brain?: string | {
22
+ inline: ManualBrain;
23
+ };
14
24
  agentTemplate: string;
15
25
  }
26
+ /** No Brain, a saved Brain name, or exactly one manual harness/model/effort. */
27
+ export declare function validMemberBrain(brain: unknown): brain is AddMemberRequest['brain'];
16
28
  export declare function memberAddition(taskId: string, requestId: string): {
17
29
  requestId: string;
18
30
  taskId: string;
@@ -35,6 +47,8 @@ export declare function requestMemberAddition(input: {
35
47
  binPath: string;
36
48
  provision?: typeof provisionMembers;
37
49
  layouts?: TaskLayouts;
50
+ /** Checks a new request only; a retry of an accepted request returns its receipt. */
51
+ checkNewRequest?: (request: AddMemberRequest) => Promise<void>;
38
52
  }): Promise<{
39
53
  requestId: string;
40
54
  taskId: string;
@@ -16,6 +16,28 @@ import { validLayoutKey } from './layout-config.js';
16
16
  export class MemberAdditionRejected extends TaskStateError {
17
17
  accepted = false;
18
18
  }
19
+ const MANUAL_KEYS = ['harness', 'session', 'model', 'effort'];
20
+ const text = (v, max = 128) => typeof v === 'string' && v.length > 0 && v.length <= max;
21
+ /** No Brain, a saved Brain name, or exactly one manual harness/model/effort. */
22
+ export function validMemberBrain(brain) {
23
+ if (brain === undefined || text(brain))
24
+ return true;
25
+ const inline = brain?.inline;
26
+ return Boolean(brain) && typeof brain === 'object' && Object.keys(brain).join() === 'inline'
27
+ && Boolean(inline) && typeof inline === 'object' && !Array.isArray(inline)
28
+ && Object.keys(inline).every(key => MANUAL_KEYS.includes(key)) && text(inline.harness)
29
+ && ['session', 'model', 'effort'].every(key => inline[key] === undefined || text(inline[key]));
30
+ }
31
+ /** The member override: a saved Brain by name, or the manual triple as this member's own inline Brain. */
32
+ function brainOverride(brain) {
33
+ if (brain === undefined)
34
+ return {};
35
+ if (typeof brain === 'string')
36
+ return { brain };
37
+ const { harness, session, model, effort } = brain.inline;
38
+ return { overrides: { brain: { inline: { harness, session: session ?? 'acp',
39
+ ...(model !== undefined ? { model } : {}), ...(effort !== undefined ? { effort } : {}) } } } };
40
+ }
19
41
  const running = new Set();
20
42
  function pathFor(taskId, requestId) {
21
43
  if (!/^[a-z0-9]{10,40}$/.test(taskId) || !/^[a-zA-Z0-9_-]{8,80}$/.test(requestId))
@@ -34,9 +56,9 @@ export function memberAddition(taskId, requestId) {
34
56
  export async function requestMemberAddition(input) {
35
57
  const { request: r, taskId } = input;
36
58
  if (!r || typeof r.slot !== 'string' || !/^[A-Za-z][A-Za-z0-9_-]{0,31}$/.test(r.slot)
37
- || ![r.role, r.brain, r.agentTemplate].every(v => typeof v === 'string' && v.length > 0 && v.length <= 128))
59
+ || ![r.role, r.agentTemplate].every(v => text(v)) || !validMemberBrain(r.brain))
38
60
  // No receipt lookup has happened yet: this request ID may already be accepted.
39
- throw new TaskStateError('Slot, Role, Brain and Agent Template are required');
61
+ throw new TaskStateError('Slot, Role and Agent Template are required; Brain must be a saved Brain or a harness, model and effort');
40
62
  const path = pathFor(taskId, r.requestId);
41
63
  const hash = createHash('sha256').update(canonicalJson(r)).digest('hex');
42
64
  mkdirSync(join(stateRoot(), 'member-additions', taskId), { recursive: true, mode: 0o700 });
@@ -47,6 +69,7 @@ export async function requestMemberAddition(input) {
47
69
  throw new TaskStateError('Request ID was already used with different settings');
48
70
  return memberAddition(taskId, r.requestId);
49
71
  }
72
+ await input.checkNewRequest?.(r);
50
73
  const task = getTask(taskId);
51
74
  const room = task.room_id && getRoomRecord(task.room_id);
52
75
  if (task.layout?.name === EMPTY_TASK_LAYOUT && input.layouts) {
@@ -63,7 +86,7 @@ export async function requestMemberAddition(input) {
63
86
  const definition = (() => {
64
87
  try {
65
88
  const plan = prepareExecutionPlan({ name: 'additional-member', version: 1, description: 'Additional task participant',
66
- members: [{ slot: r.slot, role: r.slot, count: 1, agent_template: r.agentTemplate }] }, input.cfg, { [r.slot]: { role: r.role, brain: r.brain, ...(task.workspace ? { cwd: task.workspace.path } : {}) } });
89
+ members: [{ slot: r.slot, role: r.slot, count: 1, agent_template: r.agentTemplate }] }, input.cfg, { [r.slot]: { role: r.role, ...brainOverride(r.brain), ...(task.workspace ? { cwd: task.workspace.path } : {}) } });
67
90
  return Object.values(plan.launchDefinitions)[0];
68
91
  }
69
92
  catch (error) {
@@ -106,7 +129,7 @@ export async function requestMemberAddition(input) {
106
129
  const plan = (() => {
107
130
  try {
108
131
  return prepareExecutionPlan({ name: 'additional-member', version: 1, description: 'Additional task participant',
109
- contract: room.template_snapshot?.contract, members: [{ slot: r.slot, role: r.role, count: 1, agent_template: r.agentTemplate }] }, input.cfg, { [r.slot]: { role: r.role, brain: r.brain } });
132
+ contract: room.template_snapshot?.contract, members: [{ slot: r.slot, role: r.role, count: 1, agent_template: r.agentTemplate }] }, input.cfg, { [r.slot]: { role: r.role, ...brainOverride(r.brain) } });
110
133
  }
111
134
  catch (error) {
112
135
  throw new MemberAdditionRejected(error instanceof Error ? error.message : 'Invalid member configuration');
@@ -179,7 +179,7 @@ function writeTask(record, options = {}) {
179
179
  const next = status(record), before = previous ? status(previous) : undefined;
180
180
  pending.push({ eventId: `task:${record.task_id}:${randomUUID()}`, taskId: record.task_id,
181
181
  title: `Task ${!previous ? 'created' : 'updated'}: ${taskLabel(record)}`.slice(0, 160),
182
- body: `Task ${record.task_id}: ${before ? `${before} → ` : 'created in '}${next}.`,
182
+ body: taskSentence(record, `${before ? `${before} → ` : 'created in '}${next}.`),
183
183
  target: { kind: 'task', taskId: record.task_id }, delivery: 'push-only' });
184
184
  }
185
185
  pending.push(...contributionEvents(previous, record));
@@ -196,6 +196,11 @@ function writeTask(record, options = {}) {
196
196
  }
197
197
  /** Human label for notification titles: the mission key, when issued, precedes the title. */
198
198
  const taskLabel = (record) => record.key ? `${record.key} · ${record.title}` : record.title;
199
+ /**
200
+ * Notification body naming the task by its mission key. The opaque task ID stays in routing and
201
+ * event fields only; a task without a key relies on its title, which the notification title shows.
202
+ */
203
+ const taskSentence = (record, text) => record.key ? `Task ${record.key}: ${text}` : text[0].toUpperCase() + text.slice(1);
199
204
  /** Packages written before revisions existed count as revision 1; their next change becomes 2. No package is 0. */
200
205
  export const reviewRevision = (review) => review ? review.revision ?? 1 : 0;
201
206
  const contributionsChanged = (previous, record) => (record.progress ?? []).some(entry => !(previous.progress ?? []).some(old => old.id === entry.id))
@@ -214,7 +219,7 @@ function contributionEvents(previous, record) {
214
219
  const revision = reviewRevision(record.review);
215
220
  if (revision > reviewRevision(previous.review))
216
221
  events.push({ eventId: `task:${id}:review:${revision}`, taskId: id, title: `To review updated: ${taskLabel(record)}`.slice(0, 160),
217
- body: `Task ${id}: the review package changed.`, target: { kind: 'task', taskId: id }, delivery: 'push-only' });
222
+ body: taskSentence(record, 'the review package changed.'), target: { kind: 'task', taskId: id }, delivery: 'push-only' });
218
223
  return events;
219
224
  }
220
225
  /** Item-based Progress unread and revision-based To review unread. An absent read state means nothing is unread. */
@@ -50,6 +50,14 @@ export declare class FleetConfigService {
50
50
  private validationInputs;
51
51
  /** Cached during canonical reads; observer filtering must never parse YAML. */
52
52
  resourceValidationInputs(): readonly string[];
53
+ /**
54
+ * Record the manifest's external file references before a projection that loads
55
+ * the configuration, so a reference anywhere (including a task workspace) fences
56
+ * that capture even before the configuration itself has been read. A manifest that
57
+ * cannot be read keeps the last recorded inputs; the projection reports its error.
58
+ */
59
+ observeValidationInputs(): void;
60
+ private recordValidationInputs;
53
61
  constructor(options?: FleetConfigServiceOptions);
54
62
  read(includeDefinitions?: boolean): ConfigReadResult;
55
63
  preview(baseRevision: string, input: unknown): Promise<ConfigPreviewResult>;
@@ -21,6 +21,28 @@ export class FleetConfigService {
21
21
  validationInputs = [];
22
22
  /** Cached during canonical reads; observer filtering must never parse YAML. */
23
23
  resourceValidationInputs() { return this.validationInputs; }
24
+ /**
25
+ * Record the manifest's external file references before a projection that loads
26
+ * the configuration, so a reference anywhere (including a task workspace) fences
27
+ * that capture even before the configuration itself has been read. A manifest that
28
+ * cannot be read keeps the last recorded inputs; the projection reports its error.
29
+ */
30
+ observeValidationInputs() {
31
+ try {
32
+ this.recordValidationInputs(parse(trustedFile(this.path), { prettyErrors: false }));
33
+ }
34
+ catch { /* unchanged */ }
35
+ }
36
+ recordValidationInputs(parsed) {
37
+ const record = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
38
+ const manifest = record(parsed);
39
+ const vars = record(manifest.vars);
40
+ const expand = (value) => value.replace(/\$\{(\w+)\}/g, (match, key) => key in vars ? String(vars[key]) : match);
41
+ const invite = record(record(manifest.rooms).owner).public_invite_file;
42
+ const prompts = Object.values(record(manifest.watchdogs)).map(value => record(value).prompt_file);
43
+ this.validationInputs = [...(typeof invite === 'string' ? [expand(expand(invite))] : []),
44
+ ...prompts.filter((value) => typeof value === 'string').map(expand)].map(value => resolve(value));
45
+ }
24
46
  constructor(options = {}) {
25
47
  this.path = options.configPath ?? defaultConfigPath();
26
48
  this.preflight = options.preflight ?? (async () => emptyReport());
@@ -30,16 +52,9 @@ export class FleetConfigService {
30
52
  const sources = readSources(this.path);
31
53
  let raw;
32
54
  try {
33
- const manifest = parse(sources.documents.get(basename(sources.manifest)), { prettyErrors: false });
34
- const record = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
35
- const vars = record(manifest.vars);
36
- const expand = (value) => value.replace(/\$\{(\w+)\}/g, (match, key) => key in vars ? String(vars[key]) : match);
37
- const invite = record(record(manifest.rooms).owner).public_invite_file;
38
- const prompts = Object.values(record(manifest.watchdogs)).map(value => record(value).prompt_file);
39
55
  // Record even invalid/missing references before validation, so repairing an
40
56
  // already observed dependency can never retain a certified stale capture.
41
- this.validationInputs = [...(typeof invite === 'string' ? [expand(expand(invite))] : []),
42
- ...prompts.filter((value) => typeof value === 'string').map(expand)].map(value => resolve(value));
57
+ this.recordValidationInputs(parse(sources.documents.get(basename(sources.manifest)), { prettyErrors: false }));
43
58
  loadConfig(this.path, { yamlMode: 'strict' });
44
59
  raw = modelFrom(sources, includeDefinitions);
45
60
  }
@@ -135,6 +135,8 @@ export declare class FleetResourceViews {
135
135
  private coverage;
136
136
  /** Certify each view's last projected source; never a position its capture did not read. */
137
137
  private cover;
138
+ /** Whether `path` is, contains or lies within the manifest, its split definitions or another configuration input. */
139
+ private configurationDependency;
138
140
  private affects;
139
141
  private schedule;
140
142
  private refresh;
@@ -364,22 +364,25 @@ export class FleetResourceViews {
364
364
  view.qualifiedAt = now;
365
365
  } });
366
366
  }
367
+ /** Whether `path` is, contains or lies within the manifest, its split definitions or another configuration input. */
368
+ configurationDependency(path) {
369
+ const manifest = resolve(this.scope.configPath ?? defaultConfigPath());
370
+ const root = resolve(splitRootFor(manifest)), changed = resolve(path);
371
+ const within = (candidate, parent) => {
372
+ const local = relative(parent, candidate);
373
+ return local === '' || (local !== '..' && !local.startsWith('..' + sep));
374
+ };
375
+ // Configuration and provenance read the manifest and split definitions;
376
+ // loadConfig also rejects legacy YAML files in the global fleet.d root.
377
+ // Ancestor replacement/unknown directory wakes remain conservative.
378
+ const legacy = resolve(fleetDDir());
379
+ return within(changed, root) || within(root, changed) || within(manifest, changed)
380
+ || within(changed, legacy) || within(legacy, changed)
381
+ || this.configurationInputs().some(input => within(resolve(input), changed));
382
+ }
367
383
  affects(descriptor, path, structural = false) {
368
- if (descriptor.resource === 'configuration') {
369
- const manifest = resolve(this.scope.configPath ?? defaultConfigPath());
370
- const root = resolve(splitRootFor(manifest)), changed = resolve(path);
371
- const within = (candidate, parent) => {
372
- const local = relative(parent, candidate);
373
- return local === '' || (local !== '..' && !local.startsWith('..' + sep));
374
- };
375
- // Configuration and provenance read the manifest and split definitions;
376
- // loadConfig also rejects legacy YAML files in the global fleet.d root.
377
- // Ancestor replacement/unknown directory wakes remain conservative.
378
- const legacy = resolve(fleetDDir());
379
- return within(changed, root) || within(root, changed) || within(manifest, changed)
380
- || within(changed, legacy) || within(legacy, changed)
381
- || this.configurationInputs().some(input => within(resolve(input), changed));
382
- }
384
+ if (descriptor.resource === 'configuration')
385
+ return this.configurationDependency(path);
383
386
  // An explicitly selected manifest may use any pathname, including one
384
387
  // otherwise reserved for an independent task receipt.
385
388
  if (this.scope.configPath && resolve(path) === resolve(this.scope.configPath))
@@ -394,6 +397,14 @@ export class FleetResourceViews {
394
397
  if (roleProjection && !inRoleRoot && (/^(?:history\.jsonl|models_cache\.json|(?:thread_history|state|logs|queue|goals)_\d+\.sqlite(?:-wal|-shm|-journal)?)$/.test(relative(resolve(homedir(), '.codex'), path))
395
398
  || /^(?:workspace-devices\.db(?:-wal|-shm|-journal)?|audit[/\\]audit\.jsonl)$/.test(relative(resolve(stateRoot(), 'web'), path))))
396
399
  return false;
400
+ // Task and room workspaces hold their agents' own execution artifacts (checkouts,
401
+ // builds, test logs); role projections read nothing there except declared role
402
+ // roots and configuration (manifest, split definitions), which stay fenced. Working
403
+ // agents write there continuously, which otherwise fences every directory capture
404
+ // until it resyncs. The workspace directories themselves and their ancestors remain full wakes.
405
+ if (roleProjection && !inRoleRoot && /^(?:tasks|rooms)[/\\][^/\\]+[/\\]/.test(relative(resolve(stateRoot(), 'workspaces'), path))
406
+ && !this.configurationDependency(path))
407
+ return false;
397
408
  // Preview selection reads role config/state and conversation ledgers, never
398
409
  // task layout run/operation/brief receipts. A worker may commit several of
399
410
  // these while that independent canonical preview read is in flight.