@skillcdn/console 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/README.md +4 -4
  2. package/dist/api.d.ts +4 -4
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +4 -4
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts +4 -0
  7. package/dist/cli/cli.d.ts.map +1 -1
  8. package/dist/cli/cli.js +201 -25
  9. package/dist/cli/cli.js.map +1 -1
  10. package/dist/cli/format.d.ts.map +1 -1
  11. package/dist/cli/format.js +15 -11
  12. package/dist/cli/format.js.map +1 -1
  13. package/dist/cli/help.d.ts.map +1 -1
  14. package/dist/cli/help.js +59 -26
  15. package/dist/cli/help.js.map +1 -1
  16. package/dist/cli/main.js +1 -0
  17. package/dist/cli/main.js.map +1 -1
  18. package/dist/cli/pack.d.ts +7 -2
  19. package/dist/cli/pack.d.ts.map +1 -1
  20. package/dist/cli/pack.js +16 -2
  21. package/dist/cli/pack.js.map +1 -1
  22. package/dist/client.d.ts +20 -10
  23. package/dist/client.d.ts.map +1 -1
  24. package/dist/client.js +17 -3
  25. package/dist/client.js.map +1 -1
  26. package/dist/compat.d.ts +28 -6
  27. package/dist/compat.d.ts.map +1 -1
  28. package/dist/compat.js +55 -14
  29. package/dist/compat.js.map +1 -1
  30. package/dist/components/board.d.ts +12 -1
  31. package/dist/components/board.d.ts.map +1 -1
  32. package/dist/components/board.js +57 -7
  33. package/dist/components/board.js.map +1 -1
  34. package/dist/components/calendar.d.ts +17 -7
  35. package/dist/components/calendar.d.ts.map +1 -1
  36. package/dist/components/calendar.js +89 -38
  37. package/dist/components/calendar.js.map +1 -1
  38. package/dist/components/decision-form.d.ts +12 -4
  39. package/dist/components/decision-form.d.ts.map +1 -1
  40. package/dist/components/decision-form.js +24 -6
  41. package/dist/components/decision-form.js.map +1 -1
  42. package/dist/components/decision-list.d.ts +4 -0
  43. package/dist/components/decision-list.d.ts.map +1 -1
  44. package/dist/components/decision-list.js +9 -6
  45. package/dist/components/decision-list.js.map +1 -1
  46. package/dist/components/documents.d.ts.map +1 -1
  47. package/dist/components/documents.js +3 -3
  48. package/dist/components/documents.js.map +1 -1
  49. package/dist/components/event-feed.d.ts.map +1 -1
  50. package/dist/components/event-feed.js +63 -8
  51. package/dist/components/event-feed.js.map +1 -1
  52. package/dist/components/my-decisions.d.ts +20 -0
  53. package/dist/components/my-decisions.d.ts.map +1 -0
  54. package/dist/components/my-decisions.js +36 -0
  55. package/dist/components/my-decisions.js.map +1 -0
  56. package/dist/components/people.d.ts.map +1 -1
  57. package/dist/components/people.js +1 -1
  58. package/dist/components/people.js.map +1 -1
  59. package/dist/components/picker.d.ts +39 -0
  60. package/dist/components/picker.d.ts.map +1 -0
  61. package/dist/components/picker.js +155 -0
  62. package/dist/components/picker.js.map +1 -0
  63. package/dist/components/projects.d.ts.map +1 -1
  64. package/dist/components/projects.js +3 -3
  65. package/dist/components/projects.js.map +1 -1
  66. package/dist/components/roadmap.js +1 -1
  67. package/dist/components/roadmap.js.map +1 -1
  68. package/dist/components/runs.d.ts.map +1 -1
  69. package/dist/components/runs.js +1 -1
  70. package/dist/components/runs.js.map +1 -1
  71. package/dist/components/shell.d.ts.map +1 -1
  72. package/dist/components/shell.js +36 -1
  73. package/dist/components/shell.js.map +1 -1
  74. package/dist/components/task-form.d.ts +18 -4
  75. package/dist/components/task-form.d.ts.map +1 -1
  76. package/dist/components/task-form.js +56 -13
  77. package/dist/components/task-form.js.map +1 -1
  78. package/dist/components/task-list.js +2 -2
  79. package/dist/components/task-list.js.map +1 -1
  80. package/dist/components/task-view.d.ts +7 -1
  81. package/dist/components/task-view.d.ts.map +1 -1
  82. package/dist/components/task-view.js +7 -6
  83. package/dist/components/task-view.js.map +1 -1
  84. package/dist/components/ui.d.ts +17 -0
  85. package/dist/components/ui.d.ts.map +1 -1
  86. package/dist/components/ui.js +26 -11
  87. package/dist/components/ui.js.map +1 -1
  88. package/dist/components/work.d.ts.map +1 -1
  89. package/dist/components/work.js +5 -3
  90. package/dist/components/work.js.map +1 -1
  91. package/dist/console.css +1794 -433
  92. package/dist/console.d.ts.map +1 -1
  93. package/dist/console.js +93 -10
  94. package/dist/console.js.map +1 -1
  95. package/dist/data.d.ts +21 -1
  96. package/dist/data.d.ts.map +1 -1
  97. package/dist/data.js +24 -1
  98. package/dist/data.js.map +1 -1
  99. package/dist/i18n/en.d.ts +45 -3
  100. package/dist/i18n/en.d.ts.map +1 -1
  101. package/dist/i18n/en.js +52 -10
  102. package/dist/i18n/en.js.map +1 -1
  103. package/dist/i18n/ko.d.ts.map +1 -1
  104. package/dist/i18n/ko.js +54 -12
  105. package/dist/i18n/ko.js.map +1 -1
  106. package/dist/index.d.ts +5 -3
  107. package/dist/index.d.ts.map +1 -1
  108. package/dist/index.js +5 -3
  109. package/dist/index.js.map +1 -1
  110. package/dist/limits.d.ts +2 -0
  111. package/dist/limits.d.ts.map +1 -1
  112. package/dist/limits.js +2 -0
  113. package/dist/limits.js.map +1 -1
  114. package/dist/router.d.ts +9 -1
  115. package/dist/router.d.ts.map +1 -1
  116. package/dist/router.js +12 -0
  117. package/dist/router.js.map +1 -1
  118. package/dist/routes.d.ts +3 -1
  119. package/dist/routes.d.ts.map +1 -1
  120. package/dist/routes.js +3 -1
  121. package/dist/routes.js.map +1 -1
  122. package/dist/rules.js +1 -1
  123. package/dist/rules.js.map +1 -1
  124. package/dist/schemas.d.ts +252 -12
  125. package/dist/schemas.d.ts.map +1 -1
  126. package/dist/schemas.js +27 -10
  127. package/dist/schemas.js.map +1 -1
  128. package/dist/vocabulary.d.ts +2 -0
  129. package/dist/vocabulary.d.ts.map +1 -1
  130. package/dist/vocabulary.js +2 -0
  131. package/dist/vocabulary.js.map +1 -1
  132. package/docs/specs/cli.md +14 -13
  133. package/docs/specs/rest.md +24 -19
  134. package/package.json +2 -2
  135. package/skills/building-a-console/SKILL.md +4 -4
  136. package/skills/building-a-console/references/the-default-console.md +4 -4
  137. package/skills/building-a-console/references/the-look-from-a-website.md +5 -5
  138. package/skills/deploying-a-console/SKILL.md +2 -2
  139. package/skills/deploying-a-console/references/resources.md +1 -1
  140. package/skills/working-the-board/SKILL.md +8 -6
  141. package/skills/working-the-board/references/terms.md +3 -3
  142. package/src/api.ts +7 -0
  143. package/src/cli/cli.ts +254 -25
  144. package/src/cli/format.ts +22 -11
  145. package/src/cli/help.ts +63 -26
  146. package/src/cli/main.ts +1 -0
  147. package/src/cli/pack.ts +17 -1
  148. package/src/client.ts +44 -11
  149. package/src/compat.ts +63 -14
  150. package/src/components/board.tsx +124 -25
  151. package/src/components/calendar.tsx +258 -117
  152. package/src/components/decision-form.tsx +71 -23
  153. package/src/components/decision-list.tsx +30 -3
  154. package/src/components/documents.tsx +27 -11
  155. package/src/components/event-feed.tsx +116 -33
  156. package/src/components/my-decisions.tsx +138 -0
  157. package/src/components/people.tsx +3 -1
  158. package/src/components/picker.tsx +297 -0
  159. package/src/components/projects.tsx +7 -5
  160. package/src/components/roadmap.tsx +1 -1
  161. package/src/components/runs.tsx +5 -1
  162. package/src/components/shell.tsx +36 -1
  163. package/src/components/task-form.tsx +149 -63
  164. package/src/components/task-list.tsx +3 -3
  165. package/src/components/task-view.tsx +71 -56
  166. package/src/components/ui.tsx +89 -11
  167. package/src/components/work.tsx +6 -4
  168. package/src/console.tsx +157 -29
  169. package/src/data.ts +61 -1
  170. package/src/i18n/en.ts +54 -10
  171. package/src/i18n/ko.ts +56 -12
  172. package/src/index.ts +19 -1
  173. package/src/limits.ts +2 -0
  174. package/src/router.ts +18 -1
  175. package/src/routes.ts +3 -1
  176. package/src/rules.ts +1 -1
  177. package/src/schemas.ts +29 -9
  178. package/src/styles/console.css +1794 -433
  179. package/src/vocabulary.ts +3 -0
package/src/cli/cli.ts CHANGED
@@ -7,8 +7,18 @@ import {
7
7
  type FetchLike,
8
8
  type ProjectClient,
9
9
  } from "../client.js";
10
- import { CLIENT_RANGE_HEADER, judgeClient, parseVersion, VERSION_HEADER } from "../compat.js";
11
- import { addDays, dayOf as dayOfDate, isDay } from "../dates.js";
10
+ import {
11
+ CLIENT_RANGE_HEADER,
12
+ CONSOLE_AT_LEAST,
13
+ consoleRange,
14
+ formatRange,
15
+ judgeClient,
16
+ judgeConsole,
17
+ parseVersion,
18
+ VERSION_HEADER,
19
+ formatVersion as versionText,
20
+ } from "../compat.js";
21
+ import { addDays, dayOf as dayOfDate, firstDayOfWeek, isDay, weekOf } from "../dates.js";
12
22
  import { DEFAULT_PAGE_LIMIT, LIST_LIMIT, MAX_OPTIONS, MIN_OPTIONS } from "../limits.js";
13
23
  import { fillRules, RULES_PATH, RULES_TITLE } from "../rules.js";
14
24
  import {
@@ -168,6 +178,10 @@ export interface CliIo {
168
178
  readonly install?: Install | undefined;
169
179
  /** What the command last said of the versions; left out, it says it every time. */
170
180
  readonly notices?: NoticeStore | undefined;
181
+ /** The oldest console this command works with (ADR-0038); left out, the package's own floor. */
182
+ readonly consoleAtLeast?: string | undefined;
183
+ /** The language and region of this machine (`ko-KR`), for what follows a language where the person chose none; left out, English. */
184
+ readonly locale?: string | undefined;
171
185
  /** Where the console's own skills are on this machine, shipped with the package; nothing when they are not there. */
172
186
  readonly skillsDir?: string | undefined;
173
187
  /** How `serve` listens on the loopback (ADR-0014); left out, the command cannot serve. */
@@ -889,11 +903,15 @@ const whoami: Command = async (args, io) => {
889
903
  command === undefined || server === undefined
890
904
  ? undefined
891
905
  : judgeClient(command, server).verdict;
906
+ // The oldest console this command works with (ADR-0038), as it stands within the command's line.
907
+ const atLeast = io.consoleAtLeast ?? CONSOLE_AT_LEAST;
908
+ const floor =
909
+ command === undefined ? atLeast : versionText(consoleRange(command, atLeast).atLeast);
892
910
  // A console from before the rule says nothing of itself; the command says so instead.
893
911
  const consoleWords =
894
912
  me.console === undefined
895
913
  ? "the console does not say its version, so it is older than 0.1.18"
896
- : `the console is ${me.console.version} and takes ${me.console.clientRange}`;
914
+ : `the console is ${me.console.version} and takes ${me.console.clientRange}; this command works with a console from ${floor} on`;
897
915
  answer(
898
916
  io,
899
917
  session,
@@ -975,6 +993,16 @@ const use: Command = async (args, io) => {
975
993
  return EXIT.ok;
976
994
  };
977
995
 
996
+ /**
997
+ * The last day of this week (ADR-0039): the week begins as the workspace says, or, left to the
998
+ * language, as the person's language has it, else this machine's.
999
+ */
1000
+ async function endOfWeek(io: CliIo, session: Session, today: string): Promise<string> {
1001
+ const me = await session.client.me();
1002
+ const language = me.language ?? io.locale ?? "en";
1003
+ return weekOf(today, firstDayOfWeek(language, me.workspace.weekStart ?? "auto")).at(-1) ?? today;
1004
+ }
1005
+
978
1006
  const tasks: Command = async (args, io) => {
979
1007
  const { values } = parse(
980
1008
  args,
@@ -986,6 +1014,7 @@ const tasks: Command = async (args, io) => {
986
1014
  category: { type: "string" },
987
1015
  goal: { type: "string" },
988
1016
  due: { type: "string" },
1017
+ search: { type: "string" },
989
1018
  archived: { type: "boolean" },
990
1019
  ...PAGE_OPTIONS,
991
1020
  },
@@ -994,7 +1023,7 @@ const tasks: Command = async (args, io) => {
994
1023
  const state = stateOf(text(values, "state"));
995
1024
  if (on(values, "mine") && on(values, "unassigned")) {
996
1025
  throw misuse(
997
- "Say one: --mine for the tasks assigned to you, or --unassigned for those assigned to nobody",
1026
+ "Say one: --mine for the tasks you work on, or --unassigned for those nobody works on",
998
1027
  );
999
1028
  }
1000
1029
  const page = pageOf(values);
@@ -1009,19 +1038,22 @@ const tasks: Command = async (args, io) => {
1009
1038
  : due === "overdue"
1010
1039
  ? addDays(today, -1)
1011
1040
  : due === "week"
1012
- ? addDays(today, 6)
1041
+ ? await endOfWeek(io, session, today)
1013
1042
  : (dayOf(io, due) ?? undefined);
1014
- const assignee = on(values, "mine")
1015
- ? ((await personIdOf(session, "me")) ?? undefined)
1016
- : undefined;
1043
+ const worker = on(values, "mine") ? ((await personIdOf(session, "me")) ?? undefined) : undefined;
1044
+ const search = text(values, "search")?.trim();
1045
+ if (search === "") {
1046
+ throw misuse("Say what to search for: console tasks --search <words>");
1047
+ }
1017
1048
  const found = await project.tasks({
1018
1049
  ...(state === undefined ? {} : { state }),
1019
1050
  ...(on(values, "open") || due === "overdue" ? { open: true } : {}),
1020
- ...(assignee === undefined ? {} : { assignee }),
1051
+ ...(worker === undefined ? {} : { worker }),
1021
1052
  ...(on(values, "unassigned") ? { unassigned: true } : {}),
1022
1053
  ...(text(values, "category") === undefined ? {} : { category: text(values, "category") }),
1023
1054
  ...(text(values, "goal") === undefined ? {} : { goal: text(values, "goal") }),
1024
1055
  ...(dueBy === undefined ? {} : { dueBy }),
1056
+ ...(search === undefined ? {} : { q: search }),
1025
1057
  ...(on(values, "archived") ? { archived: true } : {}),
1026
1058
  ...page,
1027
1059
  });
@@ -1140,8 +1172,37 @@ const skills: Command = async (args, io) => {
1140
1172
  return EXIT.ok;
1141
1173
  };
1142
1174
 
1175
+ /** The most characters one read of a file asks for: what the console's API allows on one page. */
1176
+ const MAX_FILE_LIMIT = 100_000;
1177
+
1178
+ /** A count given as a flag, within its bounds, or nothing; anything else is not understood. */
1179
+ function countOf(
1180
+ given: string | undefined,
1181
+ name: string,
1182
+ bounds: { readonly least: number; readonly most?: number },
1183
+ ): number | undefined {
1184
+ if (given === undefined) {
1185
+ return undefined;
1186
+ }
1187
+ const count = Number(given);
1188
+ if (
1189
+ !Number.isInteger(count) ||
1190
+ count < bounds.least ||
1191
+ (bounds.most !== undefined && count > bounds.most)
1192
+ ) {
1193
+ const range =
1194
+ bounds.most === undefined ? `${bounds.least} or more` : `${bounds.least} to ${bounds.most}`;
1195
+ throw misuse(`Not a number for --${name}, ${range}: ${given}`);
1196
+ }
1197
+ return count;
1198
+ }
1199
+
1143
1200
  const skill: Command = async (args, io) => {
1144
- const { values, positionals } = parse(args, { file: { type: "string" } });
1201
+ const { values, positionals } = parse(args, {
1202
+ file: { type: "string" },
1203
+ offset: { type: "string" },
1204
+ limit: { type: "string" },
1205
+ });
1145
1206
  const file = text(values, "file");
1146
1207
  // A file is read by its own path from the repository's root; a skill's path beside it would be
1147
1208
  // ignored, which reads as if it mattered, so the two together are not understood.
@@ -1150,6 +1211,17 @@ const skill: Command = async (args, io) => {
1150
1211
  "Say one: console skill <path of its SKILL.md> for a skill, or console skill --file <path> for a file of the pack.",
1151
1212
  );
1152
1213
  }
1214
+ // A section is a page of a file read as asked (SkillCDN 0.4): the flags mean nothing without one.
1215
+ if (
1216
+ file === undefined &&
1217
+ (text(values, "offset") !== undefined || text(values, "limit") !== undefined)
1218
+ ) {
1219
+ throw misuse(
1220
+ "--offset and --limit go with --file: console skill --file <path> [--offset <n>] [--limit <n>]",
1221
+ );
1222
+ }
1223
+ const offset = countOf(text(values, "offset"), "offset", { least: 0 });
1224
+ const limit = countOf(text(values, "limit"), "limit", { least: 1, most: MAX_FILE_LIMIT });
1153
1225
  const session = await open(io, values);
1154
1226
  const project = await session.project();
1155
1227
  const { listing, summary } = await packOf(project);
@@ -1157,6 +1229,16 @@ const skill: Command = async (args, io) => {
1157
1229
  answer(io, session, listing, () => formatSkills(listing, io.skillsDir));
1158
1230
  return EXIT.ok;
1159
1231
  }
1232
+ if (file !== undefined && (offset !== undefined || limit !== undefined)) {
1233
+ // One page as asked, the section a search or an outline named; the result says where the
1234
+ // file continues, and asks for nothing.
1235
+ const page = await project.skillFile(file, { offset, limit });
1236
+ const section = page.read === null ? undefined : joinFile([page], summary, limit);
1237
+ answer(io, session, { ...page, read: section === undefined ? null : bodyOfFile(section) }, () =>
1238
+ formatSkillFile(page, section),
1239
+ );
1240
+ return EXIT.ok;
1241
+ }
1160
1242
  if (file !== undefined) {
1161
1243
  // The file whole, page after page, as the console reads it from the pack.
1162
1244
  const pages: RestSkillFile[] = [];
@@ -1335,10 +1417,42 @@ const TASK_FIELDS: OptionSpec = {
1335
1417
  goal: { type: "string" },
1336
1418
  starts: { type: "string" },
1337
1419
  due: { type: "string" },
1338
- assignee: { type: "string" },
1420
+ worker: { type: "string", multiple: true },
1339
1421
  parent: { type: "string" },
1340
1422
  };
1341
1423
 
1424
+ /**
1425
+ * The people `--worker` names, each a login or `me`, given again or with commas between for
1426
+ * several, in place of those there were; `none` alone for nobody; nothing when the flag was
1427
+ * not given.
1428
+ */
1429
+ async function workerIdsOf(
1430
+ session: Session,
1431
+ given: readonly string[],
1432
+ ): Promise<string[] | undefined> {
1433
+ if (given.length === 0) {
1434
+ return undefined;
1435
+ }
1436
+ const names = given
1437
+ .flatMap((value) => value.split(","))
1438
+ .map((name) => name.trim())
1439
+ .filter((name) => name.length > 0);
1440
+ if (names.includes("none")) {
1441
+ if (names.length > 1) {
1442
+ throw misuse("Say --worker none alone, for nobody; or the logins of the workers.");
1443
+ }
1444
+ return [];
1445
+ }
1446
+ const ids: string[] = [];
1447
+ for (const name of names) {
1448
+ const id = await personIdOf(session, name);
1449
+ if (typeof id === "string" && !ids.includes(id)) {
1450
+ ids.push(id);
1451
+ }
1452
+ }
1453
+ return ids;
1454
+ }
1455
+
1342
1456
  /** What the flags say of a task's fields, as the API takes them; `none` clears a field. */
1343
1457
  async function taskFieldsOf(
1344
1458
  io: CliIo,
@@ -1352,7 +1466,7 @@ async function taskFieldsOf(
1352
1466
  const goalId = orNone(text(values, "goal"));
1353
1467
  const startsOn = dayOf(io, text(values, "starts"), true);
1354
1468
  const dueOn = dayOf(io, text(values, "due"), true);
1355
- const assigneeId = await personIdOf(session, text(values, "assignee"));
1469
+ const workerIds = await workerIdsOf(session, texts(values, "worker"));
1356
1470
  const parent = text(values, "parent");
1357
1471
  const parentId =
1358
1472
  parent === undefined ? undefined : parent === "none" ? null : (await project.task(parent)).id;
@@ -1363,7 +1477,7 @@ async function taskFieldsOf(
1363
1477
  ...(goalId === undefined ? {} : { goalId }),
1364
1478
  ...(startsOn === undefined ? {} : { startsOn }),
1365
1479
  ...(dueOn === undefined ? {} : { dueOn }),
1366
- ...(assigneeId === undefined ? {} : { assigneeId }),
1480
+ ...(workerIds === undefined ? {} : { workerIds }),
1367
1481
  ...(parentId === undefined ? {} : { parentId }),
1368
1482
  };
1369
1483
  }
@@ -1415,7 +1529,7 @@ const editTask: Command = async (args, io) => {
1415
1529
  };
1416
1530
  if (Object.keys(patch).length === 0) {
1417
1531
  throw misuse(
1418
- "Say what to change: --title, --body, --state, --priority, --category, --goal, --starts, --due, --assignee or --parent; none clears a field.",
1532
+ "Say what to change: --title, --body, --state, --priority, --category, --goal, --starts, --due, --worker or --parent; none clears a field.",
1419
1533
  );
1420
1534
  }
1421
1535
  const found = await project.task(ref);
@@ -1736,11 +1850,16 @@ const ask: Command = async (args, io) => {
1736
1850
  file: { type: "string" },
1737
1851
  run: { type: "string" },
1738
1852
  wait: { type: "string" },
1853
+ to: { type: "string" },
1739
1854
  });
1740
1855
  const question = theOne(
1741
1856
  positionals,
1742
1857
  'Say the question: console ask "<question>" --option "<label>" --option "<label>"',
1743
1858
  );
1859
+ const to = text(values, "to");
1860
+ if (to === "none") {
1861
+ throw misuse("Say whom the decision is for: --to <login|me>.");
1862
+ }
1744
1863
  const options = texts(values, "option");
1745
1864
  if (options.length < MIN_OPTIONS || options.length > MAX_OPTIONS) {
1746
1865
  throw misuse(`Give ${MIN_OPTIONS} to ${MAX_OPTIONS} options, one --option each.`);
@@ -1750,21 +1869,58 @@ const ask: Command = async (args, io) => {
1750
1869
  const session = await open(io, values);
1751
1870
  const project = await session.project();
1752
1871
  const runId = await currentRun(project, values);
1872
+ // Whom the decision is for (ADR-0042): the person the agent works for, unless --to names another.
1873
+ const deciderId = await personIdOf(session, to);
1753
1874
  const raised = await project.raiseDecision({
1754
1875
  question,
1755
1876
  options: options.map((label) => ({ label })),
1756
1877
  runId,
1757
1878
  ...(body === undefined ? {} : { body }),
1879
+ ...(typeof deciderId === "string" ? { deciderId } : {}),
1758
1880
  });
1759
1881
  // The id is printed before any waiting, so that a wait cut short loses nothing.
1760
1882
  io.stdout(
1761
1883
  session.json
1762
1884
  ? `${JSON.stringify(raised, null, 2)}\n`
1763
- : `Decision ${raised.id} raised on run ${runId}; the run waits for a person.\ndecision id: ${raised.id}\n${formatOptions(raised)}\n`,
1885
+ : `Decision ${raised.id} raised on run ${runId}; the run waits for ${raised.decider.login}.\ndecision id: ${raised.id}\n${formatOptions(raised)}\n`,
1764
1886
  );
1765
1887
  return settle(io, session, project, raised, seconds, true);
1766
1888
  };
1767
1889
 
1890
+ /**
1891
+ * Writes a person's answer on a decision, in their name through the agent (ADR-0021): the
1892
+ * option they chose, or their own words, and a note about it, so that a choice made in chat
1893
+ * or in passing reaches the record the run waits on.
1894
+ */
1895
+ const answering: Command = async (args, io) => {
1896
+ const { values, positionals } = parse(args, {
1897
+ option: { type: "string" },
1898
+ own: { type: "string" },
1899
+ note: { type: "string" },
1900
+ file: { type: "string" },
1901
+ });
1902
+ const id = theOne(
1903
+ positionals,
1904
+ 'Say which decision: console answer <id> --option <n>|--own "<answer>" [--note <markdown>|--file <path>]',
1905
+ );
1906
+ const option = text(values, "option");
1907
+ const own = text(values, "own");
1908
+ if ((option === undefined) === (own === undefined)) {
1909
+ throw misuse(
1910
+ 'Say the option chosen, --option <n>, or the person\'s own answer, --own "<words>": one of the two.',
1911
+ );
1912
+ }
1913
+ const note = await bodyOf(io, values, text(values, "note"));
1914
+ const session = await open(io, values);
1915
+ const project = await session.project();
1916
+ const answered = await project.answerDecision(id, {
1917
+ ...(own === undefined ? { option } : { own }),
1918
+ ...(note === undefined ? {} : { note }),
1919
+ });
1920
+ answer(io, session, answered, () => formatDecision(answered));
1921
+ return EXIT.ok;
1922
+ };
1923
+
1768
1924
  const decision: Command = async (args, io) => {
1769
1925
  const { values, positionals } = parse(args, {
1770
1926
  wait: { type: "string" },
@@ -1956,23 +2112,36 @@ const restore: Command = async (args, io) => {
1956
2112
  const goals: Command = async (args, io) => {
1957
2113
  const { values } = parse(
1958
2114
  args,
1959
- { category: { type: "string" }, state: { type: "string" } },
2115
+ {
2116
+ category: { type: "string" },
2117
+ state: { type: "string" },
2118
+ search: { type: "string" },
2119
+ ...PAGE_OPTIONS,
2120
+ },
1960
2121
  false,
1961
2122
  );
1962
2123
  const state = goalStateOf(text(values, "state"));
1963
2124
  const category = text(values, "category");
2125
+ const search = text(values, "search")?.trim();
2126
+ if (search === "") {
2127
+ throw misuse("Say what to search for: console goals --search <words>");
2128
+ }
2129
+ const page = pageOf(values);
1964
2130
  const session = await open(io, values);
1965
2131
  const project = await session.project();
1966
2132
  const found = await project.goals({
1967
2133
  ...(state === undefined ? {} : { state }),
1968
2134
  ...(category === undefined ? {} : { category }),
2135
+ ...(search === undefined ? {} : { q: search }),
2136
+ ...page,
1969
2137
  });
1970
2138
  answer(io, session, found, () =>
1971
2139
  found.items.length === 0
1972
- ? state === undefined && category === undefined
2140
+ ? state === undefined && category === undefined && search === undefined
1973
2141
  ? "No goals. An owner sets one: console goal new <title> --due <day>"
1974
2142
  : "No goal matches."
1975
- : found.items.map(formatGoalLine).join("\n"),
2143
+ : found.items.map(formatGoalLine).join("\n") +
2144
+ formatMore(found.more, page.limit, page.offset),
1976
2145
  );
1977
2146
  return EXIT.ok;
1978
2147
  };
@@ -2062,14 +2231,37 @@ const goal: Command = async (args, io) => {
2062
2231
  };
2063
2232
 
2064
2233
  const decisions: Command = async (args, io) => {
2065
- const { values } = parse(args, { open: { type: "boolean" }, ...PAGE_OPTIONS }, false);
2234
+ const { values } = parse(
2235
+ args,
2236
+ {
2237
+ open: { type: "boolean" },
2238
+ mine: { type: "boolean" },
2239
+ raised: { type: "boolean" },
2240
+ ...PAGE_OPTIONS,
2241
+ },
2242
+ false,
2243
+ );
2066
2244
  const page = pageOf(values);
2067
2245
  const session = await open(io, values);
2246
+ const open_ = on(values, "open") ? { open: true } : {};
2247
+ if (on(values, "mine") || on(values, "raised")) {
2248
+ // One's own across the projects (ADR-0042), addressed to the person or raised by them for
2249
+ // someone else: the waiting first, each with its project's key.
2250
+ const raised = on(values, "raised");
2251
+ const found = await session.client.myDecisions({ ...open_, raised, ...page });
2252
+ answer(io, session, found, () =>
2253
+ found.items.length === 0
2254
+ ? raised
2255
+ ? "No decisions you asked of others."
2256
+ : "No decisions of yours."
2257
+ : found.items
2258
+ .map(({ project, decision }) => `${project.key} ${formatDecisionLine(decision)}`)
2259
+ .join("\n") + formatMore(found.more, page.limit, page.offset),
2260
+ );
2261
+ return EXIT.ok;
2262
+ }
2068
2263
  const project = await session.project();
2069
- const found = await project.decisions({
2070
- ...(on(values, "open") ? { open: true } : {}),
2071
- ...page,
2072
- });
2264
+ const found = await project.decisions({ ...open_, ...page });
2073
2265
  answer(io, session, found, () =>
2074
2266
  found.items.length === 0
2075
2267
  ? "No decisions."
@@ -2792,6 +2984,7 @@ const COMMANDS: Readonly<Record<string, Command>> = {
2792
2984
  comment,
2793
2985
  file,
2794
2986
  ask,
2987
+ answer: answering,
2795
2988
  decision,
2796
2989
  decisions,
2797
2990
  finish: ending("finished"),
@@ -2892,11 +3085,39 @@ async function noticeVersions(io: CliIo, seen: ConsoleSeen): Promise<void> {
2892
3085
  });
2893
3086
  }
2894
3087
 
3088
+ /**
3089
+ * The refusal of a console older than this command works with (ADR-0038), in a console's own
3090
+ * form: a `426` the command reads as it reads the console's, before it acts on anything the
3091
+ * console answered.
3092
+ */
3093
+ function consoleRefusal(io: CliIo, seen: ConsoleSeen): Response | undefined {
3094
+ const command = parseVersion(io.version);
3095
+ const server = parseVersion(seen.version);
3096
+ if (command === undefined || server === undefined) {
3097
+ return undefined;
3098
+ }
3099
+ const { verdict, range } = judgeConsole(server, command, io.consoleAtLeast ?? CONSOLE_AT_LEAST);
3100
+ if (verdict !== "outdated") {
3101
+ return undefined;
3102
+ }
3103
+ return Response.json(
3104
+ {
3105
+ error: {
3106
+ code: "console.outdated",
3107
+ message: `This console, ${seen.version}, is older than this command (${io.version}) works with (${formatRange(range)}).`,
3108
+ },
3109
+ },
3110
+ { status: 426 },
3111
+ );
3112
+ }
3113
+
2895
3114
  /** What a refusal of the console is followed by, when there is something to do about it. */
2896
3115
  function hintFor(error: ApiError, io: CliIo, seen: ConsoleSeen): string {
2897
3116
  switch (error.code) {
2898
3117
  case "client.outdated":
2899
3118
  return `Update the command: ${updateWords(io, seen.version)}\n`;
3119
+ case "console.outdated":
3120
+ return `Install the console's version: ${updateWords(io, seen.version)}; or ask the console's operator to update the console.\n`;
2900
3121
  case "client.too_new":
2901
3122
  return `Install the console's version: ${updateWords(io, seen.version)}; or ask the console's operator to update the console.\n`;
2902
3123
  case "auth.required":
@@ -2972,7 +3193,13 @@ export async function runCli(argv: readonly string[], io: CliIo): Promise<number
2972
3193
  const response = await io.fetch(input, init);
2973
3194
  seen.version = response.headers.get(VERSION_HEADER) ?? seen.version;
2974
3195
  seen.range = response.headers.get(CLIENT_RANGE_HEADER) ?? seen.range;
2975
- return response;
3196
+ // A console older than this command works with is refused here (ADR-0038); `serve`
3197
+ // carries the pages' own requests, which are not the command's to judge.
3198
+ // A refusal of the console's own is passed on as it is.
3199
+ return (
3200
+ (name === "serve" || response.status === 426 ? undefined : consoleRefusal(io, seen)) ??
3201
+ response
3202
+ );
2976
3203
  },
2977
3204
  };
2978
3205
  try {
@@ -3004,7 +3231,9 @@ export async function runCli(argv: readonly string[], io: CliIo): Promise<number
3004
3231
  }
3005
3232
  if (error instanceof ApiError) {
3006
3233
  io.stderr(`${error.code}: ${error.message}\n${hintFor(error, io, seen)}`);
3007
- return error.code === "client.outdated" || error.code === "client.too_new"
3234
+ return error.code === "client.outdated" ||
3235
+ error.code === "client.too_new" ||
3236
+ error.code === "console.outdated"
3008
3237
  ? EXIT.incompatible
3009
3238
  : EXIT.failed;
3010
3239
  }
package/src/cli/format.ts CHANGED
@@ -147,10 +147,14 @@ export function formatMore(more: boolean, limit: number, offset: number): string
147
147
  return more ? `\n... more: --offset ${offset + limit}` : "";
148
148
  }
149
149
 
150
+ /** The logins of the people at work on a task, in the order they came to it. */
151
+ const logins = (people: readonly RestPerson[]): string =>
152
+ people.map((person) => person.login).join(", ");
153
+
150
154
  export function formatTaskLine(task: RestTask): string {
151
155
  const notes = [`owner ${task.owner.login}`];
152
- if (task.assignee !== null) {
153
- notes.push(`assigned to ${task.assignee.login}`);
156
+ if (task.workers.length > 0) {
157
+ notes.push(`workers ${logins(task.workers)}`);
154
158
  }
155
159
  if (task.category !== null) {
156
160
  notes.push(task.category.key);
@@ -199,7 +203,7 @@ export function formatTask(
199
203
  ): string {
200
204
  const lines = [
201
205
  `#${task.number} ${task.title}`,
202
- `state: ${task.state} priority: ${task.priority} owner: ${task.owner.login} assignee: ${task.assignee?.login ?? "nobody"}`,
206
+ `state: ${task.state} priority: ${task.priority} owner: ${task.owner.login} workers: ${task.workers.length === 0 ? "nobody" : logins(task.workers)}`,
203
207
  `id: ${task.id}${task.parentId === null ? "" : ` part of: ${task.parentId}`}`,
204
208
  ...scheduleLines(task),
205
209
  ];
@@ -375,14 +379,17 @@ export function formatCategoryLine(category: RestCategory): string {
375
379
  export function formatWork(work: RestWork): string {
376
380
  const lines: string[] = [];
377
381
  if (work.tasks.length === 0) {
378
- lines.push("No open task is assigned to you.");
382
+ lines.push("No open task is on your plate.");
379
383
  } else {
380
- lines.push("tasks assigned to you:");
384
+ lines.push("tasks you work on:");
381
385
  for (const { project, task } of work.tasks) {
382
386
  lines.push(` ${project.key} ${formatTaskLine(task)}`);
383
387
  }
384
388
  }
385
- lines.push("", work.decisions.length === 0 ? "decisions waiting: none" : "decisions waiting:");
389
+ lines.push(
390
+ "",
391
+ work.decisions.length === 0 ? "decisions waiting for you: none" : "decisions waiting for you:",
392
+ );
386
393
  for (const { project, decision } of work.decisions) {
387
394
  lines.push(` ${project.key} ${formatDecisionLine(decision)}`);
388
395
  }
@@ -392,17 +399,21 @@ export function formatWork(work: RestWork): string {
392
399
  /** Another person's plate: their open tasks, each with its project; no decisions, which wait for anyone. */
393
400
  export function formatPersonWork(person: RestPerson, work: RestPersonWork): string {
394
401
  if (work.tasks.length === 0) {
395
- return `No open task is assigned to ${person.login}.`;
402
+ return `No open task is on ${person.login}'s plate.`;
396
403
  }
397
404
  return [
398
- `tasks assigned to ${person.login}:`,
405
+ `tasks ${person.login} works on:`,
399
406
  ...work.tasks.map(({ project, task }) => ` ${project.key} ${formatTaskLine(task)}`),
400
407
  ].join("\n");
401
408
  }
402
409
 
410
+ /** Whom a decision is for, where that is not the one who asked (ADR-0042). */
411
+ const addressed = (decision: RestDecision): string =>
412
+ decision.decider.id === decision.raisedBy.id ? "" : `, for ${decision.decider.login}`;
413
+
403
414
  export function formatDecisionLine(decision: RestDecision): string {
404
415
  const state = decision.answer === null ? "waiting" : `answered ${decision.answer.option}`;
405
- return `${decision.id} ${state} ${decision.question} (asked by ${asker(decision)})`;
416
+ return `${decision.id} ${state} ${decision.question} (asked by ${asker(decision)}${addressed(decision)})`;
406
417
  }
407
418
 
408
419
  /** The options, one a line; the person's own answer, when they wrote one, is the last and says so. */
@@ -419,7 +430,7 @@ export function formatOptions(decision: RestDecision): string {
419
430
  export function formatAnswer(decision: RestDecision): string {
420
431
  const { answer } = decision;
421
432
  if (answer === null) {
422
- return "waits for a person.";
433
+ return `waits for ${decision.decider.login}.`;
423
434
  }
424
435
  const chosen = decision.options.find((option) => option.id === answer.option);
425
436
  const lines = [
@@ -434,7 +445,7 @@ export function formatAnswer(decision: RestDecision): string {
434
445
  export function formatDecision(decision: RestDecision): string {
435
446
  const lines = [
436
447
  `decision ${decision.id}: ${decision.question}`,
437
- `asked by ${asker(decision)}${decision.taskNumber === null ? "" : `, about task #${decision.taskNumber}`}`,
448
+ `asked by ${asker(decision)}${addressed(decision)}${decision.taskNumber === null ? "" : `, about task #${decision.taskNumber}`}`,
438
449
  ];
439
450
  if (decision.body.trim().length > 0) {
440
451
  lines.push("", decision.body.trimEnd());