@skillcdn/console 0.1.9 → 0.1.11

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 (162) hide show
  1. package/README.md +6 -6
  2. package/dist/api.d.ts +6 -5
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +5 -4
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts.map +1 -1
  7. package/dist/cli/cli.js +303 -18
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/format.d.ts +11 -1
  10. package/dist/cli/format.d.ts.map +1 -1
  11. package/dist/cli/format.js +87 -6
  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 +70 -14
  15. package/dist/cli/help.js.map +1 -1
  16. package/dist/client.d.ts +44 -8
  17. package/dist/client.d.ts.map +1 -1
  18. package/dist/client.js +32 -4
  19. package/dist/client.js.map +1 -1
  20. package/dist/components/board.d.ts +24 -1
  21. package/dist/components/board.d.ts.map +1 -1
  22. package/dist/components/board.js +66 -5
  23. package/dist/components/board.js.map +1 -1
  24. package/dist/components/calendar.d.ts +54 -0
  25. package/dist/components/calendar.d.ts.map +1 -0
  26. package/dist/components/calendar.js +156 -0
  27. package/dist/components/calendar.js.map +1 -0
  28. package/dist/components/categories.d.ts +13 -0
  29. package/dist/components/categories.d.ts.map +1 -0
  30. package/dist/components/categories.js +52 -0
  31. package/dist/components/categories.js.map +1 -0
  32. package/dist/components/decision-list.d.ts +10 -0
  33. package/dist/components/decision-list.d.ts.map +1 -1
  34. package/dist/components/decision-list.js +16 -1
  35. package/dist/components/decision-list.js.map +1 -1
  36. package/dist/components/event-feed.d.ts.map +1 -1
  37. package/dist/components/event-feed.js +23 -7
  38. package/dist/components/event-feed.js.map +1 -1
  39. package/dist/components/goals.d.ts +35 -0
  40. package/dist/components/goals.d.ts.map +1 -0
  41. package/dist/components/goals.js +45 -0
  42. package/dist/components/goals.js.map +1 -0
  43. package/dist/components/paged.d.ts +14 -0
  44. package/dist/components/paged.d.ts.map +1 -0
  45. package/dist/components/paged.js +28 -0
  46. package/dist/components/paged.js.map +1 -0
  47. package/dist/components/projects.d.ts +22 -2
  48. package/dist/components/projects.d.ts.map +1 -1
  49. package/dist/components/projects.js +35 -7
  50. package/dist/components/projects.js.map +1 -1
  51. package/dist/components/runs.d.ts.map +1 -1
  52. package/dist/components/runs.js +2 -2
  53. package/dist/components/runs.js.map +1 -1
  54. package/dist/components/shell.d.ts +2 -1
  55. package/dist/components/shell.d.ts.map +1 -1
  56. package/dist/components/shell.js +1 -1
  57. package/dist/components/shell.js.map +1 -1
  58. package/dist/components/task-form.d.ts +5 -1
  59. package/dist/components/task-form.d.ts.map +1 -1
  60. package/dist/components/task-form.js +13 -1
  61. package/dist/components/task-form.js.map +1 -1
  62. package/dist/components/task-list.d.ts +16 -0
  63. package/dist/components/task-list.d.ts.map +1 -0
  64. package/dist/components/task-list.js +16 -0
  65. package/dist/components/task-list.js.map +1 -0
  66. package/dist/components/task-view.d.ts +9 -1
  67. package/dist/components/task-view.d.ts.map +1 -1
  68. package/dist/components/task-view.js +10 -4
  69. package/dist/components/task-view.js.map +1 -1
  70. package/dist/components/ui.d.ts +30 -1
  71. package/dist/components/ui.d.ts.map +1 -1
  72. package/dist/components/ui.js +60 -0
  73. package/dist/components/ui.js.map +1 -1
  74. package/dist/components/work.d.ts +10 -0
  75. package/dist/components/work.d.ts.map +1 -0
  76. package/dist/components/work.js +33 -0
  77. package/dist/components/work.js.map +1 -0
  78. package/dist/console.css +658 -0
  79. package/dist/console.d.ts +8 -0
  80. package/dist/console.d.ts.map +1 -1
  81. package/dist/console.js +145 -13
  82. package/dist/console.js.map +1 -1
  83. package/dist/data.d.ts +19 -3
  84. package/dist/data.d.ts.map +1 -1
  85. package/dist/data.js +51 -4
  86. package/dist/data.js.map +1 -1
  87. package/dist/dates.d.ts +39 -0
  88. package/dist/dates.d.ts.map +1 -0
  89. package/dist/dates.js +120 -0
  90. package/dist/dates.js.map +1 -0
  91. package/dist/i18n/en.d.ts +172 -5
  92. package/dist/i18n/en.d.ts.map +1 -1
  93. package/dist/i18n/en.js +183 -6
  94. package/dist/i18n/en.js.map +1 -1
  95. package/dist/i18n/ko.d.ts.map +1 -1
  96. package/dist/i18n/ko.js +178 -6
  97. package/dist/i18n/ko.js.map +1 -1
  98. package/dist/index.d.ts +12 -5
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/index.js +12 -5
  101. package/dist/index.js.map +1 -1
  102. package/dist/limits.d.ts +11 -0
  103. package/dist/limits.d.ts.map +1 -1
  104. package/dist/limits.js +11 -0
  105. package/dist/limits.js.map +1 -1
  106. package/dist/marks.d.ts +12 -0
  107. package/dist/marks.d.ts.map +1 -0
  108. package/dist/marks.js +31 -0
  109. package/dist/marks.js.map +1 -0
  110. package/dist/router.d.ts +51 -1
  111. package/dist/router.d.ts.map +1 -1
  112. package/dist/router.js +80 -1
  113. package/dist/router.js.map +1 -1
  114. package/dist/routes.d.ts +14 -1
  115. package/dist/routes.d.ts.map +1 -1
  116. package/dist/routes.js +17 -0
  117. package/dist/routes.js.map +1 -1
  118. package/dist/schemas.d.ts +489 -3
  119. package/dist/schemas.d.ts.map +1 -1
  120. package/dist/schemas.js +150 -9
  121. package/dist/schemas.js.map +1 -1
  122. package/dist/vocabulary.d.ts +21 -1
  123. package/dist/vocabulary.d.ts.map +1 -1
  124. package/dist/vocabulary.js +31 -0
  125. package/dist/vocabulary.js.map +1 -1
  126. package/docs/specs/cli.md +17 -8
  127. package/docs/specs/rest.md +48 -17
  128. package/package.json +1 -1
  129. package/skills/working-the-board/SKILL.md +7 -6
  130. package/src/api.ts +42 -0
  131. package/src/cli/cli.ts +362 -18
  132. package/src/cli/format.ts +96 -6
  133. package/src/cli/help.ts +77 -15
  134. package/src/client.ts +98 -7
  135. package/src/components/board.tsx +173 -13
  136. package/src/components/calendar.tsx +367 -0
  137. package/src/components/categories.tsx +209 -0
  138. package/src/components/decision-list.tsx +67 -5
  139. package/src/components/event-feed.tsx +46 -27
  140. package/src/components/goals.tsx +264 -0
  141. package/src/components/paged.tsx +39 -0
  142. package/src/components/projects.tsx +128 -10
  143. package/src/components/runs.tsx +7 -1
  144. package/src/components/shell.tsx +5 -2
  145. package/src/components/task-form.tsx +78 -1
  146. package/src/components/task-list.tsx +64 -0
  147. package/src/components/task-view.tsx +75 -4
  148. package/src/components/ui.tsx +116 -1
  149. package/src/components/work.tsx +93 -0
  150. package/src/console.tsx +376 -9
  151. package/src/data.ts +83 -12
  152. package/src/dates.ts +143 -0
  153. package/src/i18n/en.ts +193 -6
  154. package/src/i18n/ko.ts +184 -6
  155. package/src/index.ts +62 -1
  156. package/src/limits.ts +11 -0
  157. package/src/marks.ts +40 -0
  158. package/src/router.ts +131 -6
  159. package/src/routes.ts +19 -0
  160. package/src/schemas.ts +187 -7
  161. package/src/styles/console.css +658 -0
  162. package/src/vocabulary.ts +43 -0
package/src/cli/cli.ts CHANGED
@@ -7,18 +7,30 @@ import {
7
7
  type FetchLike,
8
8
  type ProjectClient,
9
9
  } from "../client.js";
10
- import { MAX_OPTIONS, MIN_OPTIONS } from "../limits.js";
11
- import type { RestClaim, RestDecision } from "../schemas.js";
12
- import { TASK_PRIORITIES, TASK_STATES, type TaskPriority, type TaskState } from "../vocabulary.js";
10
+ import { addDays, dayOf as dayOfDate, isDay } from "../dates.js";
11
+ import { DEFAULT_PAGE_LIMIT, LIST_LIMIT, MAX_OPTIONS, MIN_OPTIONS } from "../limits.js";
12
+ import type { RestClaim, RestDecision, RestTaskPatch } from "../schemas.js";
13
+ import {
14
+ GOAL_STATES,
15
+ type GoalState,
16
+ TASK_PRIORITIES,
17
+ TASK_STATES,
18
+ type TaskPriority,
19
+ type TaskState,
20
+ } from "../vocabulary.js";
13
21
  import { loadWebRoot, type WebRoot, WebRootError } from "../web.js";
14
22
  import type { CredentialStore } from "./credentials.js";
15
23
  import type { DirectoryStore } from "./directory.js";
16
24
  import {
17
25
  formatAnswer,
26
+ formatCategoryLine,
18
27
  formatDecision,
19
28
  formatDecisionLine,
20
29
  formatDocument,
21
30
  formatDocuments,
31
+ formatGoal,
32
+ formatGoalLine,
33
+ formatMore,
22
34
  formatOptions,
23
35
  formatProjectLine,
24
36
  formatRun,
@@ -28,6 +40,7 @@ import {
28
40
  formatTaskLine,
29
41
  formatVersion,
30
42
  formatVersionLine,
43
+ formatWork,
31
44
  } from "./format.js";
32
45
  import { COMMAND_HELP, helpFor, usage } from "./help.js";
33
46
  import { createServeHandler, DEFAULT_SERVE_PORT, type Listen } from "./serve.js";
@@ -255,6 +268,79 @@ function priorityOf(value: string | undefined): TaskPriority | undefined {
255
268
  return found;
256
269
  }
257
270
 
271
+ function goalStateOf(value: string | undefined): GoalState | undefined {
272
+ if (value === undefined) {
273
+ return undefined;
274
+ }
275
+ const found = GOAL_STATES.find((state) => state === value);
276
+ if (found === undefined) {
277
+ throw misuse(`Not a state of a goal: ${value}. One of ${GOAL_STATES.join(", ")}`);
278
+ }
279
+ return found;
280
+ }
281
+
282
+ /** Today by this machine's clock, as a day. */
283
+ const todayOf = (io: CliIo): string => dayOfDate(new Date(io.now()));
284
+
285
+ /**
286
+ * A day as a flag takes one: `2026-10-24`, `today`, `tomorrow`, or `+<n>` days from today; and
287
+ * `none` where a field may be cleared, which is `null` on the wire.
288
+ */
289
+ function dayOf(io: CliIo, value: string | undefined, clearable = false): string | null | undefined {
290
+ if (value === undefined) {
291
+ return undefined;
292
+ }
293
+ if (clearable && value === "none") {
294
+ return null;
295
+ }
296
+ if (value === "today") {
297
+ return todayOf(io);
298
+ }
299
+ if (value === "tomorrow") {
300
+ return addDays(todayOf(io), 1);
301
+ }
302
+ const ahead = /^\+(\d{1,3})$/.exec(value);
303
+ if (ahead !== null) {
304
+ return addDays(todayOf(io), Number(ahead[1]));
305
+ }
306
+ if (!isDay(value)) {
307
+ throw misuse(`Not a day: ${value}. Say it as 2026-10-24, today, tomorrow, or +7`);
308
+ }
309
+ return value;
310
+ }
311
+
312
+ /** A value a field is set to, or `none` to clear it, which is `null` on the wire. */
313
+ const orNone = (value: string | undefined): string | null | undefined =>
314
+ value === "none" ? null : value;
315
+
316
+ /** The id of the person a flag names: `me`, or a login of the workspace. */
317
+ async function personIdOf(
318
+ session: Session,
319
+ value: string | undefined,
320
+ ): Promise<string | null | undefined> {
321
+ if (value === undefined) {
322
+ return undefined;
323
+ }
324
+ if (value === "none") {
325
+ return null;
326
+ }
327
+ if (value === "me") {
328
+ const me = await session.client.me();
329
+ if (me.person === null) {
330
+ throw failed("The console does not know this token. Sign in again: console login");
331
+ }
332
+ return me.person.id;
333
+ }
334
+ const { items } = await session.client.people();
335
+ const found = items.find((person) => person.login.toLowerCase() === value.toLowerCase());
336
+ if (found === undefined) {
337
+ throw misuse(
338
+ `Nobody of the workspace is called ${value}. console people is not a command; the logins are as people sign in.`,
339
+ );
340
+ }
341
+ return found.id;
342
+ }
343
+
258
344
  function secondsOf(value: string | undefined, fallback: number): number {
259
345
  if (value === undefined) {
260
346
  return fallback;
@@ -266,6 +352,26 @@ function secondsOf(value: string | undefined, fallback: number): number {
266
352
  return seconds;
267
353
  }
268
354
 
355
+ /** The flags of a page of a listing: how many, from which one on. */
356
+ const PAGE_OPTIONS: OptionSpec = { limit: { type: "string" }, offset: { type: "string" } };
357
+
358
+ /** The page the flags ask for: `DEFAULT_PAGE_LIMIT` items from the first when nothing is said. */
359
+ function pageOf(values: Values): { readonly limit: number; readonly offset: number } {
360
+ const limit = text(values, "limit");
361
+ const offset = text(values, "offset");
362
+ const page = {
363
+ limit: limit === undefined ? DEFAULT_PAGE_LIMIT : Number(limit),
364
+ offset: offset === undefined ? 0 : Number(offset),
365
+ };
366
+ if (!Number.isInteger(page.limit) || page.limit < 1 || page.limit > LIST_LIMIT) {
367
+ throw misuse(`Not a number of items, 1 to ${LIST_LIMIT}: ${limit}`);
368
+ }
369
+ if (!Number.isInteger(page.offset) || page.offset < 0) {
370
+ throw misuse(`Not a number of items to skip: ${offset}`);
371
+ }
372
+ return page;
373
+ }
374
+
269
375
  /** One positional, and nothing else. */
270
376
  function theOne(positionals: readonly string[], ask: string): string {
271
377
  const [first] = positionals;
@@ -452,13 +558,69 @@ const use: Command = async (args, io) => {
452
558
  };
453
559
 
454
560
  const tasks: Command = async (args, io) => {
455
- const { values } = parse(args, { state: { type: "string" } }, false);
561
+ const { values } = parse(
562
+ args,
563
+ {
564
+ state: { type: "string" },
565
+ open: { type: "boolean" },
566
+ mine: { type: "boolean" },
567
+ category: { type: "string" },
568
+ goal: { type: "string" },
569
+ due: { type: "string" },
570
+ ...PAGE_OPTIONS,
571
+ },
572
+ false,
573
+ );
456
574
  const state = stateOf(text(values, "state"));
575
+ const page = pageOf(values);
457
576
  const session = await open(io, values);
458
577
  const project = await session.project();
459
- const found = await project.tasks(state === undefined ? {} : { state });
578
+ // What is due: a day, or the words for the usual windows; overdue keeps only the open ones.
579
+ const due = text(values, "due");
580
+ const today = todayOf(io);
581
+ const dueBy =
582
+ due === undefined
583
+ ? undefined
584
+ : due === "overdue"
585
+ ? addDays(today, -1)
586
+ : due === "week"
587
+ ? addDays(today, 6)
588
+ : (dayOf(io, due) ?? undefined);
589
+ const assignee = on(values, "mine")
590
+ ? ((await personIdOf(session, "me")) ?? undefined)
591
+ : undefined;
592
+ const found = await project.tasks({
593
+ ...(state === undefined ? {} : { state }),
594
+ ...(on(values, "open") || due === "overdue" ? { open: true } : {}),
595
+ ...(assignee === undefined ? {} : { assignee }),
596
+ ...(text(values, "category") === undefined ? {} : { category: text(values, "category") }),
597
+ ...(text(values, "goal") === undefined ? {} : { goal: text(values, "goal") }),
598
+ ...(dueBy === undefined ? {} : { dueBy }),
599
+ ...page,
600
+ });
460
601
  answer(io, session, found, () =>
461
- found.items.length === 0 ? "No tasks." : found.items.map(formatTaskLine).join("\n"),
602
+ found.items.length === 0
603
+ ? "No tasks."
604
+ : found.items.map(formatTaskLine).join("\n") +
605
+ formatMore(found.more, page.limit, page.offset),
606
+ );
607
+ return EXIT.ok;
608
+ };
609
+
610
+ const work: Command = async (args, io) => {
611
+ const { values } = parse(args, {}, false);
612
+ const session = await open(io, values);
613
+ const found = await session.client.work();
614
+ answer(io, session, found, () => formatWork(found));
615
+ return EXIT.ok;
616
+ };
617
+
618
+ const categories: Command = async (args, io) => {
619
+ const { values } = parse(args, {}, false);
620
+ const session = await open(io, values);
621
+ const found = await session.client.categories();
622
+ answer(io, session, found, () =>
623
+ found.items.length === 0 ? "No categories." : found.items.map(formatCategoryLine).join("\n"),
462
624
  );
463
625
  return EXIT.ok;
464
626
  };
@@ -472,33 +634,105 @@ const skills: Command = async (args, io) => {
472
634
  return EXIT.ok;
473
635
  };
474
636
 
637
+ /** The flags that set a task's fields, shared by writing one and changing one. */
638
+ const TASK_FIELDS: OptionSpec = {
639
+ state: { type: "string" },
640
+ priority: { type: "string" },
641
+ category: { type: "string" },
642
+ goal: { type: "string" },
643
+ starts: { type: "string" },
644
+ due: { type: "string" },
645
+ assignee: { type: "string" },
646
+ parent: { type: "string" },
647
+ };
648
+
649
+ /** What the flags say of a task's fields, as the API takes them; `none` clears a field. */
650
+ async function taskFieldsOf(
651
+ io: CliIo,
652
+ session: Session,
653
+ project: ProjectClient,
654
+ values: Values,
655
+ ): Promise<RestTaskPatch> {
656
+ const state = stateOf(text(values, "state"));
657
+ const priority = priorityOf(text(values, "priority"));
658
+ const category = orNone(text(values, "category"));
659
+ const goalId = orNone(text(values, "goal"));
660
+ const startsOn = dayOf(io, text(values, "starts"), true);
661
+ const dueOn = dayOf(io, text(values, "due"), true);
662
+ const assigneeId = await personIdOf(session, text(values, "assignee"));
663
+ const parent = text(values, "parent");
664
+ const parentId =
665
+ parent === undefined ? undefined : parent === "none" ? null : (await project.task(parent)).id;
666
+ return {
667
+ ...(state === undefined ? {} : { state }),
668
+ ...(priority === undefined ? {} : { priority }),
669
+ ...(category === undefined ? {} : { category }),
670
+ ...(goalId === undefined ? {} : { goalId }),
671
+ ...(startsOn === undefined ? {} : { startsOn }),
672
+ ...(dueOn === undefined ? {} : { dueOn }),
673
+ ...(assigneeId === undefined ? {} : { assigneeId }),
674
+ ...(parentId === undefined ? {} : { parentId }),
675
+ };
676
+ }
677
+
475
678
  const newTask: Command = async (args, io) => {
476
679
  const { values, positionals } = parse(args, {
680
+ ...TASK_FIELDS,
477
681
  body: { type: "string" },
478
682
  file: { type: "string" },
479
- state: { type: "string" },
480
- priority: { type: "string" },
481
683
  });
482
684
  const title = theOne(positionals, 'Say the title: console task new "<title>"');
483
- const state = stateOf(text(values, "state"));
484
- const priority = priorityOf(text(values, "priority"));
485
685
  const body = await bodyOf(io, values, text(values, "body"));
486
686
  const session = await open(io, values);
487
687
  const project = await session.project();
688
+ const fields = await taskFieldsOf(io, session, project, values);
488
689
  const made = await project.createTask({
489
690
  title,
490
691
  ...(body === undefined ? {} : { body }),
491
- ...(state === undefined ? {} : { state }),
492
- ...(priority === undefined ? {} : { priority }),
692
+ ...fields,
493
693
  });
494
694
  answer(io, session, made, () => `Task #${made.number} written: ${made.title}\nid: ${made.id}`);
495
695
  return EXIT.ok;
496
696
  };
497
697
 
698
+ const editTask: Command = async (args, io) => {
699
+ const { values, positionals } = parse(args, {
700
+ ...TASK_FIELDS,
701
+ title: { type: "string" },
702
+ body: { type: "string" },
703
+ file: { type: "string" },
704
+ });
705
+ const ref = theOne(
706
+ positionals,
707
+ "Say which task: console task edit <number|id> --due <day> [--category <key>] ...",
708
+ );
709
+ const body = await bodyOf(io, values, text(values, "body"));
710
+ const session = await open(io, values);
711
+ const project = await session.project();
712
+ const title = text(values, "title");
713
+ const patch: RestTaskPatch = {
714
+ ...(title === undefined ? {} : { title }),
715
+ ...(body === undefined ? {} : { body }),
716
+ ...(await taskFieldsOf(io, session, project, values)),
717
+ };
718
+ if (Object.keys(patch).length === 0) {
719
+ throw misuse(
720
+ "Say what to change: --title, --body, --state, --priority, --category, --goal, --starts, --due, --assignee or --parent; none clears a field.",
721
+ );
722
+ }
723
+ const found = await project.task(ref);
724
+ const changed = await project.updateTask(found.id, patch);
725
+ answer(io, session, changed, () => formatTask(changed));
726
+ return EXIT.ok;
727
+ };
728
+
498
729
  const task: Command = async (args, io) => {
499
730
  if (args[0] === "new") {
500
731
  return newTask(args.slice(1), io);
501
732
  }
733
+ if (args[0] === "edit") {
734
+ return editTask(args.slice(1), io);
735
+ }
502
736
  const { values, positionals } = parse(args, {});
503
737
  const ref = theOne(positionals, "Say which task: console task <number|id>");
504
738
  const session = await open(io, values);
@@ -527,7 +761,7 @@ const take: Command = async (args, io) => {
527
761
  });
528
762
  answer(io, session, { run, task: found }, () =>
529
763
  [
530
- `Run ${run.id} began on #${found.number} ${found.title}, as "${run.agent}" for ${run.person.login}. The task is in progress and theirs.`,
764
+ `Run ${run.id} began on #${found.number} ${found.title}, as "${run.agent}" for ${run.person.login}. The task is in progress and theirs. A run that was open on it is over now: the board cannot know whether its agent still ran, so taking it was deciding.`,
531
765
  "",
532
766
  formatTask(found),
533
767
  "",
@@ -864,13 +1098,102 @@ const restore: Command = async (args, io) => {
864
1098
  return EXIT.ok;
865
1099
  };
866
1100
 
1101
+ const goals: Command = async (args, io) => {
1102
+ const { values } = parse(args, {}, false);
1103
+ const session = await open(io, values);
1104
+ const project = await session.project();
1105
+ const found = await project.goals();
1106
+ answer(io, session, found, () =>
1107
+ found.items.length === 0
1108
+ ? "No goals. An owner sets one: console goal new <title> --due <day>"
1109
+ : found.items.map(formatGoalLine).join("\n"),
1110
+ );
1111
+ return EXIT.ok;
1112
+ };
1113
+
1114
+ const GOAL_FIELDS: OptionSpec = {
1115
+ state: { type: "string" },
1116
+ due: { type: "string" },
1117
+ body: { type: "string" },
1118
+ file: { type: "string" },
1119
+ };
1120
+
1121
+ const newGoal: Command = async (args, io) => {
1122
+ const { values, positionals } = parse(args, GOAL_FIELDS);
1123
+ const title = theOne(positionals, 'Say the title: console goal new "<title>" [--due <day>]');
1124
+ const state = goalStateOf(text(values, "state"));
1125
+ const dueOn = dayOf(io, text(values, "due"));
1126
+ const body = await bodyOf(io, values, text(values, "body"));
1127
+ const session = await open(io, values);
1128
+ const project = await session.project();
1129
+ const made = await project.createGoal({
1130
+ title,
1131
+ ...(body === undefined ? {} : { body }),
1132
+ ...(state === undefined ? {} : { state }),
1133
+ ...(dueOn === undefined ? {} : { dueOn }),
1134
+ });
1135
+ answer(io, session, made, () => `Goal set: ${made.title}\nid: ${made.id}`);
1136
+ return EXIT.ok;
1137
+ };
1138
+
1139
+ const editGoal: Command = async (args, io) => {
1140
+ const { values, positionals } = parse(args, { ...GOAL_FIELDS, title: { type: "string" } });
1141
+ const id = theOne(positionals, "Say which goal: console goal edit <id> --state <state> ...");
1142
+ const title = text(values, "title");
1143
+ const state = goalStateOf(text(values, "state"));
1144
+ const dueOn = dayOf(io, text(values, "due"), true);
1145
+ const body = await bodyOf(io, values, text(values, "body"));
1146
+ const patch = {
1147
+ ...(title === undefined ? {} : { title }),
1148
+ ...(body === undefined ? {} : { body }),
1149
+ ...(state === undefined ? {} : { state }),
1150
+ ...(dueOn === undefined ? {} : { dueOn }),
1151
+ };
1152
+ if (Object.keys(patch).length === 0) {
1153
+ throw misuse(
1154
+ "Say what to change: --title, --body, --state or --due; --due none clears the day.",
1155
+ );
1156
+ }
1157
+ const session = await open(io, values);
1158
+ const project = await session.project();
1159
+ const changed = await project.updateGoal(id, patch);
1160
+ answer(io, session, changed, () => formatGoal(changed));
1161
+ return EXIT.ok;
1162
+ };
1163
+
1164
+ const goal: Command = async (args, io) => {
1165
+ if (args[0] === "new") {
1166
+ return newGoal(args.slice(1), io);
1167
+ }
1168
+ if (args[0] === "edit") {
1169
+ return editGoal(args.slice(1), io);
1170
+ }
1171
+ const { values, positionals } = parse(args, {});
1172
+ const id = theOne(positionals, "Say which goal: console goal <id>");
1173
+ const session = await open(io, values);
1174
+ const project = await session.project();
1175
+ const found = await project.goal(id);
1176
+ const towards = await project.tasks({ goal: found.id });
1177
+ answer(io, session, { goal: found, tasks: towards.items }, () =>
1178
+ formatGoal(found, towards.items),
1179
+ );
1180
+ return EXIT.ok;
1181
+ };
1182
+
867
1183
  const decisions: Command = async (args, io) => {
868
- const { values } = parse(args, { open: { type: "boolean" } }, false);
1184
+ const { values } = parse(args, { open: { type: "boolean" }, ...PAGE_OPTIONS }, false);
1185
+ const page = pageOf(values);
869
1186
  const session = await open(io, values);
870
1187
  const project = await session.project();
871
- const found = await project.decisions(on(values, "open") ? { open: true } : {});
1188
+ const found = await project.decisions({
1189
+ ...(on(values, "open") ? { open: true } : {}),
1190
+ ...page,
1191
+ });
872
1192
  answer(io, session, found, () =>
873
- found.items.length === 0 ? "No decisions." : found.items.map(formatDecisionLine).join("\n"),
1193
+ found.items.length === 0
1194
+ ? "No decisions."
1195
+ : found.items.map(formatDecisionLine).join("\n") +
1196
+ formatMore(found.more, page.limit, page.offset),
874
1197
  );
875
1198
  return EXIT.ok;
876
1199
  };
@@ -923,9 +1246,15 @@ const abandon: Command = async (args, io) => {
923
1246
  const runs: Command = async (args, io) => {
924
1247
  const { values } = parse(
925
1248
  args,
926
- { task: { type: "string" }, open: { type: "boolean" }, mine: { type: "boolean" } },
1249
+ {
1250
+ task: { type: "string" },
1251
+ open: { type: "boolean" },
1252
+ mine: { type: "boolean" },
1253
+ ...PAGE_OPTIONS,
1254
+ },
927
1255
  false,
928
1256
  );
1257
+ const page = pageOf(values);
929
1258
  const session = await open(io, values);
930
1259
  const project = await session.project();
931
1260
  const ref = text(values, "task");
@@ -934,9 +1263,12 @@ const runs: Command = async (args, io) => {
934
1263
  ...(taskId === undefined ? {} : { task: taskId }),
935
1264
  ...(on(values, "open") ? { open: true } : {}),
936
1265
  ...(on(values, "mine") ? { mine: true } : {}),
1266
+ ...page,
937
1267
  });
938
1268
  answer(io, session, found, () =>
939
- found.items.length === 0 ? "No runs." : found.items.map(formatRunLine).join("\n"),
1269
+ found.items.length === 0
1270
+ ? "No runs."
1271
+ : found.items.map(formatRunLine).join("\n") + formatMore(found.more, page.limit, page.offset),
940
1272
  );
941
1273
  return EXIT.ok;
942
1274
  };
@@ -1024,8 +1356,12 @@ const COMMANDS: Readonly<Record<string, Command>> = {
1024
1356
  serve,
1025
1357
  projects,
1026
1358
  use,
1359
+ work,
1027
1360
  tasks,
1028
1361
  task,
1362
+ goals,
1363
+ goal,
1364
+ categories,
1029
1365
  skills,
1030
1366
  take,
1031
1367
  report,
@@ -1072,6 +1408,14 @@ function hintFor(error: ApiError): string {
1072
1408
  return "The page has moved on since the version you started from. Read it again, and write from there.\n";
1073
1409
  case "document.archived":
1074
1410
  return "The page is archived. console restore <path> brings it back first.\n";
1411
+ case "task.invalid_category":
1412
+ return "No category has that key. console categories lists them.\n";
1413
+ case "task.invalid_goal":
1414
+ return "No goal of this project has that id. console goals lists them.\n";
1415
+ case "task.invalid_dates":
1416
+ return "The due day is before the start. Say a due day on or after it.\n";
1417
+ case "goal.not_found":
1418
+ return "No goal of this project has that id. console goals lists them.\n";
1075
1419
  case "connect.too_many":
1076
1420
  return "The console holds as many connections as it may at once. Try again in a few minutes.\n";
1077
1421
  case "network":
package/src/cli/format.ts CHANGED
@@ -1,15 +1,18 @@
1
1
  import type {
2
2
  RestArtifact,
3
+ RestCategory,
3
4
  RestDecision,
4
5
  RestDocument,
5
6
  RestDocumentSummary,
6
7
  RestDocuments,
8
+ RestGoal,
7
9
  RestProject,
8
10
  RestRun,
9
11
  RestSkills,
10
12
  RestTask,
11
13
  RestVersion,
12
14
  RestVersionSummary,
15
+ RestWork,
13
16
  } from "../schemas.js";
14
17
 
15
18
  // What the command prints: one line per thing in a list, a few lines for one thing in full.
@@ -36,26 +39,53 @@ export function formatProjectLine(project: RestProject): string {
36
39
  if (project.openDecisions > 0) {
37
40
  notes.push(`${plural(project.openDecisions, "decision")} waiting`);
38
41
  }
39
- if (project.openRuns > 0) {
40
- notes.push(`${plural(project.openRuns, "agent")} at work`);
41
- }
42
42
  return `${project.key} ${project.name} (${notes.join("; ")})`;
43
43
  }
44
44
 
45
+ /** The line under a page of a listing that has more after it: how to ask for the next page. */
46
+ export function formatMore(more: boolean, limit: number, offset: number): string {
47
+ return more ? `\n... more: --offset ${offset + limit}` : "";
48
+ }
49
+
45
50
  export function formatTaskLine(task: RestTask): string {
46
51
  const notes = [`owner ${task.owner.login}`];
47
52
  if (task.assignee !== null) {
48
53
  notes.push(`assigned to ${task.assignee.login}`);
49
54
  }
55
+ if (task.category !== null) {
56
+ notes.push(task.category.key);
57
+ }
58
+ if (task.dueOn !== null) {
59
+ notes.push(`due ${task.dueOn}`);
60
+ }
50
61
  if (task.openDecisions > 0) {
51
62
  notes.push(`${plural(task.openDecisions, "decision")} waiting`);
52
63
  }
53
- if (task.openRuns > 0) {
54
- notes.push("an agent at work");
55
- }
56
64
  return `#${task.number} ${task.state} ${task.priority} ${task.title} (${notes.join("; ")})`;
57
65
  }
58
66
 
67
+ /** The plan and what happened, as a line each, only where there is something to say. */
68
+ function scheduleLines(task: RestTask): string[] {
69
+ const lines: string[] = [];
70
+ const plan = [
71
+ task.category === null ? undefined : `category: ${task.category.key}`,
72
+ task.goalId === null ? undefined : `goal: ${task.goalId}`,
73
+ task.startsOn === null ? undefined : `starts: ${task.startsOn}`,
74
+ task.dueOn === null ? undefined : `due: ${task.dueOn}`,
75
+ ].filter((part) => part !== undefined);
76
+ if (plan.length > 0) {
77
+ lines.push(plan.join(" "));
78
+ }
79
+ const happened = [
80
+ task.startedAt === null ? undefined : `started: ${task.startedAt}`,
81
+ task.doneAt === null ? undefined : `done: ${task.doneAt}`,
82
+ ].filter((part) => part !== undefined);
83
+ if (happened.length > 0) {
84
+ lines.push(happened.join(" "));
85
+ }
86
+ return lines;
87
+ }
88
+
59
89
  /** One task in full; with the decisions about it and the runs on it, when given. */
60
90
  export function formatTask(
61
91
  task: RestTask,
@@ -66,6 +96,7 @@ export function formatTask(
66
96
  `#${task.number} ${task.title}`,
67
97
  `state: ${task.state} priority: ${task.priority} owner: ${task.owner.login} assignee: ${task.assignee?.login ?? "nobody"}`,
68
98
  `id: ${task.id}${task.parentId === null ? "" : ` part of: ${task.parentId}`}`,
99
+ ...scheduleLines(task),
69
100
  ];
70
101
  if (task.links.length > 0) {
71
102
  lines.push("links:");
@@ -96,6 +127,9 @@ export function formatRunLine(run: RestRun): string {
96
127
  if (run.waitingFor !== null) {
97
128
  notes.push(`waiting for decision ${run.waitingFor}`);
98
129
  }
130
+ if (run.endedAt === null) {
131
+ notes.push(`last heard ${run.lastHeardAt}`);
132
+ }
99
133
  return `${run.id} ${run.status} ${run.agent} for ${run.person.login} task #${run.taskNumber} (${notes.join(", ")})`;
100
134
  }
101
135
 
@@ -165,6 +199,62 @@ export function formatSkills(skills: RestSkills): string {
165
199
  return lines.join("\n");
166
200
  }
167
201
 
202
+ /** How far a goal is, from its tasks: the done, the at work, and the rest, in one phrase. */
203
+ function progressOf(goal: RestGoal): string {
204
+ const counts = goal.taskCounts;
205
+ const total = counts.idea + counts.ready + counts.in_progress + counts.in_review + counts.done;
206
+ const working = counts.in_progress + counts.in_review;
207
+ return `${counts.done}/${total} done${working === 0 ? "" : `, ${working} in progress`}${counts.dropped === 0 ? "" : `, ${counts.dropped} dropped`}`;
208
+ }
209
+
210
+ /** One goal: its id, its state, its title, when it is due, and how far it is. */
211
+ export function formatGoalLine(goal: RestGoal): string {
212
+ const notes = [goal.dueOn === null ? "no target day" : `due ${goal.dueOn}`, progressOf(goal)];
213
+ return `${goal.id} ${goal.state} ${goal.title} (${notes.join("; ")})`;
214
+ }
215
+
216
+ /** One goal in full; with the tasks that work towards it, when given. */
217
+ export function formatGoal(goal: RestGoal, tasks?: readonly RestTask[]): string {
218
+ const lines = [
219
+ `goal ${goal.id}: ${goal.title}`,
220
+ `state: ${goal.state} due: ${goal.dueOn ?? "none"} set by: ${goal.createdBy.login}`,
221
+ `progress: ${progressOf(goal)}`,
222
+ ];
223
+ if (goal.body.trim().length > 0) {
224
+ lines.push("", goal.body.trimEnd());
225
+ }
226
+ if (tasks !== undefined) {
227
+ lines.push("", tasks.length === 0 ? "tasks: none" : "tasks:");
228
+ for (const task of tasks) {
229
+ lines.push(` ${formatTaskLine(task)}`);
230
+ }
231
+ }
232
+ return lines.join("\n");
233
+ }
234
+
235
+ /** One category: its key, what people call it, and its colour. */
236
+ export function formatCategoryLine(category: RestCategory): string {
237
+ return `${category.key} ${category.name} (${category.color})`;
238
+ }
239
+
240
+ /** What is on a person's plate: their open tasks by project, and the decisions waiting. */
241
+ export function formatWork(work: RestWork): string {
242
+ const lines: string[] = [];
243
+ if (work.tasks.length === 0) {
244
+ lines.push("No open task is assigned to you.");
245
+ } else {
246
+ lines.push("tasks assigned to you:");
247
+ for (const { project, task } of work.tasks) {
248
+ lines.push(` ${project.key} ${formatTaskLine(task)}`);
249
+ }
250
+ }
251
+ lines.push("", work.decisions.length === 0 ? "decisions waiting: none" : "decisions waiting:");
252
+ for (const { project, decision } of work.decisions) {
253
+ lines.push(` ${project.key} ${formatDecisionLine(decision)}`);
254
+ }
255
+ return lines.join("\n");
256
+ }
257
+
168
258
  export function formatDecisionLine(decision: RestDecision): string {
169
259
  const state = decision.answer === null ? "waiting" : `answered ${decision.answer.option}`;
170
260
  return `${decision.id} ${state} ${decision.question} (asked by ${asker(decision)})`;