orizu 0.4.0 → 0.4.1

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/help.js CHANGED
@@ -450,20 +450,20 @@ export const COMMAND_DOCS = [
450
450
  },
451
451
  {
452
452
  path: ['tasks', 'create'],
453
- usage: 'orizu tasks create --project <team/project> --dataset <datasetId> --app <appId> --title <title> [--assignees <userIdOrEmail1,userIdOrEmail2>] [--publish] [--version <n>] [--instructions <text>] [--labels-per-item <n>] [--json]',
454
- summary: 'Create a draft review task by default, or publish immediately with --publish and assignees.',
453
+ usage: 'orizu tasks create --project <team/project> --dataset <datasetId> --app <appId> --title <title> [--assignees <userIdOrEmail1,userIdOrEmail2> | --assignment-file <path>] [--publish] [--version <n>] [--instructions <text>] [--labels-per-item <n>] [--json]',
454
+ summary: 'Create a draft review task by default, with auto distribution or an explicit row-assignment JSONL file.',
455
455
  group: 'Tasks',
456
456
  },
457
457
  {
458
458
  path: ['tasks', 'publish'],
459
- usage: 'orizu tasks publish --task <taskId> --assignees <userId1,userId2> [--json]',
459
+ usage: 'orizu tasks publish --task <taskId> (--assignees <userId1,userId2> | --assignment-file <path>) [--json]',
460
460
  summary: 'Publish an approved draft task and assign reviewers.',
461
461
  group: 'Tasks',
462
462
  },
463
463
  {
464
464
  path: ['tasks', 'assign'],
465
- usage: 'orizu tasks assign --task <taskId> --assignees <userId1,userId2>',
466
- summary: 'Assign a task to canonical user IDs.',
465
+ usage: 'orizu tasks assign --task <taskId> (--assignees <userId1,userId2> | --assignment-file <path>) [--replace-existing] [--json]',
466
+ summary: 'Assign a task with auto distribution or an explicit row-assignment JSONL file.',
467
467
  group: 'Tasks',
468
468
  },
469
469
  {
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ import { extractErrorMessage } from './error-response.js';
17
17
  import { parseGlobalFlags } from './global-flags.js';
18
18
  import { getCapabilities, renderHelpForArgs, renderRootHelp } from './help.js';
19
19
  import { runLocalAppPreview } from './preview-runtime.js';
20
+ import { parseAssignmentManifestJsonl } from './task-assignment-manifest.js';
20
21
  import { assertSecureTokenTransport, authedFetch, getBaseUrl, resolveLoginBaseUrl, setGlobalFlags, } from './http.js';
21
22
  import { computeSkillContentHash, getSkillInstallPath, getSkillTargetStatus, getTargetForAgent, installSkillTarget, isSkillInstallAgent, isSkillInstallTarget, resolveSkillSource, SKILL_INSTALL_AGENTS, SKILL_INSTALL_TARGETS, targetNeedsOverwrite, } from './skill-installer.js';
22
23
  import { renderBanner } from './banner.js';
@@ -2986,39 +2987,65 @@ function parseCommaSeparated(value) {
2986
2987
  .map(item => item.trim())
2987
2988
  .filter(Boolean);
2988
2989
  }
2990
+ function readAssignmentManifest(path) {
2991
+ if (!path) {
2992
+ return null;
2993
+ }
2994
+ const content = readFileSync(expandHomePath(path), 'utf8');
2995
+ return parseAssignmentManifestJsonl(content);
2996
+ }
2989
2997
  async function createTask() {
2990
2998
  const projectSlug = getArg('--project');
2991
2999
  const datasetId = getArg('--dataset');
2992
3000
  const appId = getArg('--app');
2993
3001
  const title = getArg('--title');
2994
3002
  const assignees = parseCommaSeparated(getArg('--assignees'));
3003
+ const assignmentFile = getArg('--assignment-file');
2995
3004
  const publish = hasArg('--publish');
2996
3005
  const versionArg = getArg('--version');
2997
3006
  const instructions = getArg('--instructions');
2998
3007
  const labelsPerItemArg = getArg('--labels-per-item');
2999
- const labelsPerItem = labelsPerItemArg ? Number(labelsPerItemArg) : 1;
3008
+ const parsedLabelsPerItem = labelsPerItemArg ? Number(labelsPerItemArg) : 1;
3009
+ const labelsPerItem = Number.isInteger(parsedLabelsPerItem) && parsedLabelsPerItem > 0
3010
+ ? parsedLabelsPerItem
3011
+ : null;
3000
3012
  const parsedVersionNum = versionArg ? Number(versionArg) : Number.NaN;
3001
3013
  const versionNum = Number.isInteger(parsedVersionNum) && parsedVersionNum > 0 ? parsedVersionNum : null;
3002
- if (!projectSlug || !datasetId || !appId || !title || (publish && assignees.length === 0)) {
3003
- throw new Error('Usage: orizu tasks create --project <team/project> --dataset <datasetId> --app <appId> --title <title> [--assignees <userIdOrEmail1,userIdOrEmail2>] [--publish] [--version <n>] [--instructions <text>] [--labels-per-item <n>] [--json]');
3014
+ if (assignmentFile && assignees.length > 0) {
3015
+ throw new Error('Use either --assignment-file or --assignees, not both.');
3016
+ }
3017
+ const explicitAssignments = readAssignmentManifest(assignmentFile);
3018
+ if (!projectSlug || !datasetId || !appId || !title || (publish && assignees.length === 0 && !explicitAssignments)) {
3019
+ throw new Error('Usage: orizu tasks create --project <team/project> --dataset <datasetId> --app <appId> --title <title> [--assignees <userIdOrEmail1,userIdOrEmail2> | --assignment-file <path>] [--publish] [--version <n>] [--instructions <text>] [--labels-per-item <n>] [--json]');
3004
3020
  }
3005
3021
  if (versionArg && versionNum === null) {
3006
3022
  throw new Error('--version must be a positive integer');
3007
3023
  }
3024
+ if (labelsPerItemArg && labelsPerItem === null) {
3025
+ throw new Error('--labels-per-item must be a positive integer');
3026
+ }
3027
+ const requestBody = {
3028
+ projectSlug,
3029
+ datasetId,
3030
+ appId,
3031
+ versionNum,
3032
+ title,
3033
+ publish,
3034
+ instructions,
3035
+ };
3036
+ if (explicitAssignments) {
3037
+ requestBody.explicitAssignments = explicitAssignments;
3038
+ }
3039
+ else {
3040
+ requestBody.memberIds = assignees;
3041
+ }
3042
+ if (!explicitAssignments || labelsPerItemArg) {
3043
+ requestBody.requiredAssignmentsPerRow = labelsPerItem;
3044
+ }
3008
3045
  const response = await authedFetch('/api/cli/tasks', {
3009
3046
  method: 'POST',
3010
3047
  headers: { 'Content-Type': 'application/json' },
3011
- body: JSON.stringify({
3012
- projectSlug,
3013
- datasetId,
3014
- appId,
3015
- versionNum,
3016
- title,
3017
- memberIds: assignees,
3018
- publish,
3019
- instructions,
3020
- requiredAssignmentsPerRow: labelsPerItem,
3021
- }),
3048
+ body: JSON.stringify(requestBody),
3022
3049
  });
3023
3050
  if (!response.ok) {
3024
3051
  const cliError = await formatTaskCreateError(response);
@@ -3041,13 +3068,16 @@ async function createTask() {
3041
3068
  taskUrl: `${getBaseUrl()}/d/${projectSlug}/tasks/${data.task.id}`,
3042
3069
  title: data.task.title,
3043
3070
  status: data.task.status,
3071
+ assignmentMode: explicitAssignments ? 'custom' : 'auto',
3044
3072
  requiredAssignmentsPerRow: data.task.requiredAssignmentsPerRow,
3045
3073
  assignmentsCreated: data.assignmentsCreated,
3046
3074
  draft: data.task.status === 'draft',
3047
3075
  ...(data.task.status === 'draft'
3048
3076
  ? {
3049
3077
  message: 'Draft created. Test it manually before assigning.',
3050
- publishCommand: `orizu tasks publish --task ${data.task.id} --assignees <userId1,userId2>`,
3078
+ publishCommand: explicitAssignments
3079
+ ? `orizu tasks publish --task ${data.task.id} --assignment-file ${assignmentFile ? shellQuote(assignmentFile) : '<path>'}`
3080
+ : `orizu tasks publish --task ${data.task.id} --assignees <userId1,userId2>`,
3051
3081
  }
3052
3082
  : {}),
3053
3083
  ...(data.assignmentShortfall !== undefined ? { assignmentShortfall: data.assignmentShortfall } : {}),
@@ -3062,24 +3092,34 @@ async function createTask() {
3062
3092
  `\n Task ID: ${sanitizeTerminalText(data.task.id)}` +
3063
3093
  `\n Dataset ID: ${sanitizeTerminalText(datasetId)}` +
3064
3094
  `\n Version: v${data.task.versionNum} (${sanitizeTerminalText(data.task.versionId)})` +
3095
+ `\n Mode: ${explicitAssignments ? 'custom row map' : 'auto distribute'}` +
3065
3096
  `\n Labels/row: ${data.task.requiredAssignmentsPerRow}` +
3066
3097
  `\n Assignments: ${data.assignmentsCreated}` +
3067
3098
  (data.warning ? `\n Warning: ${sanitizeTerminalText(data.warning)}` : '') +
3068
3099
  `\n URL: ${sanitizeTerminalText(taskUrl)}` +
3069
3100
  (isDraft
3070
- ? `\n\nThis task is a draft. Test it manually before assigning.\nPublish after approval: orizu tasks publish --task ${sanitizeTerminalText(data.task.id)} --assignees <userId1,userId2>`
3101
+ ? `\n\nThis task is a draft. Test it manually before assigning.\nPublish after approval: ${explicitAssignments
3102
+ ? `orizu tasks publish --task ${sanitizeTerminalText(data.task.id)} --assignment-file ${sanitizeTerminalText(assignmentFile || '<path>')}`
3103
+ : `orizu tasks publish --task ${sanitizeTerminalText(data.task.id)} --assignees <userId1,userId2>`}`
3071
3104
  : ''));
3072
3105
  }
3073
3106
  async function publishTask() {
3074
3107
  const taskId = getArg('--task');
3075
3108
  const assignees = parseCommaSeparated(getArg('--assignees'));
3076
- if (!taskId || assignees.length === 0) {
3077
- throw new Error('Usage: orizu tasks publish --task <taskId> --assignees <userId1,userId2> [--json]');
3109
+ const assignmentFile = getArg('--assignment-file');
3110
+ if (assignmentFile && assignees.length > 0) {
3111
+ throw new Error('Use either --assignment-file or --assignees, not both.');
3112
+ }
3113
+ const explicitAssignments = readAssignmentManifest(assignmentFile);
3114
+ if (!taskId || (assignees.length === 0 && !explicitAssignments)) {
3115
+ throw new Error('Usage: orizu tasks publish --task <taskId> (--assignees <userId1,userId2> | --assignment-file <path>) [--json]');
3078
3116
  }
3079
3117
  const response = await authedFetch(`/api/cli/tasks/${encodeURIComponent(taskId)}/publish`, {
3080
3118
  method: 'POST',
3081
3119
  headers: { 'Content-Type': 'application/json' },
3082
- body: JSON.stringify({ memberIds: assignees }),
3120
+ body: JSON.stringify(explicitAssignments
3121
+ ? { explicitAssignments }
3122
+ : { memberIds: assignees }),
3083
3123
  });
3084
3124
  if (!response.ok) {
3085
3125
  throw new Error(`Failed to publish task: ${await extractErrorMessage(response)}`);
@@ -3089,6 +3129,7 @@ async function publishTask() {
3089
3129
  printLine(JSON.stringify({
3090
3130
  taskId: data.task.id,
3091
3131
  status: data.task.status,
3132
+ assignmentMode: explicitAssignments ? 'custom' : 'auto',
3092
3133
  assignmentsCreated: data.assignmentsCreated,
3093
3134
  assignmentsProcessed: data.assignmentsProcessed,
3094
3135
  ...(data.assignmentsRemoved !== undefined
@@ -3102,6 +3143,7 @@ async function publishTask() {
3102
3143
  return;
3103
3144
  }
3104
3145
  printLine(`Published task ${sanitizeTerminalText(data.task.id)} [${sanitizeTerminalText(data.task.status)}]` +
3146
+ `\n Mode: ${explicitAssignments ? 'custom row map' : 'auto distribute'}` +
3105
3147
  `\n Assignments: ${data.assignmentsCreated}` +
3106
3148
  (data.assignmentsRemoved !== undefined
3107
3149
  ? `\n Replaced: ${data.assignmentsRemoved} old assignments removed`
@@ -3111,23 +3153,50 @@ async function publishTask() {
3111
3153
  async function assignTask() {
3112
3154
  const taskId = getArg('--task');
3113
3155
  const assignees = parseCommaSeparated(getArg('--assignees'));
3114
- if (!taskId || assignees.length === 0) {
3115
- throw new Error('Usage: orizu tasks assign --task <taskId> --assignees <userId1,userId2>');
3156
+ const assignmentFile = getArg('--assignment-file');
3157
+ if (assignmentFile && assignees.length > 0) {
3158
+ throw new Error('Use either --assignment-file or --assignees, not both.');
3159
+ }
3160
+ const explicitAssignments = readAssignmentManifest(assignmentFile);
3161
+ if (!taskId || (assignees.length === 0 && !explicitAssignments)) {
3162
+ throw new Error('Usage: orizu tasks assign --task <taskId> (--assignees <userId1,userId2> | --assignment-file <path>) [--replace-existing] [--json]');
3163
+ }
3164
+ const requestBody = explicitAssignments
3165
+ ? { explicitAssignments }
3166
+ : { memberIds: assignees };
3167
+ if (hasArg('--replace-existing')) {
3168
+ requestBody.replaceExisting = true;
3116
3169
  }
3117
3170
  const response = await authedFetch(`/api/cli/tasks/${encodeURIComponent(taskId)}/assign`, {
3118
3171
  method: 'POST',
3119
3172
  headers: { 'Content-Type': 'application/json' },
3120
- body: JSON.stringify({ memberIds: assignees }),
3173
+ body: JSON.stringify(requestBody),
3121
3174
  });
3122
3175
  if (!response.ok) {
3123
3176
  throw new Error(`Failed to assign task: ${await response.text()}`);
3124
3177
  }
3125
3178
  const data = await parseJsonResponse(response, 'Task assign');
3126
3179
  if (hasJsonFlag()) {
3127
- printJson({ assignmentsCreated: data.assignmentsCreated });
3180
+ printJson({
3181
+ assignmentMode: explicitAssignments ? 'custom' : 'auto',
3182
+ assignmentsProcessed: data.assignmentsProcessed ?? data.assignmentsCreated,
3183
+ assignmentsCreated: data.assignmentsCreated,
3184
+ ...(data.assignmentsRemoved !== undefined
3185
+ ? { assignmentsRemoved: data.assignmentsRemoved }
3186
+ : {}),
3187
+ ...(data.assignmentShortfall !== undefined
3188
+ ? { assignmentShortfall: data.assignmentShortfall }
3189
+ : {}),
3190
+ ...(data.warning ? { warning: data.warning } : {}),
3191
+ });
3128
3192
  return;
3129
3193
  }
3130
- printLine(`Created ${data.assignmentsCreated} assignments.`);
3194
+ printLine(`Created ${data.assignmentsCreated} assignments.` +
3195
+ `\n Mode: ${explicitAssignments ? 'custom row map' : 'auto distribute'}` +
3196
+ (data.assignmentsRemoved !== undefined
3197
+ ? `\n Replaced: ${data.assignmentsRemoved} old assignments removed`
3198
+ : '') +
3199
+ (data.warning ? `\n Warning: ${sanitizeTerminalText(data.warning)}` : ''));
3131
3200
  }
3132
3201
  async function taskStatus() {
3133
3202
  const taskId = getArg('--task');
@@ -0,0 +1,59 @@
1
+ function isRecord(value) {
2
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
3
+ }
4
+ export function parseAssignmentManifestJsonl(content) {
5
+ // Keep these JSONL field rules in sync with the server's explicitAssignments parser.
6
+ const entries = [];
7
+ const lines = content.split(/\r?\n/);
8
+ lines.forEach((line, lineIndex) => {
9
+ const trimmed = line.trim();
10
+ if (!trimmed) {
11
+ return;
12
+ }
13
+ let parsed;
14
+ try {
15
+ parsed = JSON.parse(trimmed);
16
+ }
17
+ catch (error) {
18
+ throw new Error(`Assignment manifest line ${lineIndex + 1} is not valid JSON`);
19
+ }
20
+ if (!isRecord(parsed)) {
21
+ throw new Error(`Assignment manifest line ${lineIndex + 1} must be a JSON object`);
22
+ }
23
+ const rowId = typeof parsed.rowId === 'string' ? parsed.rowId.trim() : '';
24
+ if (!rowId) {
25
+ throw new Error(`Assignment manifest line ${lineIndex + 1} must include a non-empty rowId`);
26
+ }
27
+ const hasAssignee = Object.hasOwn(parsed, 'assignee');
28
+ const hasAssignees = Object.hasOwn(parsed, 'assignees');
29
+ if (hasAssignee && hasAssignees) {
30
+ throw new Error(`Assignment manifest line ${lineIndex + 1} must provide either assignee or assignees, not both`);
31
+ }
32
+ if (hasAssignee) {
33
+ const assignee = typeof parsed.assignee === 'string' ? parsed.assignee.trim() : '';
34
+ if (!assignee) {
35
+ throw new Error(`Assignment manifest line ${lineIndex + 1} assignee must be a non-empty string`);
36
+ }
37
+ entries.push({ rowId, assignee });
38
+ return;
39
+ }
40
+ if (hasAssignees) {
41
+ if (!Array.isArray(parsed.assignees) || parsed.assignees.length === 0) {
42
+ throw new Error(`Assignment manifest line ${lineIndex + 1} assignees must be a non-empty string array`);
43
+ }
44
+ parsed.assignees.forEach((assigneeValue, assigneeIndex) => {
45
+ const assignee = typeof assigneeValue === 'string' ? assigneeValue.trim() : '';
46
+ if (!assignee) {
47
+ throw new Error(`Assignment manifest line ${lineIndex + 1} assignees[${assigneeIndex}] must be a non-empty string`);
48
+ }
49
+ entries.push({ rowId, assignee });
50
+ });
51
+ return;
52
+ }
53
+ throw new Error(`Assignment manifest line ${lineIndex + 1} must include assignee or assignees`);
54
+ });
55
+ if (entries.length === 0) {
56
+ throw new Error('Assignment manifest must contain at least one assignment');
57
+ }
58
+ return entries;
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orizu",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
package/src/help.ts CHANGED
@@ -475,20 +475,20 @@ export const COMMAND_DOCS: CliCommandDoc[] = [
475
475
  },
476
476
  {
477
477
  path: ['tasks', 'create'],
478
- usage: 'orizu tasks create --project <team/project> --dataset <datasetId> --app <appId> --title <title> [--assignees <userIdOrEmail1,userIdOrEmail2>] [--publish] [--version <n>] [--instructions <text>] [--labels-per-item <n>] [--json]',
479
- summary: 'Create a draft review task by default, or publish immediately with --publish and assignees.',
478
+ usage: 'orizu tasks create --project <team/project> --dataset <datasetId> --app <appId> --title <title> [--assignees <userIdOrEmail1,userIdOrEmail2> | --assignment-file <path>] [--publish] [--version <n>] [--instructions <text>] [--labels-per-item <n>] [--json]',
479
+ summary: 'Create a draft review task by default, with auto distribution or an explicit row-assignment JSONL file.',
480
480
  group: 'Tasks',
481
481
  },
482
482
  {
483
483
  path: ['tasks', 'publish'],
484
- usage: 'orizu tasks publish --task <taskId> --assignees <userId1,userId2> [--json]',
484
+ usage: 'orizu tasks publish --task <taskId> (--assignees <userId1,userId2> | --assignment-file <path>) [--json]',
485
485
  summary: 'Publish an approved draft task and assign reviewers.',
486
486
  group: 'Tasks',
487
487
  },
488
488
  {
489
489
  path: ['tasks', 'assign'],
490
- usage: 'orizu tasks assign --task <taskId> --assignees <userId1,userId2>',
491
- summary: 'Assign a task to canonical user IDs.',
490
+ usage: 'orizu tasks assign --task <taskId> (--assignees <userId1,userId2> | --assignment-file <path>) [--replace-existing] [--json]',
491
+ summary: 'Assign a task with auto distribution or an explicit row-assignment JSONL file.',
492
492
  group: 'Tasks',
493
493
  },
494
494
  {
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ import { extractErrorMessage } from './error-response.js'
27
27
  import { parseGlobalFlags } from './global-flags.js'
28
28
  import { getCapabilities, renderHelpForArgs, renderRootHelp } from './help.js'
29
29
  import { runLocalAppPreview } from './preview-runtime.js'
30
+ import { parseAssignmentManifestJsonl } from './task-assignment-manifest.js'
30
31
  import {
31
32
  assertSecureTokenTransport,
32
33
  authedFetch,
@@ -3908,43 +3909,77 @@ function parseCommaSeparated(value: string | null): string[] {
3908
3909
  .filter(Boolean)
3909
3910
  }
3910
3911
 
3912
+ function readAssignmentManifest(path: string | null): ReturnType<typeof parseAssignmentManifestJsonl> | null {
3913
+ if (!path) {
3914
+ return null
3915
+ }
3916
+
3917
+ const content = readFileSync(expandHomePath(path), 'utf8')
3918
+ return parseAssignmentManifestJsonl(content)
3919
+ }
3920
+
3911
3921
  async function createTask() {
3912
3922
  const projectSlug = getArg('--project')
3913
3923
  const datasetId = getArg('--dataset')
3914
3924
  const appId = getArg('--app')
3915
3925
  const title = getArg('--title')
3916
3926
  const assignees = parseCommaSeparated(getArg('--assignees'))
3927
+ const assignmentFile = getArg('--assignment-file')
3917
3928
  const publish = hasArg('--publish')
3918
3929
  const versionArg = getArg('--version')
3919
3930
  const instructions = getArg('--instructions')
3920
3931
  const labelsPerItemArg = getArg('--labels-per-item')
3921
- const labelsPerItem = labelsPerItemArg ? Number(labelsPerItemArg) : 1
3932
+ const parsedLabelsPerItem = labelsPerItemArg ? Number(labelsPerItemArg) : 1
3933
+ const labelsPerItem =
3934
+ Number.isInteger(parsedLabelsPerItem) && parsedLabelsPerItem > 0
3935
+ ? parsedLabelsPerItem
3936
+ : null
3922
3937
  const parsedVersionNum = versionArg ? Number(versionArg) : Number.NaN
3923
3938
  const versionNum =
3924
3939
  Number.isInteger(parsedVersionNum) && parsedVersionNum > 0 ? parsedVersionNum : null
3925
3940
 
3926
- if (!projectSlug || !datasetId || !appId || !title || (publish && assignees.length === 0)) {
3927
- throw new Error('Usage: orizu tasks create --project <team/project> --dataset <datasetId> --app <appId> --title <title> [--assignees <userIdOrEmail1,userIdOrEmail2>] [--publish] [--version <n>] [--instructions <text>] [--labels-per-item <n>] [--json]')
3941
+ if (assignmentFile && assignees.length > 0) {
3942
+ throw new Error('Use either --assignment-file or --assignees, not both.')
3943
+ }
3944
+
3945
+ const explicitAssignments = readAssignmentManifest(assignmentFile)
3946
+
3947
+ if (!projectSlug || !datasetId || !appId || !title || (publish && assignees.length === 0 && !explicitAssignments)) {
3948
+ throw new Error('Usage: orizu tasks create --project <team/project> --dataset <datasetId> --app <appId> --title <title> [--assignees <userIdOrEmail1,userIdOrEmail2> | --assignment-file <path>] [--publish] [--version <n>] [--instructions <text>] [--labels-per-item <n>] [--json]')
3928
3949
  }
3929
3950
 
3930
3951
  if (versionArg && versionNum === null) {
3931
3952
  throw new Error('--version must be a positive integer')
3932
3953
  }
3933
3954
 
3955
+ if (labelsPerItemArg && labelsPerItem === null) {
3956
+ throw new Error('--labels-per-item must be a positive integer')
3957
+ }
3958
+
3959
+ const requestBody: Record<string, unknown> = {
3960
+ projectSlug,
3961
+ datasetId,
3962
+ appId,
3963
+ versionNum,
3964
+ title,
3965
+ publish,
3966
+ instructions,
3967
+ }
3968
+
3969
+ if (explicitAssignments) {
3970
+ requestBody.explicitAssignments = explicitAssignments
3971
+ } else {
3972
+ requestBody.memberIds = assignees
3973
+ }
3974
+
3975
+ if (!explicitAssignments || labelsPerItemArg) {
3976
+ requestBody.requiredAssignmentsPerRow = labelsPerItem
3977
+ }
3978
+
3934
3979
  const response = await authedFetch('/api/cli/tasks', {
3935
3980
  method: 'POST',
3936
3981
  headers: { 'Content-Type': 'application/json' },
3937
- body: JSON.stringify({
3938
- projectSlug,
3939
- datasetId,
3940
- appId,
3941
- versionNum,
3942
- title,
3943
- memberIds: assignees,
3944
- publish,
3945
- instructions,
3946
- requiredAssignmentsPerRow: labelsPerItem,
3947
- }),
3982
+ body: JSON.stringify(requestBody),
3948
3983
  })
3949
3984
 
3950
3985
  if (!response.ok) {
@@ -3982,13 +4017,16 @@ async function createTask() {
3982
4017
  taskUrl: `${getBaseUrl()}/d/${projectSlug}/tasks/${data.task.id}`,
3983
4018
  title: data.task.title,
3984
4019
  status: data.task.status,
4020
+ assignmentMode: explicitAssignments ? 'custom' : 'auto',
3985
4021
  requiredAssignmentsPerRow: data.task.requiredAssignmentsPerRow,
3986
4022
  assignmentsCreated: data.assignmentsCreated,
3987
4023
  draft: data.task.status === 'draft',
3988
4024
  ...(data.task.status === 'draft'
3989
4025
  ? {
3990
4026
  message: 'Draft created. Test it manually before assigning.',
3991
- publishCommand: `orizu tasks publish --task ${data.task.id} --assignees <userId1,userId2>`,
4027
+ publishCommand: explicitAssignments
4028
+ ? `orizu tasks publish --task ${data.task.id} --assignment-file ${assignmentFile ? shellQuote(assignmentFile) : '<path>'}`
4029
+ : `orizu tasks publish --task ${data.task.id} --assignees <userId1,userId2>`,
3992
4030
  }
3993
4031
  : {}),
3994
4032
  ...(data.assignmentShortfall !== undefined ? { assignmentShortfall: data.assignmentShortfall } : {}),
@@ -4005,12 +4043,17 @@ async function createTask() {
4005
4043
  `\n Task ID: ${sanitizeTerminalText(data.task.id)}` +
4006
4044
  `\n Dataset ID: ${sanitizeTerminalText(datasetId)}` +
4007
4045
  `\n Version: v${data.task.versionNum} (${sanitizeTerminalText(data.task.versionId)})` +
4046
+ `\n Mode: ${explicitAssignments ? 'custom row map' : 'auto distribute'}` +
4008
4047
  `\n Labels/row: ${data.task.requiredAssignmentsPerRow}` +
4009
4048
  `\n Assignments: ${data.assignmentsCreated}` +
4010
4049
  (data.warning ? `\n Warning: ${sanitizeTerminalText(data.warning)}` : '') +
4011
4050
  `\n URL: ${sanitizeTerminalText(taskUrl)}` +
4012
4051
  (isDraft
4013
- ? `\n\nThis task is a draft. Test it manually before assigning.\nPublish after approval: orizu tasks publish --task ${sanitizeTerminalText(data.task.id)} --assignees <userId1,userId2>`
4052
+ ? `\n\nThis task is a draft. Test it manually before assigning.\nPublish after approval: ${
4053
+ explicitAssignments
4054
+ ? `orizu tasks publish --task ${sanitizeTerminalText(data.task.id)} --assignment-file ${sanitizeTerminalText(assignmentFile || '<path>')}`
4055
+ : `orizu tasks publish --task ${sanitizeTerminalText(data.task.id)} --assignees <userId1,userId2>`
4056
+ }`
4014
4057
  : '')
4015
4058
  )
4016
4059
  }
@@ -4018,15 +4061,26 @@ async function createTask() {
4018
4061
  async function publishTask() {
4019
4062
  const taskId = getArg('--task')
4020
4063
  const assignees = parseCommaSeparated(getArg('--assignees'))
4064
+ const assignmentFile = getArg('--assignment-file')
4065
+
4066
+ if (assignmentFile && assignees.length > 0) {
4067
+ throw new Error('Use either --assignment-file or --assignees, not both.')
4068
+ }
4069
+
4070
+ const explicitAssignments = readAssignmentManifest(assignmentFile)
4021
4071
 
4022
- if (!taskId || assignees.length === 0) {
4023
- throw new Error('Usage: orizu tasks publish --task <taskId> --assignees <userId1,userId2> [--json]')
4072
+ if (!taskId || (assignees.length === 0 && !explicitAssignments)) {
4073
+ throw new Error('Usage: orizu tasks publish --task <taskId> (--assignees <userId1,userId2> | --assignment-file <path>) [--json]')
4024
4074
  }
4025
4075
 
4026
4076
  const response = await authedFetch(`/api/cli/tasks/${encodeURIComponent(taskId)}/publish`, {
4027
4077
  method: 'POST',
4028
4078
  headers: { 'Content-Type': 'application/json' },
4029
- body: JSON.stringify({ memberIds: assignees }),
4079
+ body: JSON.stringify(
4080
+ explicitAssignments
4081
+ ? { explicitAssignments }
4082
+ : { memberIds: assignees }
4083
+ ),
4030
4084
  })
4031
4085
 
4032
4086
  if (!response.ok) {
@@ -4049,6 +4103,7 @@ async function publishTask() {
4049
4103
  printLine(JSON.stringify({
4050
4104
  taskId: data.task.id,
4051
4105
  status: data.task.status,
4106
+ assignmentMode: explicitAssignments ? 'custom' : 'auto',
4052
4107
  assignmentsCreated: data.assignmentsCreated,
4053
4108
  assignmentsProcessed: data.assignmentsProcessed,
4054
4109
  ...(data.assignmentsRemoved !== undefined
@@ -4064,6 +4119,7 @@ async function publishTask() {
4064
4119
 
4065
4120
  printLine(
4066
4121
  `Published task ${sanitizeTerminalText(data.task.id)} [${sanitizeTerminalText(data.task.status)}]` +
4122
+ `\n Mode: ${explicitAssignments ? 'custom row map' : 'auto distribute'}` +
4067
4123
  `\n Assignments: ${data.assignmentsCreated}` +
4068
4124
  (data.assignmentsRemoved !== undefined
4069
4125
  ? `\n Replaced: ${data.assignmentsRemoved} old assignments removed`
@@ -4075,27 +4131,65 @@ async function publishTask() {
4075
4131
  async function assignTask() {
4076
4132
  const taskId = getArg('--task')
4077
4133
  const assignees = parseCommaSeparated(getArg('--assignees'))
4134
+ const assignmentFile = getArg('--assignment-file')
4078
4135
 
4079
- if (!taskId || assignees.length === 0) {
4080
- throw new Error('Usage: orizu tasks assign --task <taskId> --assignees <userId1,userId2>')
4136
+ if (assignmentFile && assignees.length > 0) {
4137
+ throw new Error('Use either --assignment-file or --assignees, not both.')
4138
+ }
4139
+
4140
+ const explicitAssignments = readAssignmentManifest(assignmentFile)
4141
+
4142
+ if (!taskId || (assignees.length === 0 && !explicitAssignments)) {
4143
+ throw new Error('Usage: orizu tasks assign --task <taskId> (--assignees <userId1,userId2> | --assignment-file <path>) [--replace-existing] [--json]')
4144
+ }
4145
+
4146
+ const requestBody: Record<string, unknown> = explicitAssignments
4147
+ ? { explicitAssignments }
4148
+ : { memberIds: assignees }
4149
+ if (hasArg('--replace-existing')) {
4150
+ requestBody.replaceExisting = true
4081
4151
  }
4082
4152
 
4083
4153
  const response = await authedFetch(`/api/cli/tasks/${encodeURIComponent(taskId)}/assign`, {
4084
4154
  method: 'POST',
4085
4155
  headers: { 'Content-Type': 'application/json' },
4086
- body: JSON.stringify({ memberIds: assignees }),
4156
+ body: JSON.stringify(requestBody),
4087
4157
  })
4088
4158
 
4089
4159
  if (!response.ok) {
4090
4160
  throw new Error(`Failed to assign task: ${await response.text()}`)
4091
4161
  }
4092
4162
 
4093
- const data = await parseJsonResponse<{ assignmentsCreated: number }>(response, 'Task assign')
4163
+ const data = await parseJsonResponse<{
4164
+ assignmentsProcessed?: number
4165
+ assignmentsCreated: number
4166
+ assignmentsRemoved?: number
4167
+ assignmentShortfall?: number
4168
+ warning?: string
4169
+ }>(response, 'Task assign')
4094
4170
  if (hasJsonFlag()) {
4095
- printJson({ assignmentsCreated: data.assignmentsCreated })
4171
+ printJson({
4172
+ assignmentMode: explicitAssignments ? 'custom' : 'auto',
4173
+ assignmentsProcessed: data.assignmentsProcessed ?? data.assignmentsCreated,
4174
+ assignmentsCreated: data.assignmentsCreated,
4175
+ ...(data.assignmentsRemoved !== undefined
4176
+ ? { assignmentsRemoved: data.assignmentsRemoved }
4177
+ : {}),
4178
+ ...(data.assignmentShortfall !== undefined
4179
+ ? { assignmentShortfall: data.assignmentShortfall }
4180
+ : {}),
4181
+ ...(data.warning ? { warning: data.warning } : {}),
4182
+ })
4096
4183
  return
4097
4184
  }
4098
- printLine(`Created ${data.assignmentsCreated} assignments.`)
4185
+ printLine(
4186
+ `Created ${data.assignmentsCreated} assignments.` +
4187
+ `\n Mode: ${explicitAssignments ? 'custom row map' : 'auto distribute'}` +
4188
+ (data.assignmentsRemoved !== undefined
4189
+ ? `\n Replaced: ${data.assignmentsRemoved} old assignments removed`
4190
+ : '') +
4191
+ (data.warning ? `\n Warning: ${sanitizeTerminalText(data.warning)}` : '')
4192
+ )
4099
4193
  }
4100
4194
 
4101
4195
  async function taskStatus() {
@@ -0,0 +1,96 @@
1
+ export interface ParsedAssignmentManifestEntry {
2
+ rowId: string
3
+ assignee: string
4
+ }
5
+
6
+ function isRecord(value: unknown): value is Record<string, unknown> {
7
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
8
+ }
9
+
10
+ export function parseAssignmentManifestJsonl(
11
+ content: string
12
+ ): ParsedAssignmentManifestEntry[] {
13
+ // Keep these JSONL field rules in sync with the server's explicitAssignments parser.
14
+ const entries: ParsedAssignmentManifestEntry[] = []
15
+ const lines = content.split(/\r?\n/)
16
+
17
+ lines.forEach((line, lineIndex) => {
18
+ const trimmed = line.trim()
19
+ if (!trimmed) {
20
+ return
21
+ }
22
+
23
+ let parsed: unknown
24
+ try {
25
+ parsed = JSON.parse(trimmed)
26
+ } catch (error) {
27
+ throw new Error(
28
+ `Assignment manifest line ${lineIndex + 1} is not valid JSON`
29
+ )
30
+ }
31
+
32
+ if (!isRecord(parsed)) {
33
+ throw new Error(
34
+ `Assignment manifest line ${lineIndex + 1} must be a JSON object`
35
+ )
36
+ }
37
+
38
+ const rowId = typeof parsed.rowId === 'string' ? parsed.rowId.trim() : ''
39
+ if (!rowId) {
40
+ throw new Error(
41
+ `Assignment manifest line ${lineIndex + 1} must include a non-empty rowId`
42
+ )
43
+ }
44
+
45
+ const hasAssignee = Object.hasOwn(parsed, 'assignee')
46
+ const hasAssignees = Object.hasOwn(parsed, 'assignees')
47
+
48
+ if (hasAssignee && hasAssignees) {
49
+ throw new Error(
50
+ `Assignment manifest line ${lineIndex + 1} must provide either assignee or assignees, not both`
51
+ )
52
+ }
53
+
54
+ if (hasAssignee) {
55
+ const assignee =
56
+ typeof parsed.assignee === 'string' ? parsed.assignee.trim() : ''
57
+ if (!assignee) {
58
+ throw new Error(
59
+ `Assignment manifest line ${lineIndex + 1} assignee must be a non-empty string`
60
+ )
61
+ }
62
+ entries.push({ rowId, assignee })
63
+ return
64
+ }
65
+
66
+ if (hasAssignees) {
67
+ if (!Array.isArray(parsed.assignees) || parsed.assignees.length === 0) {
68
+ throw new Error(
69
+ `Assignment manifest line ${lineIndex + 1} assignees must be a non-empty string array`
70
+ )
71
+ }
72
+
73
+ parsed.assignees.forEach((assigneeValue, assigneeIndex) => {
74
+ const assignee =
75
+ typeof assigneeValue === 'string' ? assigneeValue.trim() : ''
76
+ if (!assignee) {
77
+ throw new Error(
78
+ `Assignment manifest line ${lineIndex + 1} assignees[${assigneeIndex}] must be a non-empty string`
79
+ )
80
+ }
81
+ entries.push({ rowId, assignee })
82
+ })
83
+ return
84
+ }
85
+
86
+ throw new Error(
87
+ `Assignment manifest line ${lineIndex + 1} must include assignee or assignees`
88
+ )
89
+ })
90
+
91
+ if (entries.length === 0) {
92
+ throw new Error('Assignment manifest must contain at least one assignment')
93
+ }
94
+
95
+ return entries
96
+ }
@@ -89,6 +89,8 @@ orizu tasks create --project <teamSlug>/<projectSlug> --dataset <datasetId> --ap
89
89
 
90
90
  # Open the returned task URL and test the draft manually before assigning.
91
91
  orizu tasks publish --task <taskId> --assignees <userId1,userId2>
92
+ # Or publish exact row assignments:
93
+ orizu tasks publish --task <taskId> --assignment-file ./assignments.jsonl
92
94
 
93
95
  orizu tasks status --task <taskId>
94
96
  orizu tasks report set --task <taskId> --report-file ./task-report.md
@@ -175,9 +177,9 @@ Detailed walkthrough — GEPA mechanics, Orizu-tracked optimization, optional DS
175
177
  # Execution rules
176
178
 
177
179
  - Prefer explicit flags in non-TTY contexts; reserve interactive fallback for TTY.
178
- - Canonical selectors: `--team`, `--project`, `--app`, `--task`, `--dataset`, `--assignees`.
179
- - `tasks create` creates drafts by default. Test the returned task URL manually, then use `tasks publish --task <taskId> --assignees <userId1,userId2>` after approval.
180
- - `tasks assign` and `tasks publish` take user IDs, not emails.
180
+ - Canonical selectors: `--team`, `--project`, `--app`, `--task`, `--dataset`, `--assignees`, `--assignment-file`.
181
+ - `tasks create` creates drafts by default. Test the returned task URL manually, then use `tasks publish --task <taskId> --assignees <userId1,userId2>` or `tasks publish --task <taskId> --assignment-file <path>` after approval.
182
+ - `tasks assign --assignees` and `tasks publish --assignees` take user IDs, not emails. `--assignment-file` accepts emails or user IDs in JSONL rows shaped as `{ "rowId": "...", "assignee": "..." }` or `{ "rowId": "...", "assignees": ["..."] }`.
181
183
  - `datasets edit-rows` requires a non-empty string `id` on each row in `--file`.
182
184
  - `datasets delete-rows` uses `--row-ids` as the canonical selector.
183
185
  - `datasets delete` requires interactive terminal confirmation; there is no non-interactive confirmation option.
@@ -186,23 +186,28 @@ orizu tasks create \
186
186
  Behavior:
187
187
  - task creation creates a draft by default and returns a task URL to test manually before assigning
188
188
  - use `--publish --assignees <userIdOrEmail1,userIdOrEmail2>` only when you intentionally want to create and ship immediately
189
+ - use `--assignment-file <path>` instead of `--assignees` when specific rows should go to specific labellers
189
190
  - after manually approving a draft, run `orizu tasks publish --task <taskId> --assignees <userId1,userId2>`
191
+ - or run `orizu tasks publish --task <taskId> --assignment-file <path>` to publish the exact row map
190
192
  - task creation resolves and stores the app's pinned current `version_id` at create time
191
193
  - downstream consumers (exports, judges, optimization) should trust the task's pinned `version_id`, not the app's current pointer
192
194
  - dataset compatibility is validated against that pinned app version before the task is inserted
193
195
  - malformed JSON and mixed-type assignee arrays fail with deterministic `400` responses
194
196
  - assignment fanout enforces unique `(assignee, row)` pairs; `--labels-per-item` cannot exceed the number of unique assignees, and the backend shortfalls instead of duplicating
197
+ - explicit assignment manifests are JSONL with `rowId` plus `assignee` or `assignees`; row IDs are canonical dataset row `id` values, assignees may be emails or user IDs, and V1 publish requires whole-dataset uniform coverage
195
198
 
196
199
  Publish:
197
200
 
198
201
  ```bash
199
202
  orizu tasks publish --task <taskId> --assignees <userId1,userId2>
203
+ orizu tasks publish --task <taskId> --assignment-file ./assignments.jsonl
200
204
  ```
201
205
 
202
206
  Assign:
203
207
 
204
208
  ```bash
205
209
  orizu tasks assign --task <taskId> --assignees <userId1,userId2>
210
+ orizu tasks assign --task <taskId> --assignment-file ./assignments.jsonl --replace-existing
206
211
  ```
207
212
 
208
213
  Status:
@@ -301,6 +306,8 @@ orizu tasks create \
301
306
 
302
307
  # Open the returned task URL and test the draft manually before assigning.
303
308
  orizu tasks publish --task <taskId> --assignees <userId1,userId2>
309
+ # Or publish a deterministic row map:
310
+ orizu tasks publish --task <taskId> --assignment-file ./assignments.jsonl
304
311
 
305
312
  orizu tasks status --task <taskId>
306
313
  orizu tasks export --task <taskId> --format csv --out ./support-round1.csv
@@ -321,7 +328,8 @@ Use these shortcuts only in TTY environments where prompts can run.
321
328
 
322
329
  - `tasks create` creates a draft by default, does not require `--assignees`, and pins the app's current version when the task is created.
323
330
  - `tasks create --publish --assignees <...>` intentionally creates and ships immediately.
324
- - `tasks assign` and `tasks publish` expect user IDs, not emails.
331
+ - `tasks create|publish|assign --assignment-file <path>` is mutually exclusive with `--assignees` and accepts emails or user IDs in the JSONL manifest.
332
+ - `tasks assign --assignees` and `tasks publish --assignees` expect user IDs, not emails.
325
333
  - Assignment queue reads are assignee-self-only; use task status/export as the operator summary path.
326
334
  - Assignment completion payloads are validated against the pinned app-version `output_json_schema`.
327
335
  - `datasets delete-rows` requires `--row-ids`.