@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/router.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import { isFolderPath, isSignInFailure, SIGN_IN_PARAM, type SignInFailure } from "./api.js";
2
+ import { isMonth } from "./dates.js";
2
3
 
3
4
  // The addresses of the default console (ADR-0010): the router is a function from a URL to a
4
5
  // route, and the hrefs below are its inverse. A project's pages live under `/projects/<key>`,
5
6
  // the word the REST API uses; an address names what it shows (a task by its number, a decision
6
- // by its id, a document by its path), every form has one, and the query says how a page is
7
- // shown (`?edit`, `?ask`, `?new`, `?version=`, `?q=`). The old addresses are taken to the new.
7
+ // by its id, a document by its path, a goal by its id), every form has one, and the query says
8
+ // how a page is shown (`?edit`, `?ask`, `?new`, `?version=`, `?q=`, `?month=`, and the board's
9
+ // filters). The old addresses are taken to the new.
8
10
 
9
11
  /** How a task's page is shown: as it is, or with one of its forms open. */
10
12
  export type TaskFormKind = "edit" | "ask";
@@ -16,10 +18,22 @@ export type DocsView =
16
18
  | { readonly kind: "edit" }
17
19
  | { readonly kind: "search"; readonly q: string };
18
20
 
21
+ /**
22
+ * What the board is narrowed to (ADR-0019), from the query: a category by its key (`""` for
23
+ * the tasks with none), a goal by its id (`""` for the tasks with none), a person by their id,
24
+ * or the person's own.
25
+ */
26
+ export interface BoardFilter {
27
+ readonly category?: string | undefined;
28
+ readonly goal?: string | undefined;
29
+ readonly person?: string | undefined;
30
+ readonly mine?: boolean | undefined;
31
+ }
32
+
19
33
  export type Route =
20
34
  | { readonly name: "projects" }
21
35
  | { readonly name: "new-project" }
22
- | { readonly name: "board"; readonly project: string }
36
+ | { readonly name: "board"; readonly project: string; readonly filter: BoardFilter }
23
37
  | { readonly name: "new-task"; readonly project: string }
24
38
  | {
25
39
  readonly name: "task";
@@ -38,6 +52,21 @@ export type Route =
38
52
  | { readonly name: "decisions"; readonly project: string }
39
53
  | { readonly name: "new-decision"; readonly project: string }
40
54
  | { readonly name: "decision"; readonly project: string; readonly id: string }
55
+ /** The calendar of a project: the month shown, or this one when the address names none, narrowed as the board is. */
56
+ | {
57
+ readonly name: "calendar";
58
+ readonly project: string;
59
+ readonly month: string | undefined;
60
+ readonly filter: BoardFilter;
61
+ }
62
+ | { readonly name: "roadmap"; readonly project: string }
63
+ | { readonly name: "new-goal"; readonly project: string }
64
+ | {
65
+ readonly name: "goal";
66
+ readonly project: string;
67
+ readonly id: string;
68
+ readonly form: "edit" | undefined;
69
+ }
41
70
  | { readonly name: "feed"; readonly project: string }
42
71
  | { readonly name: "skills"; readonly project: string }
43
72
  | { readonly name: "members"; readonly project: string }
@@ -47,6 +76,10 @@ export type Route =
47
76
  | { readonly name: "person-agents"; readonly id: string }
48
77
  | { readonly name: "agents" }
49
78
  | { readonly name: "new-token" }
79
+ /** What is on the person's plate across their projects. */
80
+ | { readonly name: "work" }
81
+ /** The categories tasks are sorted into; an administrator keeps them here. */
82
+ | { readonly name: "categories" }
50
83
  /** Where a person approves an agent's connection, by the code it showed, or types one. */
51
84
  | { readonly name: "connect"; readonly code: string | undefined }
52
85
  /** An address that moved: the page takes the person to `to` in its place. */
@@ -54,7 +87,15 @@ export type Route =
54
87
  | { readonly name: "not-found" };
55
88
 
56
89
  /** The pages of a project, after its key. */
57
- export type ProjectPage = "docs" | "decisions" | "feed" | "skills" | "members" | "settings";
90
+ export type ProjectPage =
91
+ | "docs"
92
+ | "decisions"
93
+ | "calendar"
94
+ | "roadmap"
95
+ | "feed"
96
+ | "skills"
97
+ | "members"
98
+ | "settings";
58
99
 
59
100
  export const PATHS = {
60
101
  /** The projects the person may see: the front page. */
@@ -62,11 +103,15 @@ export const PATHS = {
62
103
  /** Where a project's pages are: `/projects/<key>`, then the page. */
63
104
  project: "/projects",
64
105
  newProject: "/projects/new",
106
+ /** What is on the person's plate, across the projects they may see. */
107
+ work: "/work",
65
108
  people: "/people",
66
109
  /** The agents of whoever is signed in, which hold their tokens: their own page. */
67
110
  agents: "/agents",
68
111
  /** The form for a token made by hand, for a script or a console of one's own. */
69
112
  newToken: "/agents/new",
113
+ /** The categories tasks are sorted into, kept by an administrator. */
114
+ categories: "/categories",
70
115
  /** Where an agent's connection is approved (ADR-0011): `/connect/<code>`, or the code typed. */
71
116
  connect: "/connect",
72
117
  } as const;
@@ -77,7 +122,7 @@ const NUMBER = /^[1-9][0-9]{0,8}$/;
77
122
  /** A code as it may be typed into an address: checked for what it is by the page. */
78
123
  const CODE_TEXT = /^[A-Za-z0-9-]{1,16}$/;
79
124
  /** The pages of a project that take nothing after their name. */
80
- const PLAIN_PAGES: readonly Exclude<ProjectPage, "docs">[] = [
125
+ const PLAIN_PAGES: readonly Exclude<ProjectPage, "docs" | "calendar" | "roadmap">[] = [
81
126
  "decisions",
82
127
  "feed",
83
128
  "skills",
@@ -103,6 +148,19 @@ export function movedFrom(pathname: string): string | undefined {
103
148
  const taskFormOf = (params: URLSearchParams): TaskFormKind | undefined =>
104
149
  params.has("edit") ? "edit" : params.has("ask") ? "ask" : undefined;
105
150
 
151
+ /** The board's filters, as the query names them; a value that is not one is left out. */
152
+ const boardFilterOf = (params: URLSearchParams): BoardFilter => {
153
+ const category = params.get("category");
154
+ const goal = params.get("goal");
155
+ const person = params.get("person");
156
+ return {
157
+ ...(category !== null && (category === "" || PROJECT_KEY.test(category)) ? { category } : {}),
158
+ ...(goal !== null && (goal === "" || UUID.test(goal)) ? { goal } : {}),
159
+ ...(person !== null && UUID.test(person) ? { person } : {}),
160
+ ...(params.has("mine") ? { mine: true } : {}),
161
+ };
162
+ };
163
+
106
164
  /** The view the query asks for at a path, or nothing for one the path cannot be shown as. */
107
165
  const docsViewOf = (params: URLSearchParams, path: string): DocsView | undefined => {
108
166
  const q = params.get("q");
@@ -137,6 +195,9 @@ export function matchRoute(pathname: string, search = ""): Route {
137
195
  if (path === PATHS.newProject) {
138
196
  return { name: "new-project" };
139
197
  }
198
+ if (path === PATHS.work) {
199
+ return { name: "work" };
200
+ }
140
201
  if (path === PATHS.people) {
141
202
  return { name: "people" };
142
203
  }
@@ -146,6 +207,9 @@ export function matchRoute(pathname: string, search = ""): Route {
146
207
  if (path === PATHS.newToken) {
147
208
  return { name: "new-token" };
148
209
  }
210
+ if (path === PATHS.categories) {
211
+ return { name: "categories" };
212
+ }
149
213
  if (path === PATHS.connect) {
150
214
  return { name: "connect", code: undefined };
151
215
  }
@@ -167,7 +231,7 @@ export function matchRoute(pathname: string, search = ""): Route {
167
231
  return NOT_FOUND;
168
232
  }
169
233
  if (page === undefined) {
170
- return { name: "board", project };
234
+ return { name: "board", project, filter: boardFilterOf(params) };
171
235
  }
172
236
  if (page === "docs") {
173
237
  // The rest of the path is the document's, or a folder's, or nothing for the root.
@@ -205,9 +269,27 @@ export function matchRoute(pathname: string, search = ""): Route {
205
269
  }
206
270
  return UUID.test(third) ? { name: "decision", project, id: third } : NOT_FOUND;
207
271
  }
272
+ if (page === "roadmap" && third !== undefined) {
273
+ if (third === "new") {
274
+ return { name: "new-goal", project };
275
+ }
276
+ return UUID.test(third)
277
+ ? { name: "goal", project, id: third, form: params.has("edit") ? "edit" : undefined }
278
+ : NOT_FOUND;
279
+ }
208
280
  if (third !== undefined) {
209
281
  return NOT_FOUND;
210
282
  }
283
+ if (page === "calendar") {
284
+ const month = params.get("month");
285
+ if (month !== null && !isMonth(month)) {
286
+ return NOT_FOUND;
287
+ }
288
+ return { name: "calendar", project, month: month ?? undefined, filter: boardFilterOf(params) };
289
+ }
290
+ if (page === "roadmap") {
291
+ return { name: "roadmap", project };
292
+ }
211
293
  const found = PLAIN_PAGES.find((candidate) => candidate === page);
212
294
  return found === undefined ? NOT_FOUND : { name: found, project };
213
295
  }
@@ -228,6 +310,31 @@ export function projectHref(key: string, page?: ProjectPage): string {
228
310
  return page === undefined ? base : `${base}/${page}`;
229
311
  }
230
312
 
313
+ /** Where a project's board is, narrowed as the filter says; the plain board for an empty one. */
314
+ export function boardHref(key: string, filter: BoardFilter = {}): string {
315
+ const parts = filterParts(filter);
316
+ return parts.length === 0 ? projectHref(key) : `${projectHref(key)}?${parts.join("&")}`;
317
+ }
318
+
319
+ /** The filter as the query carries it, a part per narrowing; nothing for the plain board. */
320
+ function filterParts(filter: BoardFilter): string[] {
321
+ const parts: string[] = [];
322
+ // An empty value means "none": it keeps its sign; "mine" is a flag and has no value.
323
+ if (filter.category !== undefined) {
324
+ parts.push(`category=${encodeURIComponent(filter.category)}`);
325
+ }
326
+ if (filter.goal !== undefined) {
327
+ parts.push(`goal=${encodeURIComponent(filter.goal)}`);
328
+ }
329
+ if (filter.person !== undefined) {
330
+ parts.push(`person=${encodeURIComponent(filter.person)}`);
331
+ }
332
+ if (filter.mine === true) {
333
+ parts.push("mine");
334
+ }
335
+ return parts;
336
+ }
337
+
231
338
  /** Where a task is written: the form. */
232
339
  export function newTaskHref(key: string): string {
233
340
  return `${PATHS.project}/${key}/tasks/new`;
@@ -249,6 +356,24 @@ export function decisionHref(key: string, id: string): string {
249
356
  return `${PATHS.project}/${key}/decisions/${id}`;
250
357
  }
251
358
 
359
+ /** Where a project's calendar is, on a month, or on this one when none is named, narrowed as the filter says. */
360
+ export function calendarHref(key: string, month?: string, filter: BoardFilter = {}): string {
361
+ const base = projectHref(key, "calendar");
362
+ const parts = [...(month === undefined ? [] : [`month=${month}`]), ...filterParts(filter)];
363
+ return parts.length === 0 ? base : `${base}?${parts.join("&")}`;
364
+ }
365
+
366
+ /** Where a goal is set: the form. */
367
+ export function newGoalHref(key: string): string {
368
+ return `${PATHS.project}/${key}/roadmap/new`;
369
+ }
370
+
371
+ /** Where one goal is read, or changed. */
372
+ export function goalHref(key: string, id: string, form?: "edit"): string {
373
+ const base = `${PATHS.project}/${key}/roadmap/${id}`;
374
+ return form === undefined ? base : `${base}?${form}`;
375
+ }
376
+
252
377
  /**
253
378
  * Where a document of a project is read, or a folder of them listed (`""` for the root), and
254
379
  * how: a form, a version, or a search over all of them.
package/src/routes.ts CHANGED
@@ -17,8 +17,17 @@ export const REST_ROUTES = {
17
17
  tokens: "/api/v1/tokens",
18
18
  /** Connecting an agent (ADR-0011): begun as nobody, approved by a person, claimed by the command. */
19
19
  connect: "/api/v1/connect",
20
+ /** The categories tasks are sorted into (ADR-0019): the workspace's vocabulary, kept by an administrator. */
21
+ categories: "/api/v1/categories",
22
+ /** What is on the asker's plate across the projects they may see: their open tasks, and the decisions waiting. */
23
+ work: "/api/v1/work",
20
24
  } as const;
21
25
 
26
+ /** The REST path of one category, by its key. */
27
+ export function categoryPath(key: string): string {
28
+ return `${REST_ROUTES.categories}/${encodeURIComponent(key)}`;
29
+ }
30
+
22
31
  /** Where the command claims the token of a connection, with its secret. */
23
32
  export const CLAIM_PATH = `${REST_ROUTES.connect}/claim`;
24
33
 
@@ -44,6 +53,7 @@ export const PROJECT_COLLECTIONS = [
44
53
  "skills",
45
54
  "members",
46
55
  "docs",
56
+ "goals",
47
57
  ] as const;
48
58
 
49
59
  export type ProjectCollection = (typeof PROJECT_COLLECTIONS)[number];
@@ -104,3 +114,12 @@ export function projectPath(key: string, collection?: ProjectCollection, id?: st
104
114
  ? `${base}/${collection}`
105
115
  : `${base}/${collection}/${encodeURIComponent(id)}`;
106
116
  }
117
+
118
+ /**
119
+ * Where a project's own image for its mark is read, with the hash as a version where the image
120
+ * is known, so that a browser's copy is asked for again only when the image changes.
121
+ */
122
+ export function projectIconPath(key: string, icon?: { readonly sha256: string } | null): string {
123
+ const base = `${projectPath(key)}/icon`;
124
+ return icon === undefined || icon === null ? base : `${base}?v=${icon.sha256.slice(0, 16)}`;
125
+ }
package/src/schemas.ts CHANGED
@@ -7,6 +7,8 @@ import { isDocumentPath } from "./documents.js";
7
7
  import {
8
8
  MAX_AGENT_LENGTH,
9
9
  MAX_BODY_LENGTH,
10
+ MAX_CATEGORY_KEY_LENGTH,
11
+ MAX_CATEGORY_NAME_LENGTH,
10
12
  MAX_DOCUMENT_LENGTH,
11
13
  MAX_DOCUMENT_PATH_LENGTH,
12
14
  MAX_FILE_NAME_LENGTH,
@@ -19,6 +21,7 @@ import {
19
21
  MAX_PROJECT_DESCRIPTION_LENGTH,
20
22
  MAX_PROJECT_KEY_LENGTH,
21
23
  MAX_PROJECT_NAME_LENGTH,
24
+ MAX_PROJECT_SYMBOL_LENGTH,
22
25
  MAX_QUESTION_LENGTH,
23
26
  MAX_SECRET_LENGTH,
24
27
  MAX_SKILLS_ADDRESS_LENGTH,
@@ -32,9 +35,13 @@ import {
32
35
  import { hasForbiddenCodePoint } from "./text.js";
33
36
  import {
34
37
  ARTIFACT_KINDS,
38
+ CATEGORY_COLORS,
39
+ COLOR_NAMES,
35
40
  EVENT_KINDS,
41
+ GOAL_STATES,
36
42
  LINK_SOURCES,
37
43
  PERSON_ROLES,
44
+ PROJECT_ICON_TYPES,
38
45
  PROJECT_ROLES,
39
46
  PROJECT_VISIBILITIES,
40
47
  PROVIDER_KEYS,
@@ -70,6 +77,8 @@ const url = z.string().check(z.trim(), z.maxLength(MAX_URL_LENGTH), z.url({ prot
70
77
  const uuid = z.uuid();
71
78
  /** An ISO 8601 instant, as the server writes it. */
72
79
  const instant = z.iso.datetime();
80
+ /** A calendar day (`2026-10-20`): a plan is made in days, wherever the people are (ADR-0019). */
81
+ const day = z.iso.date();
73
82
  const count = z.int().check(z.nonnegative());
74
83
 
75
84
  /** A person of the workspace, as the pages show them. Nothing here is a secret. */
@@ -154,7 +163,19 @@ const projectKey = z.string().check(
154
163
  /** The address of a project's skills, as the standard spells one; the console checks it as one. */
155
164
  const skillsAddress = line(MAX_SKILLS_ADDRESS_LENGTH);
156
165
 
166
+ /** A project's mark: a letter or two, or a symbol, with no space in it; shown on the project's colour. */
167
+ const projectSymbol = line(MAX_PROJECT_SYMBOL_LENGTH).check(
168
+ z.refine((value) => !/\s/.test(value), "must have no space in it"),
169
+ );
170
+
157
171
  /** A project of the workspace, as the asker sees it: with what they are in it. */
172
+ /** A project's own image for its mark: the hash it is kept by, which `projectIconPath` reads it with, and its kind. */
173
+ export const restProjectIconSchema = z.object({
174
+ sha256: z.string().check(z.regex(/^[0-9a-f]{64}$/)),
175
+ contentType: z.enum(PROJECT_ICON_TYPES),
176
+ });
177
+ export type RestProjectIcon = z.infer<typeof restProjectIconSchema>;
178
+
158
179
  export const restProjectSchema = z.object({
159
180
  id: uuid,
160
181
  key: projectKey,
@@ -164,6 +185,12 @@ export const restProjectSchema = z.object({
164
185
  visibility: z.enum(PROJECT_VISIBILITIES),
165
186
  /** The address of the project's skills at the SkillCDN deployment, canonical, or `null` for the organization's. */
166
187
  skillsAddress: z.nullable(z.string()),
188
+ /** The colour of the project's mark, one of the names, or `null` for one chosen from its key. */
189
+ color: z.nullable(z.enum(COLOR_NAMES)),
190
+ /** The project's mark, a letter or two or a symbol, or `null` for the first letter of its name. */
191
+ symbol: z.nullable(z.string()),
192
+ /** The project's own image for its mark, shown in place of the symbol, or `null` for none. */
193
+ icon: z.nullable(restProjectIconSchema),
167
194
  /** What the asker is in it: an owner configures the project, a member works on it. */
168
195
  role: z.enum(PROJECT_ROLES),
169
196
  /** How many decisions in it wait for a person. */
@@ -188,6 +215,9 @@ export const restProjectInputSchema = z.object({
188
215
  visibility: z.optional(z.enum(PROJECT_VISIBILITIES)),
189
216
  /** Left out or `null`: the organization's skills. */
190
217
  skillsAddress: z.optional(z.nullable(skillsAddress)),
218
+ /** Left out or `null`: a colour chosen from the key, and the first letter of the name. */
219
+ color: z.optional(z.nullable(z.enum(COLOR_NAMES))),
220
+ symbol: z.optional(z.nullable(projectSymbol)),
191
221
  });
192
222
  export type RestProjectInput = z.infer<typeof restProjectInputSchema>;
193
223
 
@@ -197,6 +227,8 @@ export const restProjectPatchSchema = z.object({
197
227
  description: z.optional(paragraphs(MAX_PROJECT_DESCRIPTION_LENGTH)),
198
228
  visibility: z.optional(z.enum(PROJECT_VISIBILITIES)),
199
229
  skillsAddress: z.optional(z.nullable(skillsAddress)),
230
+ color: z.optional(z.nullable(z.enum(COLOR_NAMES))),
231
+ symbol: z.optional(z.nullable(projectSymbol)),
200
232
  });
201
233
  export type RestProjectPatch = z.infer<typeof restProjectPatchSchema>;
202
234
 
@@ -230,6 +262,48 @@ export const restTaskLinkSchema = z.object({
230
262
  });
231
263
  export type RestTaskLink = z.infer<typeof restTaskLinkSchema>;
232
264
 
265
+ /**
266
+ * A category's key: what the command and the filters say, as a project's key is. Lowercase
267
+ * letters, digits and hyphens; immutable once the category is made.
268
+ */
269
+ const categoryKey = z
270
+ .string()
271
+ .check(
272
+ z.maxLength(MAX_CATEGORY_KEY_LENGTH),
273
+ z.regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, "must be lowercase letters, digits and hyphens"),
274
+ );
275
+
276
+ /** A category tasks are sorted into (ADR-0019): the workspace's own word for a kind of work. */
277
+ export const restCategorySchema = z.object({
278
+ key: categoryKey,
279
+ name: z.string(),
280
+ /** One of the names the pages paint a category in. */
281
+ color: z.enum(CATEGORY_COLORS),
282
+ createdAt: instant,
283
+ updatedAt: instant,
284
+ });
285
+ export type RestCategory = z.infer<typeof restCategorySchema>;
286
+
287
+ /** `GET /api/v1/categories`: the workspace's categories, by name. */
288
+ export const restCategoriesSchema = z.object({ items: z.array(restCategorySchema) });
289
+ export type RestCategories = z.infer<typeof restCategoriesSchema>;
290
+
291
+ /** What `POST /api/v1/categories` is sent, by an administrator: the key, the name, and a colour or the plain one. */
292
+ export const restCategoryInputSchema = z.object({
293
+ key: categoryKey,
294
+ name: line(MAX_CATEGORY_NAME_LENGTH),
295
+ /** Left out: `gray`. */
296
+ color: z.optional(z.enum(CATEGORY_COLORS)),
297
+ });
298
+ export type RestCategoryInput = z.infer<typeof restCategoryInputSchema>;
299
+
300
+ /** What `PATCH /api/v1/categories/<key>` is sent: only what changes; the key does not. */
301
+ export const restCategoryPatchSchema = z.object({
302
+ name: z.optional(line(MAX_CATEGORY_NAME_LENGTH)),
303
+ color: z.optional(z.enum(CATEGORY_COLORS)),
304
+ });
305
+ export type RestCategoryPatch = z.infer<typeof restCategoryPatchSchema>;
306
+
233
307
  export const restTaskSchema = z.object({
234
308
  id: uuid,
235
309
  /** The task's number in its project, the one people say out loud. */
@@ -243,6 +317,18 @@ export const restTaskSchema = z.object({
243
317
  assignee: z.nullable(restPersonSchema),
244
318
  /** The task this one is part of, or `null`. */
245
319
  parentId: z.nullable(uuid),
320
+ /** The category the task is sorted into, as the workspace keeps it, or `null`. */
321
+ category: z.nullable(
322
+ z.object({ key: z.string(), name: z.string(), color: z.enum(CATEGORY_COLORS) }),
323
+ ),
324
+ /** The goal of the project's roadmap the task works towards, or `null`. */
325
+ goalId: z.nullable(uuid),
326
+ /** The plan (ADR-0019): the day the work is meant to begin, and the day it is due, each or `null`. */
327
+ startsOn: z.nullable(day),
328
+ dueOn: z.nullable(day),
329
+ /** What happened: when the task was first moved past ready, and when it was moved to done; each or `null`. The board keeps both. */
330
+ startedAt: z.nullable(instant),
331
+ doneAt: z.nullable(instant),
246
332
  links: z.array(restTaskLinkSchema),
247
333
  /** How many decisions about this task wait for a person. */
248
334
  openDecisions: count,
@@ -253,8 +339,8 @@ export const restTaskSchema = z.object({
253
339
  });
254
340
  export type RestTask = z.infer<typeof restTaskSchema>;
255
341
 
256
- /** `GET /api/v1/projects/<key>/tasks`: the board, newest first. */
257
- export const restTasksSchema = z.object({ items: z.array(restTaskSchema) });
342
+ /** `GET /api/v1/projects/<key>/tasks`: a page of the board, newest first, and whether there is more after it. */
343
+ export const restTasksSchema = z.object({ items: z.array(restTaskSchema), more: z.boolean() });
258
344
  export type RestTasks = z.infer<typeof restTasksSchema>;
259
345
 
260
346
  const taskLinkInput = z.object({
@@ -270,6 +356,13 @@ export const restTaskInputSchema = z.object({
270
356
  priority: z.optional(z.enum(TASK_PRIORITIES)),
271
357
  assigneeId: z.optional(z.nullable(uuid)),
272
358
  parentId: z.optional(z.nullable(uuid)),
359
+ /** The key of a category of the workspace, or `null` for none. */
360
+ category: z.optional(z.nullable(categoryKey)),
361
+ /** A goal of the project, or `null` for none. */
362
+ goalId: z.optional(z.nullable(uuid)),
363
+ /** The plan, in days; the due day is not before the start. */
364
+ startsOn: z.optional(z.nullable(day)),
365
+ dueOn: z.optional(z.nullable(day)),
273
366
  links: z.optional(z.array(taskLinkInput).check(z.maxLength(MAX_LINKS))),
274
367
  });
275
368
  export type RestTaskInput = z.infer<typeof restTaskInputSchema>;
@@ -282,10 +375,68 @@ export const restTaskPatchSchema = z.object({
282
375
  priority: z.optional(z.enum(TASK_PRIORITIES)),
283
376
  assigneeId: z.optional(z.nullable(uuid)),
284
377
  parentId: z.optional(z.nullable(uuid)),
378
+ category: z.optional(z.nullable(categoryKey)),
379
+ goalId: z.optional(z.nullable(uuid)),
380
+ startsOn: z.optional(z.nullable(day)),
381
+ dueOn: z.optional(z.nullable(day)),
285
382
  links: z.optional(z.array(taskLinkInput).check(z.maxLength(MAX_LINKS))),
286
383
  });
287
384
  export type RestTaskPatch = z.infer<typeof restTaskPatchSchema>;
288
385
 
386
+ /** How many tasks a goal has in each state: what its progress is read from, live. */
387
+ export const restTaskCountsSchema = z.object({
388
+ idea: count,
389
+ ready: count,
390
+ in_progress: count,
391
+ in_review: count,
392
+ done: count,
393
+ dropped: count,
394
+ });
395
+ export type RestTaskCounts = z.infer<typeof restTaskCountsSchema>;
396
+
397
+ /**
398
+ * A goal of a project's roadmap (ADR-0019): what the project works towards, with the day it is
399
+ * meant to be reached by, and how far the tasks under it are.
400
+ */
401
+ export const restGoalSchema = z.object({
402
+ id: uuid,
403
+ title: z.string(),
404
+ /** Markdown: what reaching the goal means. Shown as text or rendered to elements, never as HTML. */
405
+ body: z.string(),
406
+ state: z.enum(GOAL_STATES),
407
+ /** The day the goal is meant to be reached by, or `null`. */
408
+ dueOn: z.nullable(day),
409
+ createdBy: restPersonSchema,
410
+ /** The tasks that work towards it, counted by state. */
411
+ taskCounts: restTaskCountsSchema,
412
+ createdAt: instant,
413
+ updatedAt: instant,
414
+ });
415
+ export type RestGoal = z.infer<typeof restGoalSchema>;
416
+
417
+ /** `GET /api/v1/projects/<key>/goals`: the roadmap, the active goals first. */
418
+ export const restGoalsSchema = z.object({ items: z.array(restGoalSchema) });
419
+ export type RestGoals = z.infer<typeof restGoalsSchema>;
420
+
421
+ /** What `POST /api/v1/projects/<key>/goals` is sent: the title, and the rest with defaults. */
422
+ export const restGoalInputSchema = z.object({
423
+ title: line(MAX_TITLE_LENGTH),
424
+ body: z.optional(body),
425
+ /** Left out: `planned`. */
426
+ state: z.optional(z.enum(GOAL_STATES)),
427
+ dueOn: z.optional(z.nullable(day)),
428
+ });
429
+ export type RestGoalInput = z.infer<typeof restGoalInputSchema>;
430
+
431
+ /** What `PATCH /api/v1/projects/<key>/goals/<id>` is sent: only what changes. */
432
+ export const restGoalPatchSchema = z.object({
433
+ title: z.optional(line(MAX_TITLE_LENGTH)),
434
+ body: z.optional(body),
435
+ state: z.optional(z.enum(GOAL_STATES)),
436
+ dueOn: z.optional(z.nullable(day)),
437
+ });
438
+ export type RestGoalPatch = z.infer<typeof restGoalPatchSchema>;
439
+
289
440
  export const restDecisionOptionSchema = z.object({
290
441
  /** What an answer names: stable for the life of the decision. */
291
442
  id: z.string(),
@@ -324,10 +475,32 @@ export const restDecisionSchema = z.object({
324
475
  });
325
476
  export type RestDecision = z.infer<typeof restDecisionSchema>;
326
477
 
327
- /** `GET /api/v1/projects/<key>/decisions`: the ones that wait first, newest first within each. */
328
- export const restDecisionsSchema = z.object({ items: z.array(restDecisionSchema) });
478
+ /** `GET /api/v1/projects/<key>/decisions`: a page, the ones that wait first, newest first within each, and whether there is more. */
479
+ export const restDecisionsSchema = z.object({
480
+ items: z.array(restDecisionSchema),
481
+ more: z.boolean(),
482
+ });
329
483
  export type RestDecisions = z.infer<typeof restDecisionsSchema>;
330
484
 
485
+ /** The project a thing on a person's plate belongs to, named and marked for the page to say, link and show it. */
486
+ const projectRef = z.object({
487
+ key: projectKey,
488
+ name: z.string(),
489
+ color: z.nullable(z.enum(COLOR_NAMES)),
490
+ symbol: z.nullable(z.string()),
491
+ icon: z.nullable(restProjectIconSchema),
492
+ });
493
+
494
+ /**
495
+ * `GET /api/v1/work`: what is on the asker's plate across the projects they may see (ADR-0019):
496
+ * the open tasks assigned to them, and the decisions that wait for a person, each with its project.
497
+ */
498
+ export const restWorkSchema = z.object({
499
+ tasks: z.array(z.object({ project: projectRef, task: restTaskSchema })),
500
+ decisions: z.array(z.object({ project: projectRef, decision: restDecisionSchema })),
501
+ });
502
+ export type RestWork = z.infer<typeof restWorkSchema>;
503
+
331
504
  /** What `POST /api/v1/projects/<key>/decisions` is sent: the question, its context, and the options. */
332
505
  export const restDecisionInputSchema = z.object({
333
506
  question: line(MAX_QUESTION_LENGTH),
@@ -363,7 +536,7 @@ export type RestAnswerInput = z.infer<typeof restAnswerInputSchema>;
363
536
  export const restEventDataSchema = z.object({
364
537
  number: z.optional(z.int()),
365
538
  title: z.optional(z.string()),
366
- /** For `task.updated`, `project.updated` and `decision.updated`: which fields changed. */
539
+ /** For `task.updated`, `project.updated`, `goal.updated` and `decision.updated`: which fields changed. */
367
540
  fields: z.optional(z.array(z.string())),
368
541
  /** For the events about a document: its path, and the version written. */
369
542
  path: z.optional(z.string()),
@@ -406,6 +579,8 @@ export const restEventSchema = z.object({
406
579
  runId: z.nullable(uuid),
407
580
  /** The document it is about, or `null`. */
408
581
  documentId: z.nullable(uuid),
582
+ /** The goal it is about, or `null`. */
583
+ goalId: z.nullable(uuid),
409
584
  data: restEventDataSchema,
410
585
  createdAt: instant,
411
586
  });
@@ -458,6 +633,11 @@ export const restRunSchema = z.object({
458
633
  status: z.enum(RUN_STATUSES),
459
634
  startedAt: instant,
460
635
  endedAt: z.nullable(instant),
636
+ /**
637
+ * When the agent was last heard from on this run: when it began, reported, handed in or
638
+ * asked. The board cannot know whether an agent still runs; this is what it knows.
639
+ */
640
+ lastHeardAt: instant,
461
641
  /** What the agent said when the run ended, in Markdown, or `null`. */
462
642
  summary: z.nullable(z.string()),
463
643
  /** Oldest first. */
@@ -468,8 +648,8 @@ export const restRunSchema = z.object({
468
648
  });
469
649
  export type RestRun = z.infer<typeof restRunSchema>;
470
650
 
471
- /** `GET /api/v1/projects/<key>/runs`: the runs, newest first. */
472
- export const restRunsSchema = z.object({ items: z.array(restRunSchema) });
651
+ /** `GET /api/v1/projects/<key>/runs`: a page of the runs, newest first, and whether there is more. */
652
+ export const restRunsSchema = z.object({ items: z.array(restRunSchema), more: z.boolean() });
473
653
  export type RestRuns = z.infer<typeof restRunsSchema>;
474
654
 
475
655
  /** `GET .../events?after=`: what happened after that number, oldest first. */