@skillcdn/console 0.1.18 → 0.1.20

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 (140) hide show
  1. package/README.md +2 -2
  2. package/SKILLCDN.md +1 -1
  3. package/dist/api.d.ts +1 -1
  4. package/dist/api.d.ts.map +1 -1
  5. package/dist/api.js +1 -1
  6. package/dist/api.js.map +1 -1
  7. package/dist/cli/cli.d.ts +1 -1
  8. package/dist/cli/cli.d.ts.map +1 -1
  9. package/dist/cli/cli.js +353 -39
  10. package/dist/cli/cli.js.map +1 -1
  11. package/dist/cli/credentials.d.ts +102 -3
  12. package/dist/cli/credentials.d.ts.map +1 -1
  13. package/dist/cli/credentials.js +177 -8
  14. package/dist/cli/credentials.js.map +1 -1
  15. package/dist/cli/directory.d.ts +6 -0
  16. package/dist/cli/directory.d.ts.map +1 -1
  17. package/dist/cli/directory.js +37 -6
  18. package/dist/cli/directory.js.map +1 -1
  19. package/dist/cli/format.d.ts +10 -1
  20. package/dist/cli/format.d.ts.map +1 -1
  21. package/dist/cli/format.js +106 -2
  22. package/dist/cli/format.js.map +1 -1
  23. package/dist/cli/help.d.ts.map +1 -1
  24. package/dist/cli/help.js +44 -22
  25. package/dist/cli/help.js.map +1 -1
  26. package/dist/client.d.ts +21 -1
  27. package/dist/client.d.ts.map +1 -1
  28. package/dist/client.js +14 -1
  29. package/dist/client.js.map +1 -1
  30. package/dist/components/board.d.ts +23 -5
  31. package/dist/components/board.d.ts.map +1 -1
  32. package/dist/components/board.js +239 -9
  33. package/dist/components/board.js.map +1 -1
  34. package/dist/components/comments.d.ts +0 -2
  35. package/dist/components/comments.d.ts.map +1 -1
  36. package/dist/components/comments.js +14 -41
  37. package/dist/components/comments.js.map +1 -1
  38. package/dist/components/documents.d.ts +2 -0
  39. package/dist/components/documents.d.ts.map +1 -1
  40. package/dist/components/documents.js +51 -5
  41. package/dist/components/documents.js.map +1 -1
  42. package/dist/components/event-feed.d.ts.map +1 -1
  43. package/dist/components/event-feed.js +4 -0
  44. package/dist/components/event-feed.js.map +1 -1
  45. package/dist/components/files.d.ts +11 -0
  46. package/dist/components/files.d.ts.map +1 -0
  47. package/dist/components/files.js +44 -0
  48. package/dist/components/files.js.map +1 -0
  49. package/dist/components/goals.d.ts +17 -1
  50. package/dist/components/goals.d.ts.map +1 -1
  51. package/dist/components/goals.js +34 -9
  52. package/dist/components/goals.js.map +1 -1
  53. package/dist/components/markdown.d.ts +6 -0
  54. package/dist/components/markdown.d.ts.map +1 -1
  55. package/dist/components/markdown.js +7 -0
  56. package/dist/components/markdown.js.map +1 -1
  57. package/dist/components/pictures.d.ts +68 -0
  58. package/dist/components/pictures.d.ts.map +1 -0
  59. package/dist/components/pictures.js +213 -0
  60. package/dist/components/pictures.js.map +1 -0
  61. package/dist/components/projects.d.ts +12 -0
  62. package/dist/components/projects.d.ts.map +1 -1
  63. package/dist/components/projects.js +15 -0
  64. package/dist/components/projects.js.map +1 -1
  65. package/dist/components/runs.d.ts.map +1 -1
  66. package/dist/components/runs.js +34 -7
  67. package/dist/components/runs.js.map +1 -1
  68. package/dist/components/task-view.d.ts +3 -0
  69. package/dist/components/task-view.d.ts.map +1 -1
  70. package/dist/components/task-view.js +6 -4
  71. package/dist/components/task-view.js.map +1 -1
  72. package/dist/console.css +338 -37
  73. package/dist/console.d.ts.map +1 -1
  74. package/dist/console.js +124 -10
  75. package/dist/console.js.map +1 -1
  76. package/dist/data.d.ts +16 -1
  77. package/dist/data.d.ts.map +1 -1
  78. package/dist/data.js +47 -2
  79. package/dist/data.js.map +1 -1
  80. package/dist/docs-page.d.ts.map +1 -1
  81. package/dist/docs-page.js +14 -1
  82. package/dist/docs-page.js.map +1 -1
  83. package/dist/i18n/en.d.ts +56 -1
  84. package/dist/i18n/en.d.ts.map +1 -1
  85. package/dist/i18n/en.js +54 -3
  86. package/dist/i18n/en.js.map +1 -1
  87. package/dist/i18n/ko.d.ts.map +1 -1
  88. package/dist/i18n/ko.js +54 -3
  89. package/dist/i18n/ko.js.map +1 -1
  90. package/dist/index.d.ts +7 -5
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js +7 -5
  93. package/dist/index.js.map +1 -1
  94. package/dist/router.d.ts +25 -6
  95. package/dist/router.d.ts.map +1 -1
  96. package/dist/router.js +38 -7
  97. package/dist/router.js.map +1 -1
  98. package/dist/rules.js +5 -5
  99. package/dist/rules.js.map +1 -1
  100. package/dist/schemas.d.ts +158 -0
  101. package/dist/schemas.d.ts.map +1 -1
  102. package/dist/schemas.js +78 -0
  103. package/dist/schemas.js.map +1 -1
  104. package/dist/vocabulary.d.ts +2 -2
  105. package/dist/vocabulary.d.ts.map +1 -1
  106. package/dist/vocabulary.js +3 -0
  107. package/dist/vocabulary.js.map +1 -1
  108. package/docs/specs/cli.md +19 -11
  109. package/docs/specs/rest.md +24 -6
  110. package/package.json +1 -1
  111. package/skills/working-the-board/SKILL.md +12 -6
  112. package/src/api.ts +13 -0
  113. package/src/cli/cli.ts +441 -42
  114. package/src/cli/credentials.ts +276 -14
  115. package/src/cli/directory.ts +43 -6
  116. package/src/cli/format.ts +138 -2
  117. package/src/cli/help.ts +46 -22
  118. package/src/client.ts +60 -0
  119. package/src/components/board.tsx +401 -28
  120. package/src/components/comments.tsx +30 -49
  121. package/src/components/documents.tsx +87 -4
  122. package/src/components/event-feed.tsx +4 -0
  123. package/src/components/files.ts +49 -0
  124. package/src/components/goals.tsx +173 -106
  125. package/src/components/markdown.tsx +19 -0
  126. package/src/components/pictures.tsx +389 -0
  127. package/src/components/projects.tsx +68 -0
  128. package/src/components/runs.tsx +73 -15
  129. package/src/components/task-view.tsx +43 -2
  130. package/src/console.tsx +268 -7
  131. package/src/data.ts +72 -1
  132. package/src/docs-page.tsx +18 -1
  133. package/src/i18n/en.ts +58 -3
  134. package/src/i18n/ko.ts +58 -3
  135. package/src/index.ts +28 -1
  136. package/src/router.ts +57 -9
  137. package/src/rules.ts +5 -5
  138. package/src/schemas.ts +97 -0
  139. package/src/styles/console.css +338 -37
  140. package/src/vocabulary.ts +3 -0
package/src/console.tsx CHANGED
@@ -43,11 +43,13 @@ import { ConnectWords } from "./components/connect.js";
43
43
  import { DecisionForm } from "./components/decision-form.js";
44
44
  import { DecisionCard, DecisionList, type DecisionListProps } from "./components/decision-list.js";
45
45
  import { EventFeed, type EventFeedProps } from "./components/event-feed.js";
46
- import { GoalForm, GoalList, type GoalListProps, GoalView } from "./components/goals.js";
46
+ import { GoalForm, GoalList, type GoalListProps, GoalView, goalMonth } from "./components/goals.js";
47
+ import { PAGE_SIZE } from "./components/paged.js";
47
48
  import { ListingForm, PeopleList, type PeopleListProps } from "./components/people.js";
48
49
  import {
49
50
  MemberList,
50
51
  type MemberListProps,
52
+ PackTokenForm,
51
53
  ProjectForm,
52
54
  ProjectList,
53
55
  type ProjectListProps,
@@ -75,6 +77,8 @@ import {
75
77
  EmptyState,
76
78
  personName,
77
79
  Spinner,
80
+ StateBadge,
81
+ Time,
78
82
  } from "./components/ui.js";
79
83
  import { WorkList, type WorkListProps } from "./components/work.js";
80
84
  import { ConnectPage } from "./connect-page.js";
@@ -95,6 +99,7 @@ import {
95
99
  customPageHref,
96
100
  decisionHref,
97
101
  docHref,
102
+ type GoalTaskView,
98
103
  goalHref,
99
104
  matchRoute,
100
105
  type Navigation,
@@ -442,6 +447,101 @@ function ProjectsPage(props: {
442
447
  );
443
448
  }
444
449
 
450
+ /**
451
+ * The archive of the board (ADR-0029): the tasks put away, newest first, a page at a time from
452
+ * the server, each brought back from here by whoever may work in the project.
453
+ */
454
+ function ArchivePage(props: { readonly project: RestProject; readonly data: ConsoleData }) {
455
+ const words = useWords();
456
+ const { project, data } = props;
457
+ const key = project.key;
458
+ const { busy, error, act } = useAction();
459
+ const [items, setItems] = useState<readonly RestTask[]>([]);
460
+ const [more, setMore] = useState(false);
461
+ const [loaded, setLoaded] = useState(false);
462
+ const { archivedTasks } = data.actions;
463
+ const feedLength = data.events.length;
464
+ // The first page, read again whenever the project's feed grows: something was put away or brought back.
465
+ // biome-ignore lint/correctness/useExhaustiveDependencies: the feed's length is what triggers the read
466
+ useEffect(() => {
467
+ const controller = new AbortController();
468
+ archivedTasks({ limit: PAGE_SIZE, offset: 0 }, controller.signal)
469
+ .then((page) => {
470
+ if (!controller.signal.aborted) {
471
+ setItems(page.items);
472
+ setMore(page.more);
473
+ setLoaded(true);
474
+ }
475
+ })
476
+ .catch(() => undefined);
477
+ return () => controller.abort();
478
+ }, [archivedTasks, feedLength]);
479
+ const onMore = () =>
480
+ void archivedTasks({ limit: PAGE_SIZE, offset: items.length })
481
+ .then((page) => {
482
+ setItems((current) => [...current, ...page.items]);
483
+ setMore(page.more);
484
+ })
485
+ .catch(() => undefined);
486
+ const archive = words.archive;
487
+ return (
488
+ <>
489
+ <div className="sc-page-head">
490
+ <h1 className="sc-page-title">{archive.title}</h1>
491
+ <LinkButton href={projectHref(key)}>{words.nav.board}</LinkButton>
492
+ </div>
493
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
494
+ {!loaded ? (
495
+ <Spinner label={archive.loading} />
496
+ ) : items.length === 0 ? (
497
+ <EmptyState title={archive.empty.title} body={archive.empty.body} />
498
+ ) : (
499
+ <>
500
+ <ul className="sc-task-rows" aria-label={archive.title}>
501
+ {items.map((task) => (
502
+ <li key={task.id} className="sc-task-row">
503
+ <a className="sc-task-row-title" href={taskHref(key, task.number)}>
504
+ <span className="sc-card-number">#{task.number}</span> {task.title}
505
+ </a>
506
+ <span className="sc-task-row-meta">
507
+ <StateBadge state={task.state} />
508
+ {task.archivedAt !== null && (
509
+ <span className="sc-muted">
510
+ {archive.since} <Time iso={task.archivedAt} />
511
+ </span>
512
+ )}
513
+ <Button
514
+ size="sm"
515
+ variant="ghost"
516
+ disabled={busy}
517
+ onClick={() =>
518
+ void act(async () => {
519
+ await data.actions.restoreTask(task.id);
520
+ setItems((current) =>
521
+ current.filter((candidate) => candidate.id !== task.id),
522
+ );
523
+ })
524
+ }
525
+ >
526
+ {archive.restore}
527
+ </Button>
528
+ </span>
529
+ </li>
530
+ ))}
531
+ </ul>
532
+ {more && (
533
+ <div className="sc-show-more">
534
+ <Button variant="secondary" size="sm" onClick={onMore}>
535
+ {archive.more}
536
+ </Button>
537
+ </div>
538
+ )}
539
+ </>
540
+ )}
541
+ </>
542
+ );
543
+ }
544
+
445
545
  function TaskPage(props: {
446
546
  readonly route: Extract<Route, { name: "task" }>;
447
547
  readonly project: RestProject;
@@ -455,10 +555,43 @@ function TaskPage(props: {
455
555
  const words = useWords();
456
556
  const { route, project, data, components, navigation, busy, error, act } = props;
457
557
  const key = project.key;
458
- const task =
558
+ const current =
459
559
  route.number === undefined
460
560
  ? data.tasks.find((candidate) => candidate.id === route.id)
461
561
  : data.tasks.find((candidate) => candidate.number === route.number);
562
+ // A task put away is not among the board's (ADR-0029): it is asked for by itself.
563
+ const [fetched, setFetched] = useState<RestTask | undefined>(undefined);
564
+ const [missing, setMissing] = useState<string | undefined>(undefined);
565
+ const { fetchTask } = data.actions;
566
+ const wanted =
567
+ current === undefined && data.projectLoaded
568
+ ? route.number === undefined
569
+ ? route.id
570
+ : String(route.number)
571
+ : undefined;
572
+ useEffect(() => {
573
+ if (wanted === undefined) {
574
+ return;
575
+ }
576
+ const controller = new AbortController();
577
+ fetchTask(wanted, controller.signal)
578
+ .then((found) => {
579
+ if (!controller.signal.aborted) {
580
+ setFetched(found);
581
+ }
582
+ })
583
+ .catch(() => {
584
+ if (!controller.signal.aborted) {
585
+ setMissing(wanted);
586
+ }
587
+ });
588
+ return () => controller.abort();
589
+ }, [wanted, fetchTask]);
590
+ const task =
591
+ current ??
592
+ (fetched !== undefined && (fetched.id === route.id || fetched.number === route.number)
593
+ ? fetched
594
+ : undefined);
462
595
  const [history, setHistory] = useState<readonly RestEvent[] | undefined>(undefined);
463
596
  const [comments, setComments] = useState<readonly RestComment[] | undefined>(undefined);
464
597
  const { taskHistory, taskComments } = data.actions;
@@ -495,6 +628,9 @@ function TaskPage(props: {
495
628
  return () => controller.abort();
496
629
  }, [taskId, taskHistory, taskComments, feedLength]);
497
630
  if (task === undefined) {
631
+ if (wanted !== undefined && missing !== wanted) {
632
+ return <Spinner label={words.common.loading} />;
633
+ }
498
634
  return (
499
635
  <EmptyState
500
636
  title={words.task.noSuch.title}
@@ -545,6 +681,17 @@ function TaskPage(props: {
545
681
  void act(() => data.actions.updateDecision(decision.id, patch))
546
682
  }
547
683
  onAbandonRun={(run) => void act(() => data.actions.abandonRun(run.id))}
684
+ onArchive={(target) =>
685
+ void act(async () => {
686
+ await data.actions.archiveTask(target.id);
687
+ navigation.go(projectHref(key, "archive"));
688
+ })
689
+ }
690
+ onRestore={(target) =>
691
+ void act(async () => {
692
+ setFetched(await data.actions.restoreTask(target.id));
693
+ })
694
+ }
548
695
  onComment={(draft) =>
549
696
  void act(async () => {
550
697
  await data.actions.comment(task.id, {
@@ -592,9 +739,12 @@ function ProjectPage(props: {
592
739
  <>
593
740
  <div className="sc-page-head">
594
741
  <h1 className="sc-page-title">{project.name}</h1>
595
- <LinkButton href={newTaskHref(key)} variant="primary">
596
- {words.board.writeTask}
597
- </LinkButton>
742
+ <div className="sc-page-actions">
743
+ <LinkButton href={projectHref(key, "archive")}>{words.board.archive}</LinkButton>
744
+ <LinkButton href={newTaskHref(key)} variant="primary">
745
+ {words.board.writeTask}
746
+ </LinkButton>
747
+ </div>
598
748
  </div>
599
749
  {project.description.length > 0 && <p className="sc-lead">{project.description}</p>}
600
750
  {error !== undefined && <Callout tone="danger">{error}</Callout>}
@@ -615,6 +765,9 @@ function ProjectPage(props: {
615
765
  taskHref={href}
616
766
  onOpen={(task) => navigation.go(href(task))}
617
767
  onMove={onMove}
768
+ editHref={(task) => taskHref(key, task.number, "edit")}
769
+ onEdit={(task) => navigation.go(taskHref(key, task.number, "edit"))}
770
+ onArchive={(task) => void act(() => data.actions.archiveTask(task.id))}
618
771
  empty={
619
772
  data.tasks.length === 0 ? (
620
773
  <EmptyState
@@ -850,6 +1003,10 @@ function ProjectPage(props: {
850
1003
  </>
851
1004
  );
852
1005
  }
1006
+ const now = today();
1007
+ const towards = data.tasks.filter((task) => task.goalId === goal.id);
1008
+ const month = route.month ?? goalMonth(goal, now);
1009
+ const viewHref = (view: GoalTaskView) => goalHref(key, goal.id, { view });
853
1010
  return (
854
1011
  <>
855
1012
  <div className="sc-page-head">
@@ -862,13 +1019,62 @@ function ProjectPage(props: {
862
1019
  tasks={data.tasks}
863
1020
  taskHref={href}
864
1021
  docHref={toDoc}
865
- editHref={owner ? goalHref(key, goal.id, "edit") : undefined}
1022
+ today={now}
1023
+ editHref={owner ? goalHref(key, goal.id, { form: "edit" }) : undefined}
866
1024
  onMove={
867
1025
  owner
868
- ? (target, state) => void act(() => data.actions.updateGoal(target.id, { state }))
1026
+ ? (target, state) =>
1027
+ void act(async () => {
1028
+ await data.actions.updateGoal(target.id, { state });
1029
+ // A goal closed takes its done and dropped tasks to the archive, if the person says so (ADR-0029).
1030
+ const closed = towards.filter(
1031
+ (task) => !OPEN_TASK_STATES.includes(task.state),
1032
+ ).length;
1033
+ if (
1034
+ (state === "done" || state === "dropped") &&
1035
+ closed > 0 &&
1036
+ window.confirm(roadmap.archiveAsk(closed))
1037
+ ) {
1038
+ await data.actions.archiveGoalTasks(target.id);
1039
+ }
1040
+ })
1041
+ : undefined
1042
+ }
1043
+ onArchiveTasks={
1044
+ owner && towards.some((task) => !OPEN_TASK_STATES.includes(task.state))
1045
+ ? () => void act(() => data.actions.archiveGoalTasks(goal.id))
869
1046
  : undefined
870
1047
  }
871
1048
  busy={busy}
1049
+ view={route.view}
1050
+ viewHref={viewHref}
1051
+ board={
1052
+ <components.Board
1053
+ tasks={towards}
1054
+ taskHref={href}
1055
+ onOpen={(task) => navigation.go(href(task))}
1056
+ onMove={onMove}
1057
+ today={now}
1058
+ editHref={(task) => taskHref(key, task.number, "edit")}
1059
+ onEdit={(task) => navigation.go(taskHref(key, task.number, "edit"))}
1060
+ onArchive={(task) => void act(() => data.actions.archiveTask(task.id))}
1061
+ empty={
1062
+ towards.length === 0 ? <p className="sc-muted">{roadmap.noTasks}</p> : undefined
1063
+ }
1064
+ />
1065
+ }
1066
+ calendar={
1067
+ <components.Calendar
1068
+ tasks={towards}
1069
+ month={month}
1070
+ today={now}
1071
+ categories={data.categories}
1072
+ goals={[goal]}
1073
+ taskHref={href}
1074
+ goalHref={(target) => goalHref(key, target.id)}
1075
+ monthHref={(wanted) => goalHref(key, goal.id, { view: "calendar", month: wanted })}
1076
+ />
1077
+ }
872
1078
  />
873
1079
  </>
874
1080
  );
@@ -958,6 +1164,9 @@ function ProjectPage(props: {
958
1164
  </>
959
1165
  );
960
1166
  }
1167
+ if (route.name === "archive") {
1168
+ return <ArchivePage project={project} data={data} />;
1169
+ }
961
1170
  if (route.name === "feed") {
962
1171
  return (
963
1172
  <>
@@ -1156,6 +1365,39 @@ function ProjectSettingsPage(props: {
1156
1365
  </>
1157
1366
  );
1158
1367
  }
1368
+ if (route.form === "pack-token") {
1369
+ if (!owner) {
1370
+ return <NothingHere />;
1371
+ }
1372
+ return (
1373
+ <>
1374
+ {head}
1375
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
1376
+ <section className="sc-panel sc-panel-form" aria-label={words.projects.packToken.aria}>
1377
+ <PackTokenForm
1378
+ set={project.packToken !== null}
1379
+ busy={busy}
1380
+ onSubmit={(token) =>
1381
+ void act(async () => {
1382
+ await data.actions.setPackToken(token);
1383
+ navigation.replace(here);
1384
+ })
1385
+ }
1386
+ onRemove={
1387
+ project.packToken === null
1388
+ ? undefined
1389
+ : () =>
1390
+ void act(async () => {
1391
+ await data.actions.removePackToken();
1392
+ navigation.replace(here);
1393
+ })
1394
+ }
1395
+ onCancel={() => navigation.back(here)}
1396
+ />
1397
+ </section>
1398
+ </>
1399
+ );
1400
+ }
1159
1401
  return (
1160
1402
  <>
1161
1403
  {head}
@@ -1191,6 +1433,24 @@ function ProjectSettingsPage(props: {
1191
1433
  )}
1192
1434
  </dd>
1193
1435
  </div>
1436
+ <div>
1437
+ <dt>{settings.packToken}</dt>
1438
+ <dd>
1439
+ {project.packToken === null ? (
1440
+ <span className="sc-muted">{settings.packTokenNone}</span>
1441
+ ) : (
1442
+ <>
1443
+ {settings.packTokenSet} <Time iso={project.packToken.setAt} />
1444
+ </>
1445
+ )}
1446
+ {owner && (
1447
+ <>
1448
+ {" "}
1449
+ <a href={projectSettingsHref(key, "pack-token")}>{settings.packTokenEdit}</a>
1450
+ </>
1451
+ )}
1452
+ </dd>
1453
+ </div>
1194
1454
  <div>
1195
1455
  <dt>{form.symbol}</dt>
1196
1456
  <dd>
@@ -2073,6 +2333,7 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
2073
2333
  wide={
2074
2334
  route.name === "board" ||
2075
2335
  route.name === "calendar" ||
2336
+ (route.name === "goal" && route.form === undefined) ||
2076
2337
  (route.name === "custom" &&
2077
2338
  pages.some((candidate) => candidate.key === route.page && candidate.wide === true))
2078
2339
  }
package/src/data.ts CHANGED
@@ -6,6 +6,8 @@ import {
6
6
  type DocumentFilter,
7
7
  EVENT_KINDS,
8
8
  type FileUpload,
9
+ LIST_LIMIT,
10
+ type ProjectClient,
9
11
  type RestAnswerInput,
10
12
  type RestCategory,
11
13
  type RestCategoryInput,
@@ -44,6 +46,7 @@ import {
44
46
  type RestTask,
45
47
  type RestTaskInput,
46
48
  type RestTaskPatch,
49
+ type RestTasks,
47
50
  type RestToken,
48
51
  type RestTokenCreated,
49
52
  type RestTokenInput,
@@ -74,11 +77,26 @@ export interface ConsoleActions {
74
77
  /** Sets the image for the mark of the project the page is on, or takes it away; for an owner. */
75
78
  setProjectIcon(input: FileUpload): Promise<RestProject>;
76
79
  removeProjectIcon(): Promise<RestProject>;
80
+ /** Sets the pack token of the project the page is on, or takes it away; for an owner (ADR-0028). */
81
+ setPackToken(token: string): Promise<RestProject>;
82
+ removePackToken(): Promise<RestProject>;
77
83
  addMember(input: RestMemberInput): Promise<RestMember>;
78
84
  updateMember(personId: string, patch: RestMemberPatch): Promise<RestMember>;
79
85
  removeMember(personId: string): Promise<void>;
80
86
  createTask(input: RestTaskInput): Promise<RestTask>;
81
87
  updateTask(id: string, patch: RestTaskPatch): Promise<RestTask>;
88
+ /** One task by its id or its number, asked for by itself: a task put away is not among the board's. */
89
+ fetchTask(ref: string, signal?: AbortSignal): Promise<RestTask>;
90
+ /** Puts a done or dropped task away, or brings one back (ADR-0029). */
91
+ archiveTask(id: string): Promise<RestTask>;
92
+ restoreTask(id: string): Promise<RestTask>;
93
+ /** Puts every done or dropped task of a goal away; for an owner. Answers how many went. */
94
+ archiveGoalTasks(goalId: string): Promise<number>;
95
+ /** A page of the tasks put away, newest first. */
96
+ archivedTasks(
97
+ page: { readonly limit: number; readonly offset: number },
98
+ signal?: AbortSignal,
99
+ ): Promise<RestTasks>;
82
100
  raiseDecision(input: RestDecisionInput): Promise<RestDecision>;
83
101
  answerDecision(id: string, input: RestAnswerInput): Promise<RestDecision>;
84
102
  /** Grows a decision's record: its context, or what followed. */
@@ -201,6 +219,32 @@ async function readFeed(
201
219
  return events.slice(-FEED_KEEP);
202
220
  }
203
221
 
222
+ /** How many pages of the current tasks are read before the board is shown as it stands. */
223
+ const TASKS_MAX_PAGES = 20;
224
+
225
+ /**
226
+ * The current tasks of a project, whole: page after page while the server says there is more,
227
+ * so that a board past one page loses nothing without a word; the tasks put away are not among
228
+ * them (ADR-0029).
229
+ */
230
+ async function readTasks(
231
+ scope: ProjectClient,
232
+ signal: AbortSignal | undefined,
233
+ ): Promise<{ readonly items: RestTask[] }> {
234
+ const items: RestTask[] = [];
235
+ for (let page = 0; page < TASKS_MAX_PAGES; page += 1) {
236
+ const result = await scope.tasks(
237
+ { archived: false, limit: LIST_LIMIT, offset: page * LIST_LIMIT },
238
+ signal,
239
+ );
240
+ items.push(...result.items);
241
+ if (!result.more) {
242
+ break;
243
+ }
244
+ }
245
+ return { items };
246
+ }
247
+
204
248
  const wordsOf = (failure: unknown, fallback: string): string =>
205
249
  failure instanceof ApiError ? failure.message : fallback;
206
250
 
@@ -265,7 +309,7 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
265
309
  const [nextProject, nextTasks, nextDecisions, nextGoals, nextRuns, nextMembers] =
266
310
  await Promise.all([
267
311
  scope.get(signal),
268
- scope.tasks(undefined, signal),
312
+ readTasks(scope, signal),
269
313
  scope.decisions(undefined, signal),
270
314
  scope.goals({}, signal),
271
315
  scope.runs(undefined, signal),
@@ -454,6 +498,16 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
454
498
  await refreshWorkspace();
455
499
  return changed;
456
500
  },
501
+ async setPackToken(token) {
502
+ const changed = await scope().setPackToken({ token });
503
+ setProject(changed);
504
+ return changed;
505
+ },
506
+ async removePackToken() {
507
+ const changed = await scope().removePackToken();
508
+ setProject(changed);
509
+ return changed;
510
+ },
457
511
  async addMember(input) {
458
512
  const member = await scope().addMember(input);
459
513
  await refreshProject();
@@ -478,6 +532,23 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
478
532
  setTasks((current) => current.map((candidate) => (candidate.id === id ? task : candidate)));
479
533
  return task;
480
534
  },
535
+ fetchTask: (ref, signal) => scope().task(ref, signal),
536
+ async archiveTask(id) {
537
+ const task = await scope().archiveTask(id);
538
+ setTasks((current) => current.filter((candidate) => candidate.id !== id));
539
+ return task;
540
+ },
541
+ async restoreTask(id) {
542
+ const task = await scope().restoreTask(id);
543
+ setTasks((current) => [task, ...current.filter((candidate) => candidate.id !== id)]);
544
+ return task;
545
+ },
546
+ async archiveGoalTasks(goalId) {
547
+ const { archived } = await scope().archiveTasksOfGoal(goalId);
548
+ await refreshProject();
549
+ return archived;
550
+ },
551
+ archivedTasks: (page, signal) => scope().tasks({ archived: true, ...page }, signal),
481
552
  async raiseDecision(input) {
482
553
  const decision = await scope().raiseDecision(input);
483
554
  await refreshProject();
package/src/docs-page.tsx CHANGED
@@ -159,6 +159,22 @@ export function DocsPage(props: DocsPageProps) {
159
159
  const toDecision = (decisionId: string) => decisionHref(key, decisionId);
160
160
  /** The plain address of what the page is on. */
161
161
  const here = toDoc(path);
162
+ // The pictures put into the body are attached once the page is written, one request each.
163
+ const attachAll = async (
164
+ at: string,
165
+ files: readonly File[],
166
+ written: RestDocument,
167
+ ): Promise<RestDocument> => {
168
+ let page = written;
169
+ for (const file of files) {
170
+ page = await actions.attachDocumentFile(at, {
171
+ name: file.name,
172
+ bytes: file,
173
+ ...(file.type === "" ? {} : { contentType: file.type }),
174
+ });
175
+ }
176
+ return page;
177
+ };
162
178
  const page = document !== null && document !== undefined ? document : undefined;
163
179
  /** The folder a new page goes in: this one, or the one the page shown is in. */
164
180
  const folder = page === undefined ? path : folderOf(path);
@@ -218,6 +234,7 @@ export function DocsPage(props: DocsPageProps) {
218
234
  title: input.title,
219
235
  body: input.body,
220
236
  });
237
+ await attachAll(written.path, input.files, written);
221
238
  navigation.replace(toDoc(written.path));
222
239
  })
223
240
  }
@@ -255,7 +272,7 @@ export function DocsPage(props: DocsPageProps) {
255
272
  body: input.body,
256
273
  ...(input.baseVersion === undefined ? {} : { baseVersion: input.baseVersion }),
257
274
  });
258
- setDocument(written);
275
+ setDocument(await attachAll(page.path, input.files, written));
259
276
  navigation.replace(here);
260
277
  })
261
278
  }
package/src/i18n/en.ts CHANGED
@@ -194,12 +194,24 @@ export const en = {
194
194
  private: "The administrators own it until an owner is listed.",
195
195
  },
196
196
  },
197
+ packToken: {
198
+ aria: "The pack token",
199
+ label: "Token",
200
+ hint: "A token a person made at SkillCDN for the pack's repository, on their account page there. The console keeps it sealed, reads the pack with it for whoever may see the project, and shows it to nobody.",
201
+ placeholder: "scdn_repo_…",
202
+ set: "Set the token",
203
+ remove: "Remove the token",
204
+ },
197
205
  settings: {
198
206
  title: "Settings",
199
207
  aria: "Project settings",
200
208
  edit: "Edit the project",
201
209
  organizationsSkills: (address: string) => `The organization's: ${address}`,
202
210
  noSkills: "None: the console names no address for the organization's.",
211
+ packToken: "Pack token",
212
+ packTokenNone: "None: a public pack needs none, and a private one is not read.",
213
+ packTokenSet: "Set",
214
+ packTokenEdit: "Change it",
203
215
  },
204
216
  },
205
217
 
@@ -209,7 +221,8 @@ export const en = {
209
221
  title: "Nothing on the board yet",
210
222
  body: "Write the first task: what is to be done, and for whom.",
211
223
  },
212
- moveTo: "Move to",
224
+ archive: "Archive",
225
+ menu: "Task menu",
213
226
  decisions: (n: number) => count(n, "decision", "decisions"),
214
227
  decisionsTitle: "Decisions waiting for a person",
215
228
  filters: {
@@ -265,6 +278,10 @@ export const en = {
265
278
  : "",
266
279
  started: "Started",
267
280
  done: "Done",
281
+ archived: "Put away",
282
+ archivedAt: "Put away",
283
+ archive: "Put away",
284
+ restore: "Bring back",
268
285
  form: {
269
286
  title: "Title",
270
287
  titlePlaceholder: "What is to be done, in plain words that stand on their own.",
@@ -394,6 +411,12 @@ export const en = {
394
411
  setBy: "Set by",
395
412
  state: "State",
396
413
  nothingMore: "Nothing more was written about it.",
414
+ about: "What reaching it means",
415
+ archiveTasks: "Put the done tasks away",
416
+ archiveAsk: (n: number) =>
417
+ `Put the ${count(n, "task", "tasks")} done or dropped under this goal away as well?`,
418
+ view: "View",
419
+ views: { board: "Board", list: "List", calendar: "Calendar" },
397
420
  timeline: {
398
421
  title: "The months",
399
422
  legend: "Category colours",
@@ -555,6 +578,8 @@ export const en = {
555
578
  taskUpdated: (fields: string, task: string) => `changed ${fields} of ${task}`,
556
579
  taskCommented: (task: string, excerpt: string | undefined) =>
557
580
  `commented on ${task}${excerpt === undefined || excerpt === "" ? "" : `: ${excerpt}`}`,
581
+ taskArchived: (task: string) => `put ${task} away`,
582
+ taskRestored: (task: string) => `brought ${task} back`,
558
583
  decisionRaised: (question: string) => `asked: ${question}`,
559
584
  decisionAnswered: (question: string, option: string) => `answered "${question}": ${option}`,
560
585
  decisionUpdated: (fields: string, question: string) =>
@@ -575,6 +600,18 @@ export const en = {
575
600
  },
576
601
  },
577
602
 
603
+ archive: {
604
+ title: "Archive",
605
+ loading: "Loading the archive",
606
+ empty: {
607
+ title: "Nothing is put away",
608
+ body: "A task done or dropped is put away from its page, with its goal, or by the console after a while.",
609
+ },
610
+ since: "put away",
611
+ restore: "Bring back",
612
+ more: "Show more",
613
+ },
614
+
578
615
  runs: {
579
616
  for: "for",
580
617
  since: "since",
@@ -585,6 +622,7 @@ export const en = {
585
622
  answerIt: "answer it",
586
623
  reports: "Reports",
587
624
  handedIn: "Handed in",
625
+ picture: (name: string) => `The picture ${name}`,
588
626
  label: (agent: string, name: string) => `${agent} for ${name}`,
589
627
  },
590
628
 
@@ -601,6 +639,16 @@ export const en = {
601
639
  picture: (name: string) => `The picture ${name}`,
602
640
  label: (name: string) => `A comment by ${name}`,
603
641
  },
642
+ pictures: {
643
+ open: (name: string) => `Open the picture ${name}`,
644
+ label: (name: string) => `The picture ${name}, large`,
645
+ close: "Close",
646
+ zoomIn: "Zoom in",
647
+ zoomOut: "Zoom out",
648
+ fit: "Fit to the screen",
649
+ file: "Open the file itself",
650
+ hint: "Zoom with the wheel, the buttons or two fingers; drag to move; Escape closes.",
651
+ },
604
652
 
605
653
  skills: {
606
654
  loading: "Loading the skills",
@@ -612,13 +660,15 @@ export const en = {
612
660
  servedBy: "Served by",
613
661
  at: "at",
614
662
  connect:
615
- "An agent reads these through its own MCP connection to this address, which a person makes once:",
663
+ "An agent reads these with the console command (console skill <path>), or through its own MCP connection to this address, which a person makes once:",
616
664
  connectLink: "how, on the address's page at SkillCDN",
617
665
  status: {
618
666
  indexing: "SkillCDN is still indexing the repository. Look again in a moment.",
619
667
  failed: "SkillCDN could not index the repository. What it found is on the address's page.",
620
668
  not_found:
621
- "SkillCDN does not serve this address, or serves it only to someone signed in there. The address's page says which.",
669
+ "SkillCDN does not serve this address to the console: there is nothing there, or the pack is private and the project holds no pack token for it (Settings).",
670
+ token_invalid:
671
+ "The pack token the project holds no longer works at SkillCDN: it expired, was removed, or its maker lost the repository. An owner sets a new one on the Settings page.",
622
672
  unavailable: "SkillCDN could not be reached. The skills are as they were; look again later.",
623
673
  },
624
674
  none: {
@@ -683,6 +733,11 @@ export const en = {
683
733
  body: "Body",
684
734
  bodyPlaceholder:
685
735
  "Markdown. A link to another page's path, such as guides/onboarding, refers to that page.",
736
+ pictures: "Pictures",
737
+ addPictures: "Add pictures",
738
+ pictureHint:
739
+ "A picture pasted or chosen here is attached to the page once it is written; ![words](its name) places it in the text.",
740
+ removeFile: (name: string) => `Take ${name} out`,
686
741
  write: "Write the page",
687
742
  writeVersion: "Write a new version",
688
743
  },