@ours.network/fleet 1.1.0-nightly.1 → 1.1.0-nightly.3

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 (65) hide show
  1. package/README.md +76 -93
  2. package/dist/application/role-creation-service.d.ts +2 -21
  3. package/dist/application/role-creation-service.js +15 -64
  4. package/dist/application/role-removal-service.js +6 -3
  5. package/dist/application/task-room-service.js +7 -2
  6. package/dist/application/types.d.ts +1 -1
  7. package/dist/briefing.js +5 -5
  8. package/dist/build-info.json +4 -4
  9. package/dist/cli.js +48 -38
  10. package/dist/config.d.ts +65 -2
  11. package/dist/config.js +337 -25
  12. package/dist/creation.d.ts +1 -1
  13. package/dist/creation.js +2 -2
  14. package/dist/docs.d.ts +1 -1
  15. package/dist/docs.js +48 -97
  16. package/dist/doctor.js +4 -4
  17. package/dist/fleet-proxy.js +30 -8
  18. package/dist/generated-agent-source.d.ts +9 -0
  19. package/dist/generated-agent-source.js +53 -0
  20. package/dist/harness/agent-session.d.ts +13 -0
  21. package/dist/harness/claude-code-session.d.ts +4 -0
  22. package/dist/harness/claude-code-session.js +4 -0
  23. package/dist/harness/claude-code.js +10 -0
  24. package/dist/harness/codex-session.d.ts +4 -0
  25. package/dist/harness/codex-session.js +4 -0
  26. package/dist/harness/codex.js +14 -0
  27. package/dist/harness/registry.js +4 -0
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.js +1 -1
  30. package/dist/init-guidance.d.ts +1 -0
  31. package/dist/init-guidance.js +5 -0
  32. package/dist/model-env.d.ts +2 -3
  33. package/dist/model-env.js +3 -5
  34. package/dist/ops.js +4 -2
  35. package/dist/owner-channel/commands.js +2 -1
  36. package/dist/resolved-plan.d.ts +3 -2
  37. package/dist/resolved-plan.js +21 -5
  38. package/dist/rooms-tasks/cli.js +2 -1
  39. package/dist/rooms-tasks/config.js +24 -9
  40. package/dist/rooms-tasks/provision.js +71 -36
  41. package/dist/rooms-tasks/templates.js +6 -6
  42. package/dist/rooms-tasks/types.d.ts +8 -15
  43. package/dist/rooms-tasks/types.js +1 -4
  44. package/dist/sensitive-config.d.ts +2 -0
  45. package/dist/sensitive-config.js +4 -0
  46. package/dist/spawn.d.ts +8 -33
  47. package/dist/spawn.js +52 -211
  48. package/dist/watchdog/config.d.ts +10 -7
  49. package/dist/watchdog/config.js +29 -19
  50. package/dist/watchdog/run.js +4 -13
  51. package/dist/web/fleet-config-service.d.ts +11 -8
  52. package/dist/web/fleet-config-service.js +383 -183
  53. package/dist/web/topology-promote.js +25 -15
  54. package/dist/web/topology.js +2 -2
  55. package/dist/web/yaml-document-edit.js +2 -0
  56. package/dist/web-app/assets/index-BbTc5KtZ.js +10 -0
  57. package/dist/web-app/index.html +1 -1
  58. package/examples/fleet/agents/Alice.yaml +11 -0
  59. package/examples/fleet/agents/FleetCoordinator.yaml +7 -0
  60. package/examples/fleet/brains/claude-default.yaml +5 -0
  61. package/examples/fleet/roles/coordinator.yaml +5 -0
  62. package/examples/fleet/roles/developer.yaml +3 -0
  63. package/examples/fleet.yaml +74 -0
  64. package/package.json +2 -1
  65. package/dist/web-app/assets/index-DhMDVRU1.js +0 -10
package/dist/config.js CHANGED
@@ -1,5 +1,5 @@
1
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
2
+ import { basename, dirname, extname, join, resolve, sep } from 'node:path';
3
3
  import { agentDir, defaultConfigPath, fleetDDir, home } from './paths.js';
4
4
  import { parseFleetDocument, } from './config-yaml.js';
5
5
  import { harnessRuntimeDir, resolveIsolation, validateIsolationConfig, } from './isolation/policy.js';
@@ -10,6 +10,7 @@ import { resolveLoops } from './loops/config.js';
10
10
  import { validateRoomsConfig, validateTasksConfig, validateRoomTemplatesConfig, } from './rooms-tasks/config.js';
11
11
  import { CAPABILITIES, CAP_MONITOR_INTERRUPT_AFTER_TOOL } from './capabilities.js';
12
12
  import { runningLabel } from './provenance.js';
13
+ import { parseDuration } from './duration.js';
13
14
  /** Conservative built-in policy; `worklog: false` is the explicit opt-out. */
14
15
  export const DEFAULT_WORKLOG_POLICY = Object.freeze({
15
16
  max_kb: 1024,
@@ -126,12 +127,290 @@ export function isolationContextFor(role) {
126
127
  harnessSharedPaths: split?.shared,
127
128
  };
128
129
  }
129
- export const ROLE_NAME_RE = /^[A-Za-z0-9_-]+$/;
130
+ export const ROLE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
130
131
  const ROLE_KEYS = [
131
132
  'harness', 'session', 'session_options', 'permissions', 'identity', 'cwd', 'coordinator', 'mission', 'persona', 'bio',
132
- 'briefing_file', 'model', 'model_chain', 'max_tokens', 'autocompact_pct', 'env', 'oversee', 'harness_options',
133
+ 'briefing_file', 'model', 'effort', 'model_chain', 'max_tokens', 'autocompact_pct', 'env', 'oversee', 'harness_options',
133
134
  'isolation', 'monitor', 'owner_channel', 'worklog', 'auth_proxy',
134
135
  ];
136
+ export const ROLE_PRESET_KEYS = ['mission', 'persona', 'bio', 'briefing_file'];
137
+ export const BRAIN_PRESET_KEYS = [
138
+ 'harness', 'session', 'session_options', 'model', 'model_chain', 'max_tokens',
139
+ 'autocompact_pct', 'harness_options', 'effort',
140
+ ];
141
+ export const AGENT_KEYS = [
142
+ 'role', 'brain', 'permissions', 'identity', 'cwd', 'coordinator', 'env', 'oversee',
143
+ 'isolation', 'monitor', 'owner_channel', 'worklog', 'auth_proxy',
144
+ ];
145
+ const V2_API_VERSION = 'ours.network/fleet/v2';
146
+ const utf8Bytes = (value) => Buffer.byteLength(value, 'utf8');
147
+ function schemaError(file, pointer, summary) {
148
+ throw new ConfigError(`${file}: E_SCHEMA ${pointer}: ${summary}`);
149
+ }
150
+ export function validateRoleValue(value, file, pointer) {
151
+ const limits = {
152
+ mission: { bytes: 16 * 1024, nonblank: true },
153
+ persona: { bytes: 16 * 1024 },
154
+ bio: { bytes: 4 * 1024 },
155
+ };
156
+ for (const [key, rule] of Object.entries(limits)) {
157
+ const current = value[key];
158
+ if (current === undefined)
159
+ continue;
160
+ if (typeof current !== 'string')
161
+ schemaError(file, `${pointer}/${key}`, 'must be a string');
162
+ if (rule.nonblank && !current.trim())
163
+ schemaError(file, `${pointer}/${key}`, 'must be non-blank');
164
+ if (utf8Bytes(current) > rule.bytes)
165
+ schemaError(file, `${pointer}/${key}`, `must be at most ${rule.bytes} UTF-8 bytes`);
166
+ }
167
+ if (value.briefing_file !== undefined
168
+ && (typeof value.briefing_file !== 'string' || !value.briefing_file.trim()))
169
+ schemaError(file, `${pointer}/briefing_file`, 'must be a non-blank relative path');
170
+ }
171
+ export function validateBrainValue(value, file, pointer) {
172
+ if (typeof value.harness !== 'string' || !value.harness.trim())
173
+ schemaError(file, `${pointer}/harness`, 'is required and must be a registered non-blank harness ID');
174
+ if (value.model !== undefined && value.model !== null
175
+ && (typeof value.model !== 'string' || !value.model.trim()))
176
+ schemaError(file, `${pointer}/model`, 'must be a non-blank string or null');
177
+ for (const key of ['harness_options', 'session_options']) {
178
+ if (value[key] !== undefined && !isPlainObject(value[key]))
179
+ schemaError(file, `${pointer}/${key}`, 'must be a mapping');
180
+ }
181
+ }
182
+ function validateAgentScalars(value, file, id) {
183
+ for (const key of ['identity', 'cwd']) {
184
+ const current = value[key];
185
+ if (current !== undefined && (typeof current !== 'string' || !current.trim()))
186
+ schemaError(file, `/agents/${id}/${key}`, 'must be a non-blank string');
187
+ }
188
+ if (value.coordinator !== undefined
189
+ && (typeof value.coordinator !== 'string' || !ROLE_NAME_RE.test(value.coordinator)))
190
+ schemaError(file, `/agents/${id}/coordinator`, 'must be a valid Agent ID');
191
+ if (value.oversee !== undefined) {
192
+ if (!Array.isArray(value.oversee))
193
+ schemaError(file, `/agents/${id}/oversee`, 'must be an array');
194
+ const seen = new Set();
195
+ value.oversee.forEach((entry, index) => {
196
+ const pointer = `/agents/${id}/oversee/${index}`;
197
+ if (!isPlainObject(entry))
198
+ schemaError(file, pointer, 'must be a mapping');
199
+ const keys = Object.keys(entry);
200
+ const bad = keys.filter(key => key !== 'agent' && key !== 'interval');
201
+ if (bad.length || keys.length !== 2 || !keys.includes('agent') || !keys.includes('interval'))
202
+ schemaError(file, pointer, 'must contain exactly agent and interval');
203
+ if (typeof entry.agent !== 'string' || !ROLE_NAME_RE.test(entry.agent))
204
+ schemaError(file, `${pointer}/agent`, 'must be a valid Agent ID');
205
+ if (typeof entry.interval !== 'string')
206
+ schemaError(file, `${pointer}/interval`, 'must be a valid duration');
207
+ try {
208
+ parseDuration(entry.interval, { name: 'oversee interval' });
209
+ }
210
+ catch {
211
+ schemaError(file, `${pointer}/interval`, 'must be a valid duration such as 5m');
212
+ }
213
+ if (seen.has(entry.agent))
214
+ schemaError(file, `${pointer}/agent`, 'must be unique');
215
+ seen.add(entry.agent);
216
+ });
217
+ }
218
+ }
219
+ function assertTrustedPath(path, expected) {
220
+ const st = lstatSync(path);
221
+ if (st.isSymbolicLink())
222
+ throw new ConfigError(`E_SYMLINK: trusted configuration path is a symlink: ${path}`);
223
+ if (expected === 'file' ? !st.isFile() : !st.isDirectory())
224
+ throw new ConfigError(`E_FILE_TRUST: expected ${expected}: ${path}`);
225
+ const uid = process.getuid?.();
226
+ if (uid !== undefined && st.uid !== uid)
227
+ throw new ConfigError(`E_FILE_TRUST: ${path} is owned by uid ${st.uid}; required uid ${uid}`);
228
+ if ((st.mode & 0o022) !== 0)
229
+ throw new ConfigError(`E_FILE_TRUST: ${path} is group/world writable (mode ${(st.mode & 0o777).toString(8)})`);
230
+ }
231
+ export function assertBareKeys(value, allowed, label) {
232
+ if (!isPlainObject(value))
233
+ throw new ConfigError(`${label}: E_SCHEMA: must be a mapping`);
234
+ const bad = Object.keys(value).filter(key => !allowed.includes(key));
235
+ if (bad.length)
236
+ throw new ConfigError(`${label}: E_UNKNOWN_KEY: unknown key(s) ${bad.join(', ')}; allowed: ${allowed.join(', ')}`);
237
+ return value;
238
+ }
239
+ export function splitRootFor(base) {
240
+ const extension = extname(base).toLowerCase();
241
+ return extension === '.yaml' || extension === '.yml' ? base.slice(0, -extension.length) : `${base}.d`;
242
+ }
243
+ function readKindDirectory(root, kind, required, yamlMode, diagnostics, files) {
244
+ if (!existsSync(root)) {
245
+ if (required)
246
+ throw new ConfigError(`E_ROOT_MISSING: required ${kind} root not found: ${root}`);
247
+ return new Map();
248
+ }
249
+ assertTrustedPath(root, 'directory');
250
+ const result = new Map();
251
+ const names = readdirSync(root).filter(name => ['.yaml', '.yml'].includes(extname(name).toLowerCase())).sort();
252
+ for (const name of names) {
253
+ const file = join(root, name);
254
+ assertTrustedPath(file, 'file');
255
+ const id = basename(name, extname(name));
256
+ if (!ROLE_NAME_RE.test(id))
257
+ throw new ConfigError(`${file}: invalid ${kind} id '${id}' (allowed: [A-Za-z0-9_-])`);
258
+ const previous = result.get(id);
259
+ if (previous)
260
+ throw new ConfigError(`E_DUPLICATE_ID: ${kind} '${id}' defined by both ${previous.file} and ${file}`);
261
+ const parsed = parseFleetDocument(file, readFileSync(file, 'utf8'), yamlMode);
262
+ diagnostics.push(...parsed.diagnostics);
263
+ files.push(file);
264
+ result.set(id, { file, value: parsed.value });
265
+ }
266
+ return result;
267
+ }
268
+ function selection(raw, kind, agentFile, presets, allowed) {
269
+ if (!isPlainObject(raw))
270
+ schemaError(agentFile, `/${kind}`, 'must be { ref } or { inline }');
271
+ const keys = Object.keys(raw);
272
+ if (keys.length !== 1 || (keys[0] !== 'ref' && keys[0] !== 'inline'))
273
+ throw new ConfigError(`${agentFile}: E_UNION /${kind}: must contain exactly one of ref or inline`);
274
+ if ('ref' in raw) {
275
+ if (typeof raw.ref !== 'string' || !ROLE_NAME_RE.test(raw.ref))
276
+ schemaError(agentFile, `/${kind}/ref`, 'must be a valid case-sensitive ID');
277
+ const preset = presets.get(raw.ref);
278
+ if (!preset)
279
+ throw new ConfigError(`${agentFile}: E_REF_MISSING: ${kind} '${raw.ref}' not found`);
280
+ return {
281
+ value: assertBareKeys(preset.value, allowed, `${preset.file}: ${kind} '${raw.ref}'`),
282
+ file: preset.file,
283
+ pointer: `/${kind}s/${raw.ref}`,
284
+ };
285
+ }
286
+ return {
287
+ value: assertBareKeys(raw.inline, allowed, `${agentFile}: agent.${kind}.inline`),
288
+ file: agentFile,
289
+ pointer: `/${kind}/inline`,
290
+ };
291
+ }
292
+ function deepSubStrict(value, vars, file, pointer) {
293
+ if (typeof value === 'string') {
294
+ return value.replace(/\$\{(\w+)\}/g, (_match, key) => {
295
+ if (!(key in vars))
296
+ schemaError(file, pointer, `unknown variable \${${key}}`);
297
+ return String(vars[key]);
298
+ });
299
+ }
300
+ if (Array.isArray(value))
301
+ return value.map((item, index) => deepSubStrict(item, vars, file, `${pointer}/${index}`));
302
+ if (isPlainObject(value))
303
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [
304
+ key, deepSubStrict(child, vars, file, `${pointer}/${key}`),
305
+ ]));
306
+ return value;
307
+ }
308
+ function composeSplitRoles(base, baseDoc, yamlMode, diagnostics, files, additionalAgent) {
309
+ if (baseDoc.api_version !== V2_API_VERSION)
310
+ throw new ConfigError(`${base}: legacy fleet configuration is unsupported; run 'ours-fleet doctor' and rewrite it as ${V2_API_VERSION}`);
311
+ if ('roles' in baseDoc)
312
+ throw new ConfigError(`${base}: legacy top-level roles: is unsupported; define bare Agent files under ${splitRootFor(base)}/agents/`);
313
+ const root = splitRootFor(base);
314
+ if (!existsSync(root))
315
+ throw new ConfigError(`E_ROOT_MISSING: required configuration root not found: ${root}`);
316
+ assertTrustedPath(root, 'directory');
317
+ const brains = readKindDirectory(join(root, 'brains'), 'brain', false, yamlMode, diagnostics, files);
318
+ const roles = readKindDirectory(join(root, 'roles'), 'role', false, yamlMode, diagnostics, files);
319
+ const agents = readKindDirectory(join(root, 'agents'), 'agent', true, yamlMode, diagnostics, files);
320
+ if (additionalAgent) {
321
+ if (agents.has(additionalAgent.id))
322
+ throw new ConfigError(`agent '${additionalAgent.id}' already exists`);
323
+ agents.set(additionalAgent.id, {
324
+ file: '(spawn request)', value: structuredClone(additionalAgent.definition),
325
+ });
326
+ }
327
+ const vars = (baseDoc.vars ?? {});
328
+ const documents = [];
329
+ for (const [id, agent] of agents) {
330
+ const a = assertBareKeys(agent.value, AGENT_KEYS, `${agent.file}: agent '${id}'`);
331
+ if (!('role' in a) || !('brain' in a))
332
+ throw new ConfigError(`${agent.file}: agent '${id}' requires role and brain`);
333
+ const role = selection(a.role, 'role', agent.file, roles, ROLE_PRESET_KEYS);
334
+ const brain = selection(a.brain, 'brain', agent.file, brains, BRAIN_PRESET_KEYS);
335
+ const operational = deepSubStrict(Object.fromEntries(Object.entries(a).filter(([key]) => key !== 'role' && key !== 'brain')), vars, agent.file, `/agents/${id}`);
336
+ validateAgentScalars(operational, agent.file, id);
337
+ const brainValue = deepSubStrict({ ...brain.value }, vars, brain.file, brain.pointer);
338
+ validateBrainValue(brainValue, brain.file, brain.pointer);
339
+ const effort = brainValue.effort;
340
+ delete brainValue.effort;
341
+ const harness = brainValue.harness;
342
+ {
343
+ let adapter;
344
+ try {
345
+ adapter = getAdapter(harness);
346
+ }
347
+ catch {
348
+ schemaError(brain.file, `${brain.pointer}/harness`, `unregistered harness '${harness}'`);
349
+ }
350
+ let resolved;
351
+ try {
352
+ resolved = adapter.agentSession.resolveBrain({
353
+ model: brainValue.model,
354
+ effort: effort,
355
+ harnessOptions: brainValue.harness_options,
356
+ });
357
+ }
358
+ catch (error) {
359
+ schemaError(brain.file, `${brain.pointer}/effort`, error.message);
360
+ }
361
+ brainValue.model = resolved.model;
362
+ brainValue.harness_options = resolved.harnessOptions;
363
+ brainValue.effort = effort;
364
+ }
365
+ const roleValue = deepSubStrict({ ...role.value }, vars, role.file, role.pointer);
366
+ validateRoleValue(roleValue, role.file, role.pointer);
367
+ if (typeof roleValue.briefing_file === 'string') {
368
+ const containmentRoot = realpathSync(dirname(role.file));
369
+ const briefing = resolve(dirname(role.file), roleValue.briefing_file);
370
+ if (briefing !== containmentRoot && !briefing.startsWith(`${containmentRoot}${sep}`))
371
+ throw new ConfigError(`${role.file}: E_PATH_ESCAPE: briefing_file escapes its ${role.file === agent.file ? 'Agent' : 'Role'} root`);
372
+ assertTrustedPath(briefing, 'file');
373
+ const real = realpathSync(briefing);
374
+ if (real !== containmentRoot && !real.startsWith(`${containmentRoot}${sep}`))
375
+ throw new ConfigError(`${role.file}: E_PATH_ESCAPE: briefing_file realpath escapes its root`);
376
+ roleValue.briefing_file = real;
377
+ }
378
+ const provenance = {};
379
+ const record = (values, source, kind, via) => {
380
+ for (const [key, raw] of Object.entries(values))
381
+ provenance[key] = {
382
+ sourceFile: source.file, sourcePointer: `${source.pointer}/${key}`,
383
+ sourceKind: kind, sourceId: via?.id ?? id, origin: 'explicit',
384
+ ...(via ? { viaReference: via } : {}),
385
+ transforms: [
386
+ ...(JSON.stringify(raw).includes('${')
387
+ ? [{ kind: 'substitution', detail: 'manifest variable substitution' }] : []),
388
+ ...(['model', 'harness_options', 'effort'].includes(key)
389
+ ? [{ kind: 'adapter-normalization', detail: 'AgentSession Brain resolution' }] : []),
390
+ ],
391
+ };
392
+ };
393
+ record(a, { file: agent.file, pointer: `/agents/${id}` }, 'Agent');
394
+ record(role.value, { file: role.file, pointer: role.pointer }, 'Role', 'ref' in a.role
395
+ ? { sourcePointer: `/agents/${id}/role/ref`, kind: 'Role', id: String(a.role.ref) }
396
+ : undefined);
397
+ record(brain.value, { file: brain.file, pointer: brain.pointer }, 'Brain', 'ref' in a.brain
398
+ ? { sourcePointer: `/agents/${id}/brain/ref`, kind: 'Brain', id: String(a.brain.ref) }
399
+ : undefined);
400
+ delete provenance.role;
401
+ delete provenance.brain;
402
+ const canonicalDefinition = {
403
+ role: deepSubStrict(a.role, vars, agent.file, `/agents/${id}/role`),
404
+ brain: deepSubStrict(a.brain, vars, agent.file, `/agents/${id}/brain`),
405
+ ...operational,
406
+ };
407
+ documents.push({ file: agent.file,
408
+ doc: { roles: { [id]: { ...brainValue, ...roleValue, ...operational } } }, provenance,
409
+ agentSelections: structuredClone({ role: canonicalDefinition.role, brain: canonicalDefinition.brain }),
410
+ agentDefinition: canonicalDefinition });
411
+ }
412
+ return documents;
413
+ }
135
414
  function deepSub(v, vars) {
136
415
  if (typeof v === 'string')
137
416
  return v.replace(/\$\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m));
@@ -141,43 +420,43 @@ function deepSub(v, vars) {
141
420
  return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, deepSub(x, vars)]));
142
421
  return v;
143
422
  }
144
- /** Load ~/fleet.yaml (or an explicit path) merged with ~/fleet.d/*.yaml drop-ins. */
423
+ /** Load a v2 manifest and bare Agent/Role/Brain documents from its stem directory. */
145
424
  export function loadConfig(configPath, options = {}) {
146
425
  const base = configPath ?? defaultConfigPath();
147
426
  const files = [];
148
427
  const diagnostics = [];
149
- const docs = [];
428
+ let docs = [];
429
+ let manifestDoc = {};
150
430
  if (existsSync(base)) {
431
+ assertTrustedPath(base, 'file');
151
432
  const parsed = parseFleetDocument(base, readFileSync(base, 'utf8'), options.yamlMode);
152
- docs.push({ file: base, doc: parsed.value });
433
+ manifestDoc = parsed.value;
153
434
  diagnostics.push(...parsed.diagnostics);
154
435
  files.push(base);
155
436
  }
156
437
  else if (configPath) {
157
438
  throw new ConfigError(`config not found: ${base}`);
158
439
  }
159
- const dd = fleetDDir();
160
- if (existsSync(dd)) {
161
- for (const f of readdirSync(dd).filter(f => f.endsWith('.yaml') || f.endsWith('.yml')).sort()) {
162
- const p = join(dd, f);
163
- const parsed = parseFleetDocument(p, readFileSync(p, 'utf8'), options.yamlMode);
164
- const doc = parsed.value;
165
- diagnostics.push(...parsed.diagnostics);
166
- const FLEET_D_ALLOWED = ['roles', 'rooms', 'room_templates', 'tasks'];
167
- const extra = Object.keys(doc).filter(k => !FLEET_D_ALLOWED.includes(k));
168
- if (extra.length)
169
- throw new ConfigError(`${p}: fleet.d files may only define ${FLEET_D_ALLOWED.join(', ')}: (found: ${extra.join(', ')})`);
170
- docs.push({ file: p, doc });
171
- files.push(p);
172
- }
440
+ else {
441
+ throw new ConfigError(`config not found: ${base}`);
173
442
  }
174
- const baseDoc = docs.length && docs[0].file === base ? docs[0].doc : {};
443
+ const dd = fleetDDir();
444
+ if (existsSync(dd) && readdirSync(dd).some(f => f.endsWith('.yaml') || f.endsWith('.yml')))
445
+ throw new ConfigError(`${dd}: legacy fleet.d configuration is unsupported; run 'ours-fleet doctor' and move agents to ${splitRootFor(base)}/agents/`);
446
+ docs = [{ file: base, doc: manifestDoc }, ...composeSplitRoles(base, manifestDoc, options.yamlMode ?? 'compat', diagnostics, files, options.additionalAgent)];
447
+ const baseDoc = manifestDoc;
175
448
  const vars = (baseDoc.vars ?? {});
176
449
  const defaults = (baseDoc.defaults ?? {});
450
+ const legacyRuntimeDefaults = ['harness', 'model', 'model_chain', 'effort', 'harness_options',
451
+ 'session', 'session_options', 'max_tokens', 'autocompact_pct']
452
+ .filter(key => Object.hasOwn(defaults, key));
453
+ if (legacyRuntimeDefaults.length)
454
+ throw new ConfigError(`${base}: E_LEGACY /defaults: Brain-owned field(s) ${legacyRuntimeDefaults.join(', ')} are unsupported; move them to a Brain definition`);
177
455
  const startStaggerMs = resolveStartStaggerMs(baseDoc.start_stagger_ms, base);
178
456
  const seen = new Map();
179
457
  const roles = [];
180
- for (const { file, doc } of docs) {
458
+ const agentDefinitions = {};
459
+ for (const { file, doc, provenance, agentSelections, agentDefinition } of docs) {
181
460
  for (const [name, raw] of Object.entries((doc.roles ?? {}))) {
182
461
  if (!ROLE_NAME_RE.test(name))
183
462
  throw new ConfigError(`${file}: invalid role name '${name}' (allowed: [A-Za-z0-9_-])`);
@@ -185,6 +464,8 @@ export function loadConfig(configPath, options = {}) {
185
464
  if (prev)
186
465
  throw new ConfigError(`role '${name}' defined in both ${prev} and ${file}`);
187
466
  seen.set(name, file);
467
+ if (agentDefinition)
468
+ agentDefinitions[name] = structuredClone(agentDefinition);
188
469
  const r = deepSub(raw ?? {}, vars);
189
470
  const bad = Object.keys(r).filter(k => !ROLE_KEYS.includes(k));
190
471
  if (bad.length)
@@ -234,6 +515,21 @@ export function loadConfig(configPath, options = {}) {
234
515
  const modelChain = resolveModelChain(model, r.model_chain ?? (inheritsModelDefaults
235
516
  ? defaults.model_chain
236
517
  : undefined), file, name);
518
+ const fieldProvenance = { ...(provenance ?? {}) };
519
+ const defaulted = (key, explicit, builtIn = false) => {
520
+ if (fieldProvenance[key])
521
+ return;
522
+ fieldProvenance[key] = {
523
+ sourceFile: builtIn ? '(built-in)' : base,
524
+ sourcePointer: builtIn ? `/${key}` : `/defaults/${key}`,
525
+ sourceKind: builtIn ? 'built-in' : 'Manifest',
526
+ origin: builtIn ? 'built-in' : explicit ? 'explicit' : 'typed-default',
527
+ transforms: [{ kind: 'validation-default', detail: builtIn ? 'built-in default' : 'manifest operational default' }],
528
+ };
529
+ };
530
+ defaulted('identity', r.identity !== undefined, r.identity === undefined);
531
+ defaulted('permissions', defaults.permissions !== undefined, defaults.permissions === undefined);
532
+ defaulted('monitor', defaults.monitor !== undefined, defaults.monitor === undefined);
237
533
  roles.push({
238
534
  ...r,
239
535
  name,
@@ -252,9 +548,11 @@ export function loadConfig(configPath, options = {}) {
252
548
  monitor,
253
549
  owner_channel: ownerChannel,
254
550
  worklog,
551
+ provenance: fieldProvenance,
255
552
  auth_proxy: authProxy,
256
553
  env: Object.keys(env).length ? env : undefined,
257
554
  loops: [],
555
+ ...(agentSelections ? { agentSelections } : {}),
258
556
  });
259
557
  // Forbidden-path enforcement: a mount that would breach the policy
260
558
  // is a configuration error, caught by `config` rather than at launch.
@@ -270,7 +568,12 @@ export function loadConfig(configPath, options = {}) {
270
568
  }
271
569
  }
272
570
  validateOwnerChannelIdentities(roles);
273
- const watchdogs = resolveWatchdogs(baseDoc, base, roles, vars, defaults);
571
+ const watchdogs = options.skipWatchdogs ? [] : resolveWatchdogs(baseDoc, base, roles, vars, agentDefinitions, (name, definition) => {
572
+ const id = `WatchdogAgent${name}`.slice(0, 64);
573
+ return findRole(loadConfig(base, {
574
+ yamlMode: options.yamlMode, additionalAgent: { id, definition }, skipWatchdogs: true,
575
+ }), id);
576
+ });
274
577
  const resolvedLoops = resolveLoops(baseDoc.loops, base, roles, vars);
275
578
  for (const role of roles)
276
579
  role.loops = resolvedLoops.byRole.get(role.name) ?? [];
@@ -304,9 +607,18 @@ export function loadConfig(configPath, options = {}) {
304
607
  }
305
608
  }
306
609
  const result = {
307
- roles, vars, defaults, files, startStaggerMs, diagnostics, watchdogs,
610
+ roles, agentDefinitions, vars, defaults, files, startStaggerMs, diagnostics, watchdogs,
308
611
  loops: resolvedLoops.loops,
309
612
  rooms, roomTemplates, tasks, ownerInviteFingerprint,
613
+ configMode: 'split-v2',
614
+ sourceDocuments: files.map(path => {
615
+ if (path === base)
616
+ return { kind: 'Manifest', path };
617
+ const parent = basename(dirname(path));
618
+ const kind = parent === 'agents' ? 'Agent'
619
+ : parent === 'roles' ? 'Role' : 'Brain';
620
+ return { kind, id: basename(path, extname(path)), path };
621
+ }),
310
622
  };
311
623
  if (ownerInvite !== undefined) {
312
624
  Object.defineProperty(result, 'ownerInvite', {
@@ -153,7 +153,7 @@ export interface DaemonIdentityProvisionerOptions {
153
153
  export declare function daemonIdentityProvisioner(env?: NodeJS.ProcessEnv, attachClient?: (options: AttachOursClientOptions) => Promise<IdentityInventoryClient>, options?: DaemonIdentityProvisionerOptions): IdentityProvisioner;
154
154
  /** Inventory-only variant for session-owned temporary identity lifecycles. */
155
155
  export declare function daemonIdentityInventoryProvisioner(env?: NodeJS.ProcessEnv, attachClient?: (options: AttachOursClientOptions) => Promise<IdentityInventoryClient>): IdentityProvisioner;
156
- /** Atomically write a role's fleet.d file, journalling it for rollback. */
156
+ /** Atomically write a bare Agent file, journalling it for rollback. */
157
157
  export declare function writeRoleFile(tx: CreationTransaction, file: string, contents: string): void;
158
158
  /** Where a setting's effective value came from. */
159
159
  export type ProvenanceSource = 'cli' | 'fleet-default' | 'caller-role' | 'built-in';
package/dist/creation.js CHANGED
@@ -300,12 +300,12 @@ export function daemonIdentityInventoryProvisioner(env = process.env, attachClie
300
300
  acceptTemporaryExisting: true,
301
301
  });
302
302
  }
303
- /** Atomically write a role's fleet.d file, journalling it for rollback. */
303
+ /** Atomically write a bare Agent file, journalling it for rollback. */
304
304
  export function writeRoleFile(tx, file, contents) {
305
305
  const existed = existsSync(file);
306
306
  replaceFileAtomically(file, contents, 0o644);
307
307
  tx.record({
308
- stage: `fleet.d file ${file}`,
308
+ stage: `Agent file ${file}`,
309
309
  // Only remove what THIS transaction created; never delete a file the
310
310
  // operator already had.
311
311
  undo: () => { if (!existed)