@ours.network/fleet 1.0.4 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/docs.js CHANGED
@@ -361,6 +361,25 @@ Messenger-bound results are capped at 3,500 Unicode code points and 12,000 UTF-8
361
361
  bytes with structural omission notices. \`--json\` bypasses this presentation layer
362
362
  and retains the versioned machine schema and serialization order.
363
363
 
364
+ Every task belongs to a named list. The built-in \`default\` list always exists,
365
+ and legacy tasks or create calls without \`--list\` resolve to it. Use \`task lists\`,
366
+ \`task list-create <name>\`, \`task list-rename <name> <new-name>\`, and
367
+ \`task list-delete <name> [--move-to <destination>]\` to manage lists. A non-empty
368
+ list cannot be deleted without an explicit, different destination; Fleet moves
369
+ the assignments and never deletes the tasks. \`task move <id> --list <name>\`
370
+ changes only organizational metadata. \`task list --list <name>\` filters and
371
+ \`--group-by-list --json\` returns deterministic groups.
372
+
373
+ List names are NFC-normalized, case-sensitive, and limited to 64 Unicode code
374
+ points. Leading/trailing whitespace, controls, format/path characters, normalized
375
+ duplicates, and the reserved exact name \`default\` are rejected. The authenticated
376
+ owner channel provides the matching \`/task\` subcommands, while authenticated web
377
+ clients use \`/api/v1/task-lists\`, \`/api/v1/tasks\`, and
378
+ \`/api/v1/tasks/:id/list\`; every adapter delegates to the same application service.
379
+ Messenger's multiline command grammar treats surrounding whitespace on each
380
+ value line as transport framing; the canonical value passed to the shared service
381
+ is the trimmed line. CLI arguments and REST strings are passed verbatim.
382
+
364
383
  Older prerelease files with the exact legacy \`provider: cowork\` key under
365
384
  \`rooms:\` still load, but the key is ignored and omitted from resolved
366
385
  configuration. Remove it when editing the file. Any other legacy value is an
@@ -609,7 +628,6 @@ owner_channel:
609
628
  max_file_bytes: 10485760
610
629
  max_request_bytes: 20971520
611
630
  retention_ms: 86400000
612
- allowed_mime: [application/pdf, text/plain, image/png, audio/ogg]
613
631
  \`\`\`
614
632
 
615
633
  Permanent role identities are also reconciled before launch. Fleet creates a
@@ -658,14 +676,18 @@ suppresses receipts, progress notices, or the final answer.
658
676
 
659
677
  Owner documents, images, and voice messages use the same authenticated sender
660
678
  and source-wire boundary. Fleet inspects body-free metadata first and rejects
661
- disabled, over-count, over-size, or disallowed-MIME requests before selective
679
+ disabled, over-count, or over-size requests before selective
662
680
  retrieval. Unauthorized CIDs are never retrieved or answered. Reply-linked text
663
681
  and files from the same sender become one ordered request; a file-only wake also
664
682
  starts a turn. Retrieved bytes must match their structured size and SHA-256,
665
- their content signature must match the declared MIME, and symlinks or non-regular
666
- paths fail closed. Sanitized copies live only in a mode-0700 request directory as
683
+ while MIME values, extensions, file categories, and declared-versus-detected mismatches
684
+ remain report-only metadata. Symlinks or non-regular paths fail closed. Sanitized copies live only in a mode-0700 request directory as
667
685
  mode-0600 files and are removed after completion or bounded stale retention.
668
686
 
687
+ The legacy \`attachments.allowed_mime\` key is accepted and ignored so existing
688
+ configurations keep loading; it is omitted from resolved configuration and cannot
689
+ affect admission.
690
+
669
691
  Voice prompts include a bounded transcript only when typed daemon metadata reports success.
670
692
  Failure or unavailability is explicit and preserves the private audio path as the
671
693
  input for direct review. Run \`ours config show --json\` and inspect \`sttConfigured\` without
@@ -55,13 +55,11 @@ export declare function parseRetrievedAttachments(raw: OursRetrievedFiles | unde
55
55
  export declare function validateAttachmentSelection(files: IncomingAttachment[], config: OwnerAttachmentConfig): string | undefined;
56
56
  /**
57
57
  * Managed-agent -> owner egress limits. This intentionally does not consult
58
- * `enabled` or `allowed_mime`: those are owner -> agent admission policy.
58
+ * `enabled`: that is owner -> agent admission policy.
59
59
  */
60
60
  export declare function validateAttachmentRelaySelection(files: IncomingAttachment[], config: OwnerAttachmentConfig): string | undefined;
61
61
  export declare function prepareAttachmentDirectory(root: string, requestId: string): Promise<string>;
62
- export declare function admitAttachments(files: RetrievedAttachment[], dir: string, config: OwnerAttachmentConfig, options?: {
63
- mimePolicy?: 'strict' | 'report-only';
64
- }): Promise<AdmittedAttachment[]>;
62
+ export declare function admitAttachments(files: RetrievedAttachment[], dir: string, config: OwnerAttachmentConfig): Promise<AdmittedAttachment[]>;
65
63
  /** Injectable short-write seam, so partial writes are provably handled. */
66
64
  export interface AttachmentWriteDeps {
67
65
  write?(handle: FileHandle, bytes: Uint8Array, offset: number): Promise<number>;
@@ -94,16 +94,9 @@ function parseTranscription(value, wireId) {
94
94
  fileWireId: wireId,
95
95
  } };
96
96
  }
97
- // Voice notes ride "<base>; x-ours-kind=voice-message" verbatim end to end
98
- // (the recorder's real container varies: audio/webm Chrome/Android, audio/mp4
99
- // iOS Safari, audio/ogg fallback), so policy for voice messages applies to the
100
- // base container type. Ordinary files keep exact allowlist matching.
101
97
  function baseMime(mime) {
102
98
  return mime.split(';')[0].trim();
103
99
  }
104
- function policyMime(file) {
105
- return file.kind === 'voice_message' ? baseMime(file.mime) : file.mime;
106
- }
107
100
  export function validateAttachmentSelection(files, config) {
108
101
  if (!config.enabled)
109
102
  return 'attachments are disabled for this owner channel';
@@ -111,11 +104,6 @@ export function validateAttachmentSelection(files, config) {
111
104
  return `the request exceeds the ${config.max_files_per_request}-file limit`;
112
105
  let total = 0;
113
106
  for (const file of files) {
114
- const mime = policyMime(file);
115
- if (file.kind === 'voice_message' && !mime.startsWith('audio/'))
116
- return `voice-message MIME type ${file.mime || '(missing)'} is not an audio container`;
117
- if (!config.allowed_mime.includes(mime))
118
- return `MIME type ${file.mime || '(missing)'} is not allowed`;
119
107
  if (file.size > config.max_file_bytes)
120
108
  return `a file exceeds the ${config.max_file_bytes}-byte limit`;
121
109
  total += file.size;
@@ -126,7 +114,7 @@ export function validateAttachmentSelection(files, config) {
126
114
  }
127
115
  /**
128
116
  * Managed-agent -> owner egress limits. This intentionally does not consult
129
- * `enabled` or `allowed_mime`: those are owner -> agent admission policy.
117
+ * `enabled`: that is owner -> agent admission policy.
130
118
  */
131
119
  export function validateAttachmentRelaySelection(files, config) {
132
120
  if (files.length > config.max_files_per_request)
@@ -157,7 +145,7 @@ export async function prepareAttachmentDirectory(root, requestId) {
157
145
  await chmod(dir, 0o700);
158
146
  return dir;
159
147
  }
160
- export async function admitAttachments(files, dir, config, options = {}) {
148
+ export async function admitAttachments(files, dir, config) {
161
149
  const admitted = [];
162
150
  let total = 0;
163
151
  for (let index = 0; index < files.length; index++) {
@@ -184,11 +172,8 @@ export async function admitAttachments(files, dir, config, options = {}) {
184
172
  total += bytes.length;
185
173
  if (total > config.max_request_bytes)
186
174
  throw new Error('retrieved attachments exceed the request size limit');
187
- const declaredMime = policyMime(file);
188
- const detectedMime = detectMime(bytes, declaredMime);
189
- if ((options.mimePolicy ?? 'strict') === 'strict'
190
- && !mimeCompatible(declaredMime, detectedMime))
191
- throw new Error(`retrieved attachment content does not match declared MIME ${declaredMime}`);
175
+ const declaredMime = file.mime;
176
+ const detectedMime = detectMime(bytes, baseMime(declaredMime));
192
177
  const filename = sanitizeFilename(file.filename);
193
178
  const finalPath = join(dir, `${index + 1}-${file.wireId.slice(0, 12)}-${filename}`);
194
179
  const tmp = join(dir, `.${basename(finalPath)}.${randomUUID()}.tmp`);
@@ -417,6 +402,8 @@ export function sanitizeFilename(value) {
417
402
  return clean || 'attachment.bin';
418
403
  }
419
404
  function detectMime(bytes, declared) {
405
+ if (bytes.length === 0)
406
+ return 'application/octet-stream';
420
407
  if (bytes.subarray(0, 5).toString() === '%PDF-')
421
408
  return 'application/pdf';
422
409
  if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
@@ -443,27 +430,7 @@ function detectMime(bytes, declared) {
443
430
  return 'application/x-cfb';
444
431
  const text = bytes.toString('utf8');
445
432
  if (!text.includes('\ufffd') && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/u.test(text)) {
446
- if (declared === 'application/json') {
447
- try {
448
- JSON.parse(text);
449
- return 'application/json';
450
- }
451
- catch { }
452
- }
453
433
  return 'text/plain';
454
434
  }
455
435
  return 'application/octet-stream';
456
436
  }
457
- function mimeCompatible(declared, detected) {
458
- if (declared === detected)
459
- return true;
460
- if (['audio/wav', 'audio/x-wav'].includes(declared) && detected === 'audio/wav')
461
- return true;
462
- if (detected === 'application/zip' && declared.startsWith('application/vnd.openxmlformats-officedocument.'))
463
- return true;
464
- if (detected === 'application/x-cfb' && [
465
- 'application/msword', 'application/vnd.ms-excel', 'application/vnd.ms-powerpoint',
466
- ].includes(declared))
467
- return true;
468
- return false;
469
- }
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { mkdir, readFile, readdir, rm } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
- import { DEFAULT_OWNER_ATTACHMENT_MIME, canonicalCid, } from '../config.js';
5
+ import { canonicalCid } from '../config.js';
6
6
  import { replaceFileAtomically } from '../atomic-file.js';
7
7
  import { TaskRoomApplicationService } from '../application/task-room-service.js';
8
8
  import { RoleLifecycleService } from '../application/role-command-service.js';
@@ -109,7 +109,6 @@ export class OwnerChannel {
109
109
  this.attachmentConfig = options.config.attachments ?? {
110
110
  enabled: true, max_files_per_request: 4, max_file_bytes: 10 * 1024 * 1024,
111
111
  max_request_bytes: 20 * 1024 * 1024, retention_ms: 24 * 60 * 60 * 1_000,
112
- allowed_mime: [...DEFAULT_OWNER_ATTACHMENT_MIME],
113
112
  };
114
113
  const integrity = this.authorizationIntegrity();
115
114
  if (!integrity.ok)
@@ -1018,6 +1017,20 @@ export class OwnerChannel {
1018
1017
  actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId,
1019
1018
  }),
1020
1019
  listTasks: filter => new TaskRoomApplicationService(this.options.configPath).listTasks(filter),
1020
+ groupedTasks: filter => new TaskRoomApplicationService(this.options.configPath).groupedTasks(filter),
1021
+ listTaskLists: () => new TaskRoomApplicationService(this.options.configPath).listTaskLists(),
1022
+ createTaskList: name => new TaskRoomApplicationService(this.options.configPath).createTaskList({
1023
+ actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, name,
1024
+ }),
1025
+ renameTaskList: (name, newName) => new TaskRoomApplicationService(this.options.configPath).renameTaskList({
1026
+ actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, name, newName,
1027
+ }),
1028
+ deleteTaskList: (name, destination) => new TaskRoomApplicationService(this.options.configPath).deleteTaskList({
1029
+ actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, name, destination,
1030
+ }),
1031
+ moveTask: (taskId, list) => new TaskRoomApplicationService(this.options.configPath).moveTask({
1032
+ actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId, list,
1033
+ }),
1021
1034
  getTask: taskId => new TaskRoomApplicationService(this.options.configPath).getTask(taskId),
1022
1035
  blockTask: (taskId, reason) => new TaskRoomApplicationService(this.options.configPath).blockTask({
1023
1036
  actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId, reason,
@@ -1349,7 +1362,7 @@ export class OwnerChannel {
1349
1362
  }
1350
1363
  const order = new Map(group.files.map((file, index) => [file.wireId, index]));
1351
1364
  retrieved.sort((a, b) => order.get(a.wireId) - order.get(b.wireId));
1352
- const admitted = await admitAttachments(retrieved, requestDir, this.attachmentConfig, { mimePolicy: 'report-only' });
1365
+ const admitted = await admitAttachments(retrieved, requestDir, this.attachmentConfig);
1353
1366
  const digest = createHash('sha256').update(`managed-agent-attachment\0${handledWireIds.slice().sort().join('\0')}`).digest('hex');
1354
1367
  const sending = this.conversations.beginSend(route.contact, digest, Date.now(), 0, 'all');
1355
1368
  try {
@@ -1,5 +1,5 @@
1
1
  import type { InterruptOutcome, SessionEvent, SessionSnapshot } from '../session/types.js';
2
- import type { RoomOrchestrationRecord, TaskOutcome, TaskRecord, TaskTerminalIntent } from '../rooms-tasks/types.js';
2
+ import type { RoomOrchestrationRecord, TaskOutcome, TaskRecord, TaskTerminalIntent, TaskListRecord } from '../rooms-tasks/types.js';
3
3
  import { type OwnerCommentsState } from './notices.js';
4
4
  import type { CreateRoomRequest, CreateTaskRequest, TaskRoomApplicationService } from '../application/task-room-service.js';
5
5
  import type { TaskState } from '../rooms-tasks/types.js';
@@ -58,7 +58,24 @@ export interface OwnerCommandContext {
58
58
  startTask(taskId: string): Promise<TaskRecord>;
59
59
  listTasks(filter?: {
60
60
  state?: TaskState | TaskState[];
61
+ list?: string;
61
62
  }): TaskRecord[];
63
+ groupedTasks(filter?: {
64
+ state?: TaskState | TaskState[];
65
+ list?: string;
66
+ }): Array<{
67
+ list: TaskListRecord;
68
+ tasks: TaskRecord[];
69
+ }>;
70
+ listTaskLists(): TaskListRecord[];
71
+ createTaskList(name: string): Promise<TaskListRecord>;
72
+ renameTaskList(name: string, newName: string): Promise<TaskListRecord>;
73
+ deleteTaskList(name: string, destination?: string): Promise<{
74
+ deleted: TaskListRecord;
75
+ moved: number;
76
+ destination?: TaskListRecord;
77
+ }>;
78
+ moveTask(taskId: string, list: string): Promise<TaskRecord>;
62
79
  getTask(taskId: string): {
63
80
  task: TaskRecord;
64
81
  orchestration: RoomOrchestrationRecord | undefined;
@@ -5,12 +5,14 @@ import { TaskStateError } from '../rooms-tasks/task-state.js';
5
5
  import { markdownCode, markdownProse, renderMarkdownFailure, renderMarkdownList, renderMarkdownResult, roomStatus, taskStatus, } from '../rooms-tasks/markdown.js';
6
6
  import { OWNER_COMMENT_LABEL, ownerNotices } from './notices.js';
7
7
  import { TaskRoomApplicationError } from '../application/task-room-service.js';
8
+ import { TaskListError } from '../rooms-tasks/task-lists.js';
8
9
  /** A malformed invocation; the dispatcher answers it with annotated help. */
9
10
  class OwnerCommandUsageError extends Error {
10
11
  }
11
12
  const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
12
13
  const REPLY_MAX_CHARS = 3_500;
13
14
  const taskListRecord = (task) => `${taskStatus(task.state)} ${markdownCode(task.task_id)} — ${markdownProse(task.title)}`
15
+ + ` — List ${markdownCode(task.list_name ?? 'default')}`
14
16
  + (task.blocked ? ` — 🚧 Blocked: ${markdownProse(task.blocked.reason)}` : '');
15
17
  const roomListRecord = (room) => `${roomStatus(room.state)} ${markdownCode(room.room_id)} — ${markdownProse(room.room_name)}`
16
18
  + (room.task_id ? ` — Task ${markdownCode(room.task_id)}` : '');
@@ -35,6 +37,14 @@ function isKnownOwnerRoomState(message) {
35
37
  ].some(pattern => pattern.test(message));
36
38
  }
37
39
  function ownerTaskFailure(error) {
40
+ if (error instanceof TaskListError)
41
+ return {
42
+ kind: error.code === 'list_not_found' ? 'not_found'
43
+ : error.code === 'invalid_name' ? 'usage' : 'state',
44
+ detail: error.message,
45
+ action: error.code === 'list_not_found' ? 'Run /task lists to find a valid list.'
46
+ : 'Choose a valid list name or an explicit different destination.',
47
+ };
38
48
  if (error instanceof TaskStateError) {
39
49
  const missing = /^task not found: ([A-Za-z0-9_-]{1,128})$/u.exec(error.message);
40
50
  if (missing)
@@ -263,6 +273,7 @@ export const ownerCommands = [
263
273
  { label: 'ID', value: t.task_id, kind: 'code' },
264
274
  { label: 'Title', value: t.title },
265
275
  { label: 'Status', value: taskStatus(t.state), kind: 'markdown' },
276
+ { label: 'List', value: t.list_name ?? 'default', kind: 'code' },
266
277
  ...(t.blocked ? [{ label: 'Blocked', value: t.blocked.reason }] : []),
267
278
  ...(t.template ? [{ label: 'Template', value: `${t.template.name}@${t.template.version}`, kind: 'code' }] : []),
268
279
  ...(t.room_id ? [{ label: 'Room', value: t.room_id, kind: 'code' }] : []),
@@ -280,12 +291,15 @@ export const ownerCommands = [
280
291
  const tplFlag = rest.find(r => r.startsWith('--template='));
281
292
  const template = tplFlag ? tplFlag.slice('--template='.length) : undefined;
282
293
  const noRoom = rest.includes('--no-room');
283
- const titleParts = rest.filter(r => r !== '--backlog' && !r.startsWith('--template=') && r !== '--no-room');
294
+ const listFlag = rest.find(r => r.startsWith('--list='));
295
+ const list = listFlag?.slice('--list='.length);
296
+ const titleParts = rest.filter(r => r !== '--backlog' && !r.startsWith('--template=')
297
+ && !r.startsWith('--list=') && r !== '--no-room');
284
298
  if (!titleParts.length)
285
299
  throw new OwnerCommandUsageError('usage: /task create [--backlog] [--template=<name>|--no-room] <title>');
286
300
  const t = await ctx.createTask({
287
301
  title: titleParts.join(' '), origin: { type: 'owner_channel' },
288
- backlog, template, noRoom, brief: trailingLines,
302
+ backlog, template, noRoom, brief: trailingLines, list,
289
303
  });
290
304
  await ctx.reply(taskAction('Task created', t, [
291
305
  { label: 'Title', value: t.title },
@@ -295,7 +309,10 @@ export const ownerCommands = [
295
309
  break;
296
310
  }
297
311
  case 'list': {
298
- const filter = rest[0];
312
+ const listFlag = rest.find(r => r.startsWith('--list='));
313
+ const list = trailingLines ?? listFlag?.slice('--list='.length);
314
+ const grouped = rest.includes('--group-by-list');
315
+ const filter = rest.find(r => !r.startsWith('--'));
299
316
  const stateMap = {
300
317
  backlog: 'backlog', active: 'active', blocked: 'active',
301
318
  done: 'done', all: ['backlog', 'provisioning', 'active', 'review', 'done', 'cancelled', 'failed'],
@@ -303,7 +320,8 @@ export const ownerCommands = [
303
320
  const stateFilter = filter && stateMap[filter]
304
321
  ? { state: stateMap[filter] }
305
322
  : undefined;
306
- const tasks = ctx.listTasks(stateFilter);
323
+ const combined = { ...(stateFilter ?? {}), ...(list ? { list } : {}) };
324
+ const tasks = ctx.listTasks(combined);
307
325
  if (filter === 'blocked') {
308
326
  const blocked = tasks.filter(t => t.blocked);
309
327
  await ctx.reply(renderMarkdownList({
@@ -312,12 +330,63 @@ export const ownerCommands = [
312
330
  }));
313
331
  break;
314
332
  }
333
+ if (grouped) {
334
+ const groups = ctx.groupedTasks(combined);
335
+ await ctx.reply(groups.map(group => renderMarkdownList({
336
+ icon: '📋', title: `Tasks — ${group.list.name}`, empty: 'No tasks found.',
337
+ records: group.tasks.map(taskListRecord),
338
+ })).join('\n\n'));
339
+ break;
340
+ }
315
341
  await ctx.reply(renderMarkdownList({
316
342
  icon: '📋', title: 'Tasks', empty: 'No tasks found.',
317
343
  records: tasks.map(taskListRecord),
318
344
  }));
319
345
  break;
320
346
  }
347
+ case 'lists': {
348
+ const lists = ctx.listTaskLists();
349
+ await ctx.reply(renderMarkdownList({ icon: '📚', title: 'Task lists', empty: 'No task lists found.',
350
+ records: lists.map(list => `${markdownCode(list.name)}${list.built_in ? ' — built-in' : ''}`) }));
351
+ break;
352
+ }
353
+ case 'list-create': {
354
+ const name = trailingLines ?? rest.join(' ');
355
+ if (!name)
356
+ throw new OwnerCommandUsageError('usage: /task list-create\n<name>');
357
+ const list = await ctx.createTaskList(name);
358
+ await ctx.reply(renderMarkdownResult({ icon: '📚', title: 'Task list created', fields: [{ label: 'Name', value: list.name }] }));
359
+ break;
360
+ }
361
+ case 'list-rename': {
362
+ const names = argLines.slice(1).map(line => line.trim()).filter(Boolean);
363
+ const current = names.length === 2 ? names[0] : rest[0];
364
+ const next = names.length === 2 ? names[1] : rest[1];
365
+ if (!current || !next)
366
+ throw new OwnerCommandUsageError('usage: /task list-rename\n<name>\n<new-name>');
367
+ const list = await ctx.renameTaskList(current, next);
368
+ await ctx.reply(renderMarkdownResult({ icon: '📚', title: 'Task list renamed', fields: [{ label: 'Name', value: list.name }] }));
369
+ break;
370
+ }
371
+ case 'list-delete': {
372
+ const names = argLines.slice(1).map(line => line.trim()).filter(Boolean);
373
+ const name = names[0] ?? rest[0];
374
+ const destination = names[1] ?? rest.find(r => r.startsWith('--move-to='))?.slice('--move-to='.length);
375
+ if (!name)
376
+ throw new OwnerCommandUsageError('usage: /task list-delete\n<name>\n[move-to-name]');
377
+ const result = await ctx.deleteTaskList(name, destination);
378
+ await ctx.reply(renderMarkdownResult({ icon: '🗑️', title: 'Task list deleted', fields: [
379
+ { label: 'Name', value: result.deleted.name }, { label: 'Tasks moved', value: String(result.moved) },
380
+ ] }));
381
+ break;
382
+ }
383
+ case 'move': {
384
+ const destination = trailingLines ?? rest.find(r => r.startsWith('--list='))?.slice('--list='.length);
385
+ if (!rest[0] || !destination)
386
+ throw new OwnerCommandUsageError('usage: /task move <id> --list=<name>');
387
+ await showTask((await ctx.moveTask(rest[0], destination)).task_id);
388
+ break;
389
+ }
321
390
  case 'show': {
322
391
  if (!rest[0])
323
392
  throw new OwnerCommandUsageError('usage: /task show <id>');
@@ -8,6 +8,7 @@ import { createRoomRecord, getRoomRecord, advanceSaga, setOwnerSeat, setSagaErro
8
8
  import { createCoworkAdapter, CoworkProtocolError } from './cowork-adapter.js';
9
9
  import { markdownCode, markdownProse, renderMarkdownFailure, renderMarkdownList, renderMarkdownResult, roomStatus, taskStatus, } from './markdown.js';
10
10
  import { TaskRoomApplicationError, TaskRoomApplicationService } from '../application/task-room-service.js';
11
+ import { TaskListError } from './task-lists.js';
11
12
  class TaskRoomPublicError extends Error {
12
13
  code;
13
14
  fields;
@@ -128,6 +129,21 @@ function dieTaskRoom(e) {
128
129
  process.stderr.write(`${output}\n`);
129
130
  process.exit(1);
130
131
  }
132
+ if (e instanceof TaskListError) {
133
+ const notFound = e.code === 'list_not_found';
134
+ const conflict = ['duplicate_name', 'reserved_name', 'default_immutable',
135
+ 'destination_required', 'same_destination'].includes(e.code);
136
+ const output = renderMarkdownFailure({
137
+ kind: notFound ? 'not_found' : conflict ? 'state' : 'usage',
138
+ subject: 'ours-fleet task list',
139
+ detail: e.message,
140
+ action: notFound ? 'Run ours-fleet task lists to find a valid list.'
141
+ : conflict ? 'Choose a different list name or an explicit valid destination.'
142
+ : 'Use a valid NFC-normalized name of at most 64 Unicode code points.',
143
+ });
144
+ process.stderr.write(`${output}\n`);
145
+ process.exit(1);
146
+ }
131
147
  const taskMissing = e instanceof TaskStateError
132
148
  ? /^task not found: ([A-Za-z0-9_-]{1,128})$/u.exec(e.message) : null;
133
149
  const roomMissing = e instanceof RoomStateError
@@ -156,6 +172,7 @@ function dieTaskRoom(e) {
156
172
  const taskListMarkdown = (tasks, title = 'Tasks') => renderMarkdownList({
157
173
  icon: '📋', title, empty: 'No tasks found.',
158
174
  records: tasks.map(t => `${taskStatus(t.state)} ${markdownCode(t.task_id)} — ${markdownProse(t.title)}`
175
+ + ` — List ${markdownCode(t.list_name ?? 'default')}`
159
176
  + (t.blocked ? ` — 🚧 Blocked: ${markdownProse(t.blocked.reason)}` : '')),
160
177
  });
161
178
  const taskActionMarkdown = (title, task, fields = []) => renderMarkdownResult({
@@ -163,6 +180,7 @@ const taskActionMarkdown = (title, task, fields = []) => renderMarkdownResult({
163
180
  fields: [
164
181
  { label: 'ID', value: task.task_id, kind: 'code' },
165
182
  { label: 'Status', value: taskStatus(task.state), kind: 'markdown' },
183
+ { label: 'List', value: task.list_name ?? 'default', kind: 'code' },
166
184
  ...fields,
167
185
  ],
168
186
  });
@@ -435,6 +453,7 @@ export function registerTaskCommands(parent, cOpt) {
435
453
  .option('--backlog', 'create in backlog (do not start immediately)')
436
454
  .option('--no-room', 'create task without a room')
437
455
  .option('--idempotency-key <key>', 'idempotency key')
456
+ .option('--list <name>', 'task list (default: default)')
438
457
  .option('--json', 'JSON output')
439
458
  .action(async (opts) => {
440
459
  try {
@@ -443,6 +462,7 @@ export function registerTaskCommands(parent, cOpt) {
443
462
  brief: opts.brief, briefFile: opts.briefFile, template: opts.template,
444
463
  backlog: opts.backlog, noRoom: opts.room === false,
445
464
  idempotencyKey: opts.idempotencyKey, origin: { type: 'cli' },
465
+ list: opts.list,
446
466
  });
447
467
  if (opts.json) {
448
468
  console.log(JSON.stringify({ schema_version: 1, task: record }, null, 2));
@@ -464,6 +484,8 @@ export function registerTaskCommands(parent, cOpt) {
464
484
  cOpt(taskCmd.command('list'))
465
485
  .description('list tasks')
466
486
  .option('--state <state>', 'filter by state (backlog|active|provisioning|review|done|cancelled|failed|all)')
487
+ .option('--list <name>', 'filter by task list')
488
+ .option('--group-by-list', 'group deterministic results by list (JSON)')
467
489
  .option('--json', 'JSON output')
468
490
  .action((opts) => {
469
491
  try {
@@ -471,9 +493,13 @@ export function registerTaskCommands(parent, cOpt) {
471
493
  if (opts.state && opts.state !== 'all') {
472
494
  stateFilter = opts.state;
473
495
  }
474
- const tasks = taskRoomService(opts.configuration).listTasks(stateFilter ? { state: stateFilter } : undefined);
496
+ const service = taskRoomService(opts.configuration);
497
+ const filter = { ...(stateFilter ? { state: stateFilter } : {}), ...(opts.list ? { list: opts.list } : {}) };
498
+ const tasks = service.listTasks(filter);
475
499
  if (opts.json) {
476
- console.log(JSON.stringify({ schema_version: 1, tasks }, null, 2));
500
+ console.log(JSON.stringify(opts.groupByList
501
+ ? { schema_version: 1, groups: service.groupedTasks(filter) }
502
+ : { schema_version: 1, tasks }, null, 2));
477
503
  return;
478
504
  }
479
505
  console.log(taskListMarkdown(tasks));
@@ -484,6 +510,99 @@ export function registerTaskCommands(parent, cOpt) {
484
510
  dieTaskRoom(e);
485
511
  }
486
512
  });
513
+ taskCmd.command('lists')
514
+ .description('list named task lists')
515
+ .option('--json', 'JSON output')
516
+ .action((opts) => {
517
+ try {
518
+ const lists = taskRoomService().listTaskLists();
519
+ if (opts.json) {
520
+ console.log(JSON.stringify({ schema_version: 1, lists }, null, 2));
521
+ return;
522
+ }
523
+ console.log(renderMarkdownList({ icon: '📚', title: 'Task lists', empty: 'No task lists found.',
524
+ records: lists.map(list => `${markdownCode(list.name)}${list.built_in ? ' — built-in' : ''}`) }));
525
+ }
526
+ catch (e) {
527
+ if (opts.json)
528
+ die(e);
529
+ dieTaskRoom(e);
530
+ }
531
+ });
532
+ taskCmd.command('list-create <name>')
533
+ .description('create a named task list')
534
+ .option('--json', 'JSON output')
535
+ .action(async (name, opts) => {
536
+ try {
537
+ const list = await taskRoomService().createTaskList({ actor: { kind: 'local_control', surface: 'cli' }, name });
538
+ if (opts.json) {
539
+ console.log(JSON.stringify({ schema_version: 1, list }, null, 2));
540
+ return;
541
+ }
542
+ console.log(renderMarkdownResult({ icon: '📚', title: 'Task list created', fields: [{ label: 'Name', value: list.name }] }));
543
+ }
544
+ catch (e) {
545
+ if (opts.json)
546
+ die(e);
547
+ dieTaskRoom(e);
548
+ }
549
+ });
550
+ taskCmd.command('list-rename <name> <new-name>')
551
+ .description('rename a named task list')
552
+ .option('--json', 'JSON output')
553
+ .action(async (name, newName, opts) => {
554
+ try {
555
+ const list = await taskRoomService().renameTaskList({ actor: { kind: 'local_control', surface: 'cli' }, name, newName });
556
+ if (opts.json) {
557
+ console.log(JSON.stringify({ schema_version: 1, list }, null, 2));
558
+ return;
559
+ }
560
+ console.log(renderMarkdownResult({ icon: '📚', title: 'Task list renamed', fields: [{ label: 'Name', value: list.name }] }));
561
+ }
562
+ catch (e) {
563
+ if (opts.json)
564
+ die(e);
565
+ dieTaskRoom(e);
566
+ }
567
+ });
568
+ taskCmd.command('list-delete <name>')
569
+ .description('delete a task list; non-empty lists require --move-to')
570
+ .option('--move-to <name>', 'destination for assigned tasks')
571
+ .option('--json', 'JSON output')
572
+ .action(async (name, opts) => {
573
+ try {
574
+ const result = await taskRoomService().deleteTaskList({ actor: { kind: 'local_control', surface: 'cli' }, name, destination: opts.moveTo });
575
+ if (opts.json) {
576
+ console.log(JSON.stringify({ schema_version: 1, ...result }, null, 2));
577
+ return;
578
+ }
579
+ console.log(renderMarkdownResult({ icon: '🗑️', title: 'Task list deleted', fields: [{ label: 'Name', value: result.deleted.name }, { label: 'Tasks moved', value: String(result.moved) }] }));
580
+ }
581
+ catch (e) {
582
+ if (opts.json)
583
+ die(e);
584
+ dieTaskRoom(e);
585
+ }
586
+ });
587
+ taskCmd.command('move <id>')
588
+ .description('move a task to another list without changing its lifecycle')
589
+ .requiredOption('--list <name>', 'destination task list')
590
+ .option('--json', 'JSON output')
591
+ .action(async (id, opts) => {
592
+ try {
593
+ const task = await taskRoomService().moveTask({ actor: { kind: 'local_control', surface: 'cli' }, taskId: id, list: opts.list });
594
+ if (opts.json) {
595
+ console.log(JSON.stringify({ schema_version: 1, task }, null, 2));
596
+ return;
597
+ }
598
+ console.log(taskActionMarkdown('Task moved', task));
599
+ }
600
+ catch (e) {
601
+ if (opts.json)
602
+ die(e);
603
+ dieTaskRoom(e);
604
+ }
605
+ });
487
606
  taskCmd.command('show <id>')
488
607
  .description('show task details')
489
608
  .option('--json', 'JSON output')
@@ -0,0 +1,19 @@
1
+ import type { TaskListRecord } from './types.js';
2
+ export declare const DEFAULT_TASK_LIST_ID = "default";
3
+ export declare const TASK_LIST_NAME_MAX_CODE_POINTS = 64;
4
+ export declare class TaskListError extends Error {
5
+ readonly code: 'invalid_name' | 'duplicate_name' | 'reserved_name' | 'list_not_found' | 'default_immutable' | 'destination_required' | 'same_destination';
6
+ constructor(code: 'invalid_name' | 'duplicate_name' | 'reserved_name' | 'list_not_found' | 'default_immutable' | 'destination_required' | 'same_destination', message: string);
7
+ }
8
+ export declare const taskListsPath: () => string;
9
+ export declare const taskListsLockPath: () => string;
10
+ export declare const withTaskListsLock: <T>(fn: () => T | Promise<T>) => Promise<T>;
11
+ export declare function normalizeTaskListName(input: string): string;
12
+ export declare function readTaskLists(): TaskListRecord[];
13
+ export declare function resolveTaskList(name: string, lists?: TaskListRecord[]): TaskListRecord;
14
+ /** Caller must hold withTaskListsLock. */
15
+ export declare function createTaskListLocked(name: string): TaskListRecord;
16
+ /** Caller must hold withTaskListsLock. */
17
+ export declare function renameTaskListLocked(currentName: string, nextName: string): TaskListRecord;
18
+ /** Caller must hold withTaskListsLock. */
19
+ export declare function deleteTaskListRecordLocked(listId: string): void;