@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/README.md +13 -9
- package/dist/application/errors.d.ts +1 -1
- package/dist/application/task-room-service.d.ts +37 -2
- package/dist/application/task-room-service.js +49 -7
- package/dist/atomic-file.d.ts +5 -3
- package/dist/atomic-file.js +32 -8
- package/dist/build-info.json +4 -4
- package/dist/config.d.ts +6 -3
- package/dist/config.js +0 -17
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +26 -4
- package/dist/owner-channel/attachments.d.ts +2 -4
- package/dist/owner-channel/attachments.js +6 -39
- package/dist/owner-channel/channel.js +16 -3
- package/dist/owner-channel/commands.d.ts +18 -1
- package/dist/owner-channel/commands.js +73 -4
- package/dist/rooms-tasks/cli.js +121 -2
- package/dist/rooms-tasks/task-lists.d.ts +19 -0
- package/dist/rooms-tasks/task-lists.js +96 -0
- package/dist/rooms-tasks/task-state.d.ts +3 -0
- package/dist/rooms-tasks/task-state.js +281 -175
- package/dist/rooms-tasks/types.d.ts +11 -1
- package/dist/web/runtime.js +2 -0
- package/dist/web/server.d.ts +2 -0
- package/dist/web/server.js +86 -1
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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>');
|
package/dist/rooms-tasks/cli.js
CHANGED
|
@@ -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
|
|
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(
|
|
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;
|