@ours.network/fleet 0.19.0-nightly.2 → 0.19.0-nightly.4

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.
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "0.19.0-nightly.2",
3
- "buildId": "13880d55bd06",
4
- "commit": "ccf83082de238e6dc8ddb1bc557d2b7ffc7e816f",
2
+ "version": "0.19.0-nightly.4",
3
+ "buildId": "27ab2ac6d2c0",
4
+ "commit": "0213a2667a2d24e14297f7364f42880997451742",
5
5
  "dirty": true,
6
- "builtAt": "2026-08-23T08:33:08.792Z",
6
+ "builtAt": "2026-08-23T12:52:41.918Z",
7
7
  "capabilities": [
8
8
  "monitor.interrupt.after_tool"
9
9
  ]
package/dist/config.d.ts CHANGED
@@ -170,6 +170,12 @@ export interface FleetConfig {
170
170
  tasks?: import('./rooms-tasks/types.js').TasksConfig;
171
171
  /** SHA-256 fingerprint of the resolved owner invite (never the invite itself). */
172
172
  ownerInviteFingerprint?: string;
173
+ /**
174
+ * Resolved owner invite for immediate private-IPC use only. This property is
175
+ * deliberately non-enumerable so config dumps and JSON output cannot expose
176
+ * the bearer credential.
177
+ */
178
+ ownerInvite?: string;
173
179
  }
174
180
  export declare class ConfigError extends Error {
175
181
  }
package/dist/config.js CHANGED
@@ -276,6 +276,7 @@ export function loadConfig(configPath, options = {}) {
276
276
  // top-level (room_templates merge by name, last writer wins).
277
277
  let rooms;
278
278
  let ownerInviteFingerprint;
279
+ let ownerInvite;
279
280
  let roomTemplates;
280
281
  let tasks;
281
282
  for (const { file, doc } of docs) {
@@ -284,6 +285,7 @@ export function loadConfig(configPath, options = {}) {
284
285
  throw new ConfigError(`rooms: defined in multiple files; last: ${file}`);
285
286
  const validated = validateRoomsConfig(deepSub(doc.rooms, vars), vars, file);
286
287
  ownerInviteFingerprint = validated._invite?.fingerprint;
288
+ ownerInvite = validated._invite?.value;
287
289
  const { _invite: _, ...clean } = validated;
288
290
  rooms = clean;
289
291
  }
@@ -297,11 +299,20 @@ export function loadConfig(configPath, options = {}) {
297
299
  tasks = validateTasksConfig(deepSub(doc.tasks, vars), file);
298
300
  }
299
301
  }
300
- return {
302
+ const result = {
301
303
  roles, vars, defaults, files, startStaggerMs, diagnostics, watchdogs,
302
304
  loops: resolvedLoops.loops,
303
305
  rooms, roomTemplates, tasks, ownerInviteFingerprint,
304
306
  };
307
+ if (ownerInvite !== undefined) {
308
+ Object.defineProperty(result, 'ownerInvite', {
309
+ value: ownerInvite,
310
+ enumerable: false,
311
+ writable: false,
312
+ configurable: false,
313
+ });
314
+ }
315
+ return result;
305
316
  }
306
317
  /**
307
318
  * Canonical form of a 64-hex container ID for authorization decisions. Hex
package/dist/doctor.js CHANGED
@@ -106,6 +106,126 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
106
106
  ok: true,
107
107
  detail: `warning: ${diagnostic.message}`,
108
108
  });
109
+ // ── Rooms/tasks checks (§5.3) ────────────────────────────────────────
110
+ if (loaded.ok && loaded.rooms) {
111
+ const rooms = loaded.rooms;
112
+ // Cowork socket reachability
113
+ const coworkConfig = rooms.cowork?.config;
114
+ try {
115
+ const { createCoworkAdapter } = await import('./rooms-tasks/cowork-adapter.js');
116
+ const adapter = createCoworkAdapter({ configPath: coworkConfig });
117
+ const reachable = await adapter.available();
118
+ checks.push({
119
+ name: 'cowork', ok: reachable,
120
+ detail: reachable
121
+ ? 'management socket reachable'
122
+ : `management socket unreachable${coworkConfig ? ` (config: ${coworkConfig})` : ''}`,
123
+ });
124
+ }
125
+ catch (e) {
126
+ checks.push({
127
+ name: 'cowork', ok: false,
128
+ detail: `management socket error${coworkConfig ? ` (config: ${coworkConfig})` : ''}: ${e?.message ?? e}`,
129
+ });
130
+ }
131
+ // Owner CID shape
132
+ const cidOk = /^[0-9a-fA-F]{64}$/.test(rooms.owner.expected_cid);
133
+ checks.push({
134
+ name: 'rooms: owner CID', ok: cidOk,
135
+ detail: cidOk ? 'valid 64-hex CID' : `invalid CID shape: ${rooms.owner.expected_cid.slice(0, 16)}...`,
136
+ });
137
+ // Invite presence (never content)
138
+ checks.push({
139
+ name: 'rooms: owner invite',
140
+ ok: !!loaded.ownerInviteFingerprint,
141
+ detail: loaded.ownerInviteFingerprint
142
+ ? `present (fingerprint: ${loaded.ownerInviteFingerprint.slice(0, 12)}...)`
143
+ : 'not configured — room owner attachment will use waiting_owner_invite',
144
+ });
145
+ // Template validity and referenced role validity
146
+ const { listTemplates, resolveTemplate } = await import('./rooms-tasks/templates.js');
147
+ const customs = loaded.roomTemplates ?? {};
148
+ const templates = listTemplates(customs);
149
+ const roleNames = new Set(roles.map(r => r.name));
150
+ for (const t of templates) {
151
+ const badRefs = t.members
152
+ .filter(m => m.role_ref && !roleNames.has(m.role_ref))
153
+ .map(m => m.role_ref);
154
+ if (badRefs.length) {
155
+ checks.push({
156
+ name: `template: ${t.name}@${t.version}`,
157
+ ok: true,
158
+ detail: `warning: role_ref(s) ${badRefs.join(', ')} not found in configured roles`,
159
+ });
160
+ }
161
+ }
162
+ // Default template validity
163
+ const defaultTemplate = loaded.rooms.defaults?.template
164
+ ?? loaded.tasks?.default_room_template;
165
+ if (defaultTemplate) {
166
+ const resolved = resolveTemplate(defaultTemplate, customs);
167
+ checks.push({
168
+ name: 'rooms: default template',
169
+ ok: !!resolved,
170
+ detail: resolved
171
+ ? `${resolved.name}@${resolved.version}`
172
+ : `template '${defaultTemplate}' not found`,
173
+ });
174
+ }
175
+ // Shared-daemon selection coherence
176
+ if (rooms.cowork?.config) {
177
+ const { existsSync: exists } = await import('node:fs');
178
+ const configOk = exists(rooms.cowork.config);
179
+ checks.push({
180
+ name: 'rooms: cowork config', ok: configOk,
181
+ detail: configOk
182
+ ? `cowork config at ${rooms.cowork.config}`
183
+ : `cowork config not found: ${rooms.cowork.config}`,
184
+ });
185
+ }
186
+ // Hard prerelease capability check
187
+ try {
188
+ const { createCoworkAdapter } = await import('./rooms-tasks/cowork-adapter.js');
189
+ const adapter = createCoworkAdapter({ configPath: coworkConfig });
190
+ const roomList = await adapter.listRooms();
191
+ checks.push({
192
+ name: 'rooms: capability', ok: true,
193
+ detail: `cowork room management operational (${roomList.length} room(s))`,
194
+ });
195
+ }
196
+ catch (e) {
197
+ const msg = e?.message ?? String(e);
198
+ const isProto = msg.includes('protocol') || msg.includes('invalid');
199
+ checks.push({
200
+ name: 'rooms: capability', ok: false,
201
+ detail: isProto
202
+ ? `cowork does not support room management protocol — upgrade ours-cowork to prerelease`
203
+ : `room management check failed: ${msg}`,
204
+ });
205
+ }
206
+ // Stale task/room warnings
207
+ try {
208
+ const { listTasks } = await import('./rooms-tasks/task-state.js');
209
+ const { listRoomRecords } = await import('./rooms-tasks/room-state.js');
210
+ const tasks = listTasks();
211
+ const roomRecords = listRoomRecords();
212
+ const roomIds = new Set(roomRecords.map(r => r.room_id));
213
+ const taskIds = new Set(tasks.map(t => t.task_id));
214
+ const orphanedTasks = tasks.filter(t => t.room_id && !roomIds.has(t.room_id));
215
+ const orphanedRooms = roomRecords.filter(r => r.task_id && !taskIds.has(r.task_id));
216
+ if (orphanedTasks.length)
217
+ checks.push({
218
+ name: 'rooms: stale tasks', ok: true,
219
+ detail: `warning: ${orphanedTasks.length} task(s) reference missing rooms: ${orphanedTasks.map(t => t.task_id).join(', ')}`,
220
+ });
221
+ if (orphanedRooms.length)
222
+ checks.push({
223
+ name: 'rooms: stale rooms', ok: true,
224
+ detail: `warning: ${orphanedRooms.length} room(s) reference missing tasks: ${orphanedRooms.map(r => r.room_id).join(', ')}`,
225
+ });
226
+ }
227
+ catch { /* state dir may not exist yet */ }
228
+ }
109
229
  if (roles.length === 0 || roles.some(role => (role.session ?? 'tmux') === 'tmux')) {
110
230
  const tmux = await exec('tmux', ['-V']);
111
231
  checks.push({
@@ -405,7 +525,11 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
405
525
  function loadConfigResult(configPath, yamlMode) {
406
526
  try {
407
527
  const cfg = loadConfig(configPath, { yamlMode });
408
- return { ok: true, roles: cfg.roles, files: cfg.files, diagnostics: cfg.diagnostics };
528
+ return {
529
+ ok: true, roles: cfg.roles, files: cfg.files, diagnostics: cfg.diagnostics,
530
+ rooms: cfg.rooms, roomTemplates: cfg.roomTemplates, tasks: cfg.tasks,
531
+ ownerInviteFingerprint: cfg.ownerInviteFingerprint,
532
+ };
409
533
  }
410
534
  catch (e) {
411
535
  return { ok: false, error: e instanceof Error ? e.message : String(e) };
@@ -159,14 +159,20 @@ export const ownerCommands = [
159
159
  },
160
160
  },
161
161
  {
162
- name: 'task', usage: '/task <id>',
163
- summary: 'show task details',
162
+ name: 'task',
163
+ usage: '/task <create|list|show|start|block|unblock|review|done|cancel|recover> ...',
164
+ summary: 'task lifecycle subcommands',
164
165
  execute: async (ctx, args) => {
165
166
  if (!args)
166
- throw new OwnerCommandUsageError('usage: /task <id>');
167
- const { getTask } = await import('../rooms-tasks/task-state.js');
168
- try {
169
- const t = getTask(args.trim());
167
+ throw new OwnerCommandUsageError('usage: /task <subcommand> <id>');
168
+ const argLines = args.trim().split('\n');
169
+ const firstLineTokens = argLines[0].trim().split(/\s+/);
170
+ const sub = firstLineTokens[0];
171
+ const rest = firstLineTokens.slice(1);
172
+ const trailingLines = argLines.slice(1).join('\n').trim() || undefined;
173
+ const showTask = async (id) => {
174
+ const { getTask } = await import('../rooms-tasks/task-state.js');
175
+ const t = getTask(id);
170
176
  const lines = [
171
177
  `📋 Task: ${t.task_id}`,
172
178
  `Title: ${t.title}`,
@@ -177,8 +183,157 @@ export const ownerCommands = [
177
183
  `Created: ${t.created_at}`,
178
184
  ];
179
185
  await ctx.reply(lines.join('\n'));
186
+ };
187
+ try {
188
+ switch (sub) {
189
+ case 'create': {
190
+ if (!rest[0])
191
+ throw new OwnerCommandUsageError('usage: /task create [--backlog] [--template=<name>|--no-room] <title>');
192
+ const backlog = rest.includes('--backlog');
193
+ const tplFlag = rest.find(r => r.startsWith('--template='));
194
+ const template = tplFlag ? tplFlag.slice('--template='.length) : undefined;
195
+ const noRoom = rest.includes('--no-room');
196
+ const titleParts = rest.filter(r => r !== '--backlog' && !r.startsWith('--template=') && r !== '--no-room');
197
+ if (!titleParts.length)
198
+ throw new OwnerCommandUsageError('usage: /task create [--backlog] [--template=<name>|--no-room] <title>');
199
+ const { createTask } = await import('../rooms-tasks/task-state.js');
200
+ const t = createTask({
201
+ title: titleParts.join(' '),
202
+ origin: { type: 'owner_channel' },
203
+ start: !backlog,
204
+ ...(template ? { template: { name: template, version: 1, content_hash: '' } } : {}),
205
+ ...(noRoom ? { no_room: true } : {}),
206
+ ...(trailingLines ? { brief: trailingLines } : {}),
207
+ });
208
+ const lines = [`Task ${t.task_id} created · ${t.state}`];
209
+ if (template)
210
+ lines.push(`Template: ${template}`);
211
+ if (noRoom)
212
+ lines.push('No room requested');
213
+ await ctx.reply(lines.join('\n'));
214
+ break;
215
+ }
216
+ case 'list': {
217
+ const { listTasks } = await import('../rooms-tasks/task-state.js');
218
+ const filter = rest[0];
219
+ const stateMap = {
220
+ backlog: 'backlog', active: 'active', blocked: 'active',
221
+ done: 'done', all: ['backlog', 'provisioning', 'active', 'review', 'done', 'cancelled', 'failed'],
222
+ };
223
+ const stateFilter = filter && stateMap[filter]
224
+ ? { state: stateMap[filter] }
225
+ : undefined;
226
+ const tasks = listTasks(stateFilter);
227
+ if (filter === 'blocked') {
228
+ const blocked = tasks.filter(t => t.blocked);
229
+ if (!blocked.length) {
230
+ await ctx.reply('📋 No blocked tasks.');
231
+ break;
232
+ }
233
+ const lines = blocked.map(t => `${t.task_id} ${t.state} ${t.title} [BLOCKED: ${t.blocked.reason}]`);
234
+ await ctx.reply(tail(`📋 Blocked tasks:\n${lines.join('\n')}`, REPLY_MAX_CHARS));
235
+ break;
236
+ }
237
+ if (!tasks.length) {
238
+ await ctx.reply('📋 No tasks.');
239
+ break;
240
+ }
241
+ const lines = tasks.map(t => `${t.task_id} ${t.state} ${t.title}${t.blocked ? ` [BLOCKED]` : ''}`);
242
+ await ctx.reply(tail(`📋 Tasks:\n${lines.join('\n')}`, REPLY_MAX_CHARS));
243
+ break;
244
+ }
245
+ case 'show': {
246
+ if (!rest[0])
247
+ throw new OwnerCommandUsageError('usage: /task show <id>');
248
+ await showTask(rest[0]);
249
+ break;
250
+ }
251
+ case 'start': {
252
+ if (!rest[0])
253
+ throw new OwnerCommandUsageError('usage: /task start <id>');
254
+ const { startTask } = await import('../rooms-tasks/task-state.js');
255
+ const t = startTask(rest[0]);
256
+ await ctx.reply(`✅ Task ${t.task_id} → provisioning`);
257
+ break;
258
+ }
259
+ case 'block': {
260
+ if (!rest[0] || !rest[1])
261
+ throw new OwnerCommandUsageError('usage: /task block <id> <reason>');
262
+ const { blockTask } = await import('../rooms-tasks/task-state.js');
263
+ const reason = rest.slice(1).join(' ');
264
+ const t = blockTask(rest[0], reason);
265
+ await ctx.reply(`🚧 Task ${t.task_id} blocked: ${reason}`);
266
+ break;
267
+ }
268
+ case 'unblock': {
269
+ if (!rest[0])
270
+ throw new OwnerCommandUsageError('usage: /task unblock <id>');
271
+ const { unblockTask } = await import('../rooms-tasks/task-state.js');
272
+ const t = unblockTask(rest[0]);
273
+ await ctx.reply(`✅ Task ${t.task_id} unblocked`);
274
+ break;
275
+ }
276
+ case 'review': {
277
+ if (!rest[0])
278
+ throw new OwnerCommandUsageError('usage: /task review <id>');
279
+ const { reviewTask } = await import('../rooms-tasks/task-state.js');
280
+ const t = reviewTask(rest[0]);
281
+ await ctx.reply(`📝 Task ${t.task_id} → review`);
282
+ break;
283
+ }
284
+ case 'done': {
285
+ if (!rest[0])
286
+ throw new OwnerCommandUsageError('usage: /task done <id> [summary]');
287
+ const { completeTask } = await import('../rooms-tasks/task-state.js');
288
+ const summary = rest.slice(1).join(' ') || undefined;
289
+ const t = completeTask(rest[0], summary ? { summary } : undefined);
290
+ await ctx.reply(`✅ Task ${t.task_id} → done`);
291
+ break;
292
+ }
293
+ case 'cancel': {
294
+ if (rest.length < 2 || rest[0] !== rest[1])
295
+ throw new OwnerCommandUsageError('destructive: /task cancel <id> <id> — provide the task ID twice');
296
+ const { cancelTask } = await import('../rooms-tasks/task-state.js');
297
+ const t = cancelTask(rest[0]);
298
+ await ctx.reply(`🗑️ Task ${t.task_id} → cancelled`);
299
+ break;
300
+ }
301
+ case 'recover': {
302
+ if (!rest[0])
303
+ throw new OwnerCommandUsageError('usage: /task recover <id>');
304
+ const { getTask } = await import('../rooms-tasks/task-state.js');
305
+ const { getRoomRecord } = await import('../rooms-tasks/room-state.js');
306
+ const t = getTask(rest[0]);
307
+ const room = t.room_id ? getRoomRecord(t.room_id) : undefined;
308
+ const hints = [];
309
+ if (t.state === 'provisioning' && room) {
310
+ if (room.provisioning_detail === 'waiting_cowork')
311
+ hints.push('Cowork socket unreachable');
312
+ if (room.provisioning_detail === 'waiting_owner_invite')
313
+ hints.push('Owner invite missing or invalid');
314
+ if (room.provisioning_detail === 'owner_cid_mismatch')
315
+ hints.push('Owner CID mismatch');
316
+ if (room.provisioning_detail === 'member_failed')
317
+ hints.push(`Member failed at step ${room.saga.step_index}`);
318
+ }
319
+ const lines = [`Task ${t.task_id} · ${t.state}`];
320
+ if (room)
321
+ lines.push(`Room ${room.room_id} · ${room.state} · saga: ${room.saga.phase}`);
322
+ if (hints.length)
323
+ lines.push(`Hints: ${hints.join('; ')}`);
324
+ else
325
+ lines.push('No automated recovery actions available.');
326
+ await ctx.reply(lines.join('\n'));
327
+ break;
328
+ }
329
+ default:
330
+ // bare /task <id> → show
331
+ await showTask(sub);
332
+ }
180
333
  }
181
334
  catch (e) {
335
+ if (e instanceof OwnerCommandUsageError)
336
+ throw e;
182
337
  await ctx.reply(`⚠️ ${e instanceof Error ? e.message : String(e)}`);
183
338
  }
184
339
  },
@@ -195,30 +350,125 @@ export const ownerCommands = [
195
350
  }),
196
351
  },
197
352
  {
198
- name: 'room', usage: '/room <id>',
199
- summary: 'show room details',
353
+ name: 'room',
354
+ usage: '/room <create|list|show|close|recover> ...',
355
+ summary: 'room lifecycle subcommands',
200
356
  execute: async (ctx, args) => {
201
357
  if (!args)
202
- throw new OwnerCommandUsageError('usage: /room <id>');
203
- const { getRoomRecord } = await import('../rooms-tasks/room-state.js');
204
- const r = getRoomRecord(args.trim());
205
- if (!r)
206
- return ctx.reply(`⚠️ room not found: ${args.trim()}`);
207
- const lines = [
208
- `🏠 Room: ${r.room_id}`,
209
- `Name: ${r.room_name}`,
210
- `State: ${r.state}`,
211
- `Saga: ${r.saga.phase} (step ${r.saga.step_index})`,
212
- ...(r.task_id ? [`Task: ${r.task_id}`] : []),
213
- ...(r.provisioning_detail ? [`Detail: ${r.provisioning_detail}`] : []),
214
- ...(r.saga.error ? [`Error: ${r.saga.error}`] : []),
215
- `Created: ${r.created_at}`,
216
- ];
217
- await ctx.reply(lines.join('\n'));
358
+ throw new OwnerCommandUsageError('usage: /room <subcommand> <id>');
359
+ const argLines = args.trim().split('\n');
360
+ const firstLineTokens = argLines[0].trim().split(/\s+/);
361
+ const sub = firstLineTokens[0];
362
+ const rest = firstLineTokens.slice(1);
363
+ const roomTrailingLines = argLines.slice(1).join('\n').trim() || undefined;
364
+ const showRoom = async (id) => {
365
+ const { getRoomRecord } = await import('../rooms-tasks/room-state.js');
366
+ const r = getRoomRecord(id);
367
+ if (!r)
368
+ return ctx.reply(`⚠️ room not found: ${id}`);
369
+ const lines = [
370
+ `🏠 Room: ${r.room_id}`,
371
+ `Name: ${r.room_name}`,
372
+ `State: ${r.state}`,
373
+ `Saga: ${r.saga.phase} (step ${r.saga.step_index})`,
374
+ ...(r.task_id ? [`Task: ${r.task_id}`] : []),
375
+ ...(r.provisioning_detail ? [`Detail: ${r.provisioning_detail}`] : []),
376
+ ...(r.saga.error ? [`Error: ${r.saga.error}`] : []),
377
+ `Created: ${r.created_at}`,
378
+ ];
379
+ await ctx.reply(lines.join('\n'));
380
+ };
381
+ try {
382
+ switch (sub) {
383
+ case 'create': {
384
+ const tplFlag = rest.find(r => r.startsWith('--template='));
385
+ const templateName = tplFlag?.slice('--template='.length);
386
+ const nameParts = rest.filter(r => !r.startsWith('--'));
387
+ if (!nameParts.length)
388
+ throw new OwnerCommandUsageError('usage: /room create --template=<name> <room name>');
389
+ const { randomUUID } = await import('node:crypto');
390
+ const { createRoomRecord } = await import('../rooms-tasks/room-state.js');
391
+ const { resolveTemplate, snapshotTemplate } = await import('../rooms-tasks/templates.js');
392
+ let templateSnapshot;
393
+ if (templateName) {
394
+ const tpl = resolveTemplate(templateName, {});
395
+ if (!tpl)
396
+ throw new OwnerCommandUsageError(`unknown template: ${templateName}`);
397
+ templateSnapshot = snapshotTemplate(tpl);
398
+ }
399
+ const r = createRoomRecord({
400
+ room_id: randomUUID().replace(/-/g, '').slice(0, 16),
401
+ room_name: nameParts.join(' '),
402
+ ...(templateSnapshot ? { template_snapshot: templateSnapshot } : {}),
403
+ ...(roomTrailingLines ? { goal: roomTrailingLines } : {}),
404
+ });
405
+ const lines = [`🏠 Room ${r.room_id} created · ${r.state}`];
406
+ if (templateName)
407
+ lines.push(`Template: ${templateName}`);
408
+ await ctx.reply(lines.join('\n'));
409
+ break;
410
+ }
411
+ case 'list': {
412
+ const { listRoomRecords } = await import('../rooms-tasks/room-state.js');
413
+ const filter = rest[0];
414
+ let rooms = listRoomRecords();
415
+ if (filter === 'active')
416
+ rooms = rooms.filter(r => r.state === 'active');
417
+ if (!rooms.length) {
418
+ await ctx.reply('🏠 No rooms.');
419
+ break;
420
+ }
421
+ const lines = rooms.map(r => `${r.room_id} ${r.state} ${r.room_name}${r.task_id ? ` (task: ${r.task_id})` : ''}`);
422
+ await ctx.reply(tail(`🏠 Rooms:\n${lines.join('\n')}`, REPLY_MAX_CHARS));
423
+ break;
424
+ }
425
+ case 'show': {
426
+ if (!rest[0])
427
+ throw new OwnerCommandUsageError('usage: /room show <id>');
428
+ await showRoom(rest[0]);
429
+ break;
430
+ }
431
+ case 'close': {
432
+ if (rest.length < 2 || rest[0] !== rest[1])
433
+ throw new OwnerCommandUsageError('destructive: /room close <id> <id> — provide the room ID twice');
434
+ const { closeRoom } = await import('../rooms-tasks/room-state.js');
435
+ const r = closeRoom(rest[0]);
436
+ await ctx.reply(`🔒 Room ${r.room_id} → closed`);
437
+ break;
438
+ }
439
+ case 'recover': {
440
+ if (!rest[0])
441
+ throw new OwnerCommandUsageError('usage: /room recover <id>');
442
+ const { getRoomRecord } = await import('../rooms-tasks/room-state.js');
443
+ const r = getRoomRecord(rest[0]);
444
+ if (!r) {
445
+ await ctx.reply(`⚠️ room not found: ${rest[0]}`);
446
+ break;
447
+ }
448
+ const lines = [`🏠 Room ${r.room_id} · ${r.state} · saga: ${r.saga.phase}`];
449
+ if (r.saga.error)
450
+ lines.push(`Error: ${r.saga.error}`);
451
+ if (r.provisioning_detail)
452
+ lines.push(`Detail: ${r.provisioning_detail}`);
453
+ lines.push(r.saga.error ? 'Run `ours-fleet room recover` from CLI for full recovery.' : 'No recovery needed.');
454
+ await ctx.reply(lines.join('\n'));
455
+ break;
456
+ }
457
+ default:
458
+ // bare /room <id> → show
459
+ await showRoom(sub);
460
+ }
461
+ }
462
+ catch (e) {
463
+ if (e instanceof OwnerCommandUsageError)
464
+ throw e;
465
+ await ctx.reply(`⚠️ ${e instanceof Error ? e.message : String(e)}`);
466
+ }
218
467
  },
219
468
  },
220
469
  {
221
- name: 'templates', summary: 'list available room templates',
470
+ name: 'templates', aliases: ['template-list'],
471
+ summary: 'list available room templates',
222
472
  execute: noArgs('/templates', async (ctx) => {
223
473
  const { listTemplates } = await import('../rooms-tasks/templates.js');
224
474
  const templates = listTemplates({});
@@ -231,6 +481,55 @@ export const ownerCommands = [
231
481
  await ctx.reply(`📐 Templates:\n${lines.join('\n')}`);
232
482
  }),
233
483
  },
484
+ {
485
+ name: 'template',
486
+ usage: '/template <show|list> <name[@version]>',
487
+ summary: 'template subcommands (show, list)',
488
+ execute: async (ctx, args) => {
489
+ if (!args)
490
+ throw new OwnerCommandUsageError('usage: /template show <name[@version]>');
491
+ const parts = args.trim().split(/\s+/);
492
+ const sub = parts[0];
493
+ const showTemplate = async (nameStr) => {
494
+ const { resolveTemplate } = await import('../rooms-tasks/templates.js');
495
+ const t = resolveTemplate(nameStr, {});
496
+ if (!t)
497
+ return ctx.reply(`⚠️ template not found: ${nameStr}`);
498
+ const lines = [
499
+ `📐 Template: ${t.name}@${t.version}`,
500
+ `Description: ${t.description}`,
501
+ ...(t.builtin ? ['Source: built-in'] : []),
502
+ ...(t.contract ? [`Contract: ${t.contract}`] : []),
503
+ 'Members:',
504
+ ...t.members.map(m => ` ${m.slot} (${m.role}) ×${m.count} → role_ref: ${m.role_ref}`),
505
+ ];
506
+ await ctx.reply(lines.join('\n'));
507
+ };
508
+ switch (sub) {
509
+ case 'show': {
510
+ if (!parts[1])
511
+ throw new OwnerCommandUsageError('usage: /template show <name[@version]>');
512
+ await showTemplate(parts[1]);
513
+ break;
514
+ }
515
+ case 'list': {
516
+ const { listTemplates } = await import('../rooms-tasks/templates.js');
517
+ const templates = listTemplates({});
518
+ if (!templates.length)
519
+ return ctx.reply('📐 No templates.');
520
+ const lines = templates.map(t => {
521
+ const tag = t.builtin ? ' (built-in)' : '';
522
+ return `${t.name}@${t.version}${tag} ${t.description}`;
523
+ });
524
+ await ctx.reply(`📐 Templates:\n${lines.join('\n')}`);
525
+ break;
526
+ }
527
+ default:
528
+ // bare /template <name> → show
529
+ await showTemplate(sub);
530
+ }
531
+ },
532
+ },
234
533
  ];
235
534
  /** Trimmed slash-prefixed text is a command attempt and is never forwarded. */
236
535
  export const isOwnerCommandText = (text) => text.trim().startsWith('/');