@skillcdn/console 0.1.10 → 0.1.12

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 (145) hide show
  1. package/README.md +6 -5
  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 +49 -9
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/format.d.ts +2 -0
  10. package/dist/cli/format.d.ts.map +1 -1
  11. package/dist/cli/format.js +7 -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 +10 -9
  15. package/dist/cli/help.js.map +1 -1
  16. package/dist/client.d.ts +14 -5
  17. package/dist/client.d.ts.map +1 -1
  18. package/dist/client.js +13 -1
  19. package/dist/client.js.map +1 -1
  20. package/dist/components/board.d.ts +1 -4
  21. package/dist/components/board.d.ts.map +1 -1
  22. package/dist/components/board.js +6 -13
  23. package/dist/components/board.js.map +1 -1
  24. package/dist/components/calendar.d.ts +21 -1
  25. package/dist/components/calendar.d.ts.map +1 -1
  26. package/dist/components/calendar.js +69 -9
  27. package/dist/components/calendar.js.map +1 -1
  28. package/dist/components/decision-list.d.ts.map +1 -1
  29. package/dist/components/decision-list.js +11 -9
  30. package/dist/components/decision-list.js.map +1 -1
  31. package/dist/components/event-feed.d.ts.map +1 -1
  32. package/dist/components/event-feed.js +11 -7
  33. package/dist/components/event-feed.js.map +1 -1
  34. package/dist/components/paged.d.ts +14 -0
  35. package/dist/components/paged.d.ts.map +1 -0
  36. package/dist/components/paged.js +28 -0
  37. package/dist/components/paged.js.map +1 -0
  38. package/dist/components/people.js +2 -2
  39. package/dist/components/people.js.map +1 -1
  40. package/dist/components/projects.d.ts +22 -2
  41. package/dist/components/projects.d.ts.map +1 -1
  42. package/dist/components/projects.js +37 -9
  43. package/dist/components/projects.js.map +1 -1
  44. package/dist/components/runs.d.ts.map +1 -1
  45. package/dist/components/runs.js +3 -3
  46. package/dist/components/runs.js.map +1 -1
  47. package/dist/components/shell.d.ts +2 -1
  48. package/dist/components/shell.d.ts.map +1 -1
  49. package/dist/components/shell.js +3 -3
  50. package/dist/components/shell.js.map +1 -1
  51. package/dist/components/task-form.js +2 -2
  52. package/dist/components/task-form.js.map +1 -1
  53. package/dist/components/task-list.d.ts +1 -1
  54. package/dist/components/task-list.d.ts.map +1 -1
  55. package/dist/components/task-list.js +6 -4
  56. package/dist/components/task-list.js.map +1 -1
  57. package/dist/components/task-view.js +1 -1
  58. package/dist/components/task-view.js.map +1 -1
  59. package/dist/components/ui.d.ts +7 -2
  60. package/dist/components/ui.d.ts.map +1 -1
  61. package/dist/components/ui.js +9 -4
  62. package/dist/components/ui.js.map +1 -1
  63. package/dist/components/work.d.ts.map +1 -1
  64. package/dist/components/work.js +7 -3
  65. package/dist/components/work.js.map +1 -1
  66. package/dist/console.css +149 -2
  67. package/dist/console.d.ts +38 -4
  68. package/dist/console.d.ts.map +1 -1
  69. package/dist/console.js +84 -23
  70. package/dist/console.js.map +1 -1
  71. package/dist/data.d.ts +5 -2
  72. package/dist/data.d.ts.map +1 -1
  73. package/dist/data.js +15 -2
  74. package/dist/data.js.map +1 -1
  75. package/dist/i18n/en.d.ts +17 -11
  76. package/dist/i18n/en.d.ts.map +1 -1
  77. package/dist/i18n/en.js +18 -14
  78. package/dist/i18n/en.js.map +1 -1
  79. package/dist/i18n/ko.d.ts.map +1 -1
  80. package/dist/i18n/ko.js +18 -14
  81. package/dist/i18n/ko.js.map +1 -1
  82. package/dist/index.d.ts +6 -5
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +6 -5
  85. package/dist/index.js.map +1 -1
  86. package/dist/limits.d.ts +6 -0
  87. package/dist/limits.d.ts.map +1 -1
  88. package/dist/limits.js +6 -0
  89. package/dist/limits.js.map +1 -1
  90. package/dist/marks.d.ts +12 -0
  91. package/dist/marks.d.ts.map +1 -0
  92. package/dist/marks.js +31 -0
  93. package/dist/marks.js.map +1 -0
  94. package/dist/router.d.ts +19 -3
  95. package/dist/router.d.ts.map +1 -1
  96. package/dist/router.js +28 -5
  97. package/dist/router.js.map +1 -1
  98. package/dist/routes.d.ts +7 -0
  99. package/dist/routes.d.ts.map +1 -1
  100. package/dist/routes.js +8 -0
  101. package/dist/routes.js.map +1 -1
  102. package/dist/schemas.d.ts +123 -3
  103. package/dist/schemas.d.ts.map +1 -1
  104. package/dist/schemas.js +42 -10
  105. package/dist/schemas.js.map +1 -1
  106. package/dist/vocabulary.d.ts +10 -4
  107. package/dist/vocabulary.d.ts.map +1 -1
  108. package/dist/vocabulary.js +8 -3
  109. package/dist/vocabulary.js.map +1 -1
  110. package/docs/specs/cli.md +7 -5
  111. package/docs/specs/rest.md +18 -12
  112. package/package.json +1 -1
  113. package/skills/building-a-console/SKILL.md +2 -2
  114. package/skills/working-the-board/SKILL.md +1 -1
  115. package/src/api.ts +12 -0
  116. package/src/cli/cli.ts +51 -8
  117. package/src/cli/format.ts +8 -6
  118. package/src/cli/help.ts +10 -10
  119. package/src/client.ts +29 -6
  120. package/src/components/board.tsx +6 -30
  121. package/src/components/calendar.tsx +168 -56
  122. package/src/components/decision-list.tsx +38 -33
  123. package/src/components/event-feed.tsx +34 -27
  124. package/src/components/paged.tsx +39 -0
  125. package/src/components/people.tsx +5 -5
  126. package/src/components/projects.tsx +132 -12
  127. package/src/components/runs.tsx +9 -3
  128. package/src/components/shell.tsx +8 -5
  129. package/src/components/task-form.tsx +2 -2
  130. package/src/components/task-list.tsx +35 -32
  131. package/src/components/task-view.tsx +1 -1
  132. package/src/components/ui.tsx +14 -5
  133. package/src/components/work.tsx +12 -2
  134. package/src/console.tsx +158 -25
  135. package/src/data.ts +20 -4
  136. package/src/i18n/en.ts +19 -14
  137. package/src/i18n/ko.ts +19 -14
  138. package/src/index.ts +9 -0
  139. package/src/limits.ts +6 -0
  140. package/src/marks.ts +40 -0
  141. package/src/router.ts +45 -7
  142. package/src/routes.ts +9 -0
  143. package/src/schemas.ts +48 -8
  144. package/src/styles/console.css +149 -2
  145. package/src/vocabulary.ts +14 -4
package/docs/specs/cli.md CHANGED
@@ -23,7 +23,7 @@ The board is a project's ([ADR-0008](../adr/0008-a-workspace-holds-projects-and-
23
23
  | `projects` | `GET /api/v1/projects` | one line per project: key, name, what the person is in it, what waits in it |
24
24
  | `use <key>` | `GET /api/v1/projects/<key>` | that the directory works in the project from now on; writes `.skillcdn-console.json` |
25
25
  | `work` | `GET /api/v1/work` | what is on the person's plate across their projects: the open tasks assigned to them, the sooner due first, each with its project; and the decisions waiting. Needs no project |
26
- | `tasks [--state <state>] [--open] [--mine] [--category <key>] [--goal <id>] [--due <day\|today\|week\|overdue>]` | `GET .../tasks` with the filters | one line per task: number, state, priority, title, owner, assignee, category, due day, what waits on it |
26
+ | `tasks [--state <state>] [--open] [--mine] [--category <key>] [--goal <id>] [--due <day\|today\|week\|overdue>] [--limit <n>] [--offset <n>]` | `GET .../tasks` with the filters and the page | one line per task: number, state, priority, title, owner, assignee, category, due day, what waits on it; a page at a time |
27
27
  | `task <number\|id>` | `GET .../tasks/<ref>`, the decisions about it, the runs on it | the task in full, with its plan and its moments |
28
28
  | `task new <title> [--body <markdown>\|--file <path>] [--state <state>] [--priority <priority>] [--category <key>] [--goal <id>] [--starts <day>] [--due <day>] [--assignee <login\|me>] [--parent <number\|id>]` | `POST .../tasks` | the number and the id |
29
29
  | `task edit <number\|id> [--title <title>] [--body ...] [the same flags; `none` clears a field]` | `GET .../tasks/<ref>`, then `PATCH .../tasks/<id>` | the task in full, as it is now |
@@ -33,15 +33,15 @@ The board is a project's ([ADR-0008](../adr/0008-a-workspace-holds-projects-and-
33
33
  | `goal edit <id> [--title <title>] [--body ...] [--state <state>] [--due <day\|none>]` | `PATCH .../goals/<id>` | the goal in full, as it is now |
34
34
  | `categories` | `GET /api/v1/categories` | one line per category: key, name, colour |
35
35
  | `skills` | `GET .../skills` | the project's skills, or the organization's: each with its description and the URI an agent loads it by through its own SkillCDN connection; or why there are none to show |
36
- | `take <number\|id> [--agent <name>]` | `GET .../tasks/<ref>`, then `POST .../runs` | the run that began, the task in full, and what to do next |
36
+ | `take <number\|id> [--agent <name>]` | `GET .../tasks/<ref>`, then `POST .../runs` | the run that began, the task in full, and what to do next; a run open on the task is taken over ([ADR-0020](../adr/0020-the-console-does-not-claim-an-agent-is-at-work-a-run-says-when-it-was-last-heard-from-and-a-new-run-takes-over.md)) |
37
37
  | `report <markdown>`, `report --file <path>`, `report -` | `POST .../runs/<id>/reports` | how many reports the run carries |
38
38
  | `hand-in <https url \| file path> [--label <words>]` | `POST .../runs/<id>/artifacts` for a link; `POST .../runs/<id>/files` for a file, sent as a form with its name and its media type by extension | how many artifacts |
39
39
  | `ask "<question>" --option "<label>" ... [--body <markdown>\|--file <path>] [--wait <seconds>]` | `POST .../decisions` with `runId`, then `GET .../decisions/<id>?wait=` | the decision raised, its options; then the answer, or that it still waits |
40
40
  | `decision <id> [--wait <seconds>]` | `GET .../decisions/<id>`, with `?wait=` while waiting | the decision; with a wait, the answer or that it still waits |
41
- | `decisions [--open]` | `GET .../decisions` | one line per decision |
41
+ | `decisions [--open] [--limit <n>] [--offset <n>]` | `GET .../decisions` | one line per decision; a page at a time |
42
42
  | `finish [--summary <markdown>\|--file <path>]`, `fail [...]` | `POST .../runs/<id>/end` | how the run ended |
43
43
  | `abandon [<run id>] [--summary <markdown>]` | `POST .../runs/<id>/end`, as `abandoned` | the same |
44
- | `runs [--task <number\|id>] [--open] [--mine]` | `GET .../runs` | one line per run |
44
+ | `runs [--task <number\|id>] [--open] [--mine] [--limit <n>] [--offset <n>]` | `GET .../runs` | one line per run, with when an open one was last heard from; a page at a time |
45
45
  | `run <id>` | `GET .../runs/<id>` | the run in full, with its reports, what it handed in and where each file is read |
46
46
  | `decision <id> --outcome <markdown>\|--file <path>` | `PATCH .../decisions/<id>` | the decision as a record, with what followed written down |
47
47
  | `docs [<folder>] [--search <words>] [--archived]` | `GET .../docs?folder=` or `?q=` | the folders and the pages of a folder, or the pages found by words: one line per page |
@@ -63,6 +63,8 @@ A project's pages of Markdown ([ADR-0009](../adr/0009-documents-are-pages-of-mar
63
63
 
64
64
  `report`, `hand-in`, `ask`, `finish` and `fail` act on a run. Named with `--run <id>`, that one; else the one open run begun with this token in the project (`GET .../runs?open=true&mine=true`). None open, or several: the command exits with `1` and says so, listing the open ones, so that an agent working two tasks at once says which.
65
65
 
66
+ `tasks`, `decisions` and `runs` show a page at a time: `DEFAULT_PAGE_LIMIT` lines unless `--limit <n>` says otherwise (up to `LIST_LIMIT`), from `--offset <n>` on, and a last line, `... more: --offset <n>`, says how to ask for the next page when there is one; with `--json`, the console's answer as it is, `more` included. An agent reads the first page and asks for the next only when what it looks for is not on it.
67
+
66
68
  ## Waiting for a decision
67
69
 
68
70
  `ask` waits 60 seconds for the answer unless `--wait` says otherwise, within what an agent's shell gives one command; `decision <id> --wait <seconds>` waits as long as it is told, a day at most. The command asks the console to hold each request for up to 50 seconds (`?wait=`), the server's own bound, and repeats while the time lasts, with a breath between requests; the answer arrives as soon as a person gives it, since the console is woken through the database's own channel. A decision that still waits when the time is up is exit code `3`: the run waits with it, so the agent keeps waiting with `console decision <id> --wait 100`, within what its shell gives one command, and goes on meanwhile only with work that does not depend on the answer. `ask` prints the decision's id as soon as it is raised, before any waiting, so that a wait cut short loses nothing; with `--json` it prints the decision then, and again with its answer when that comes.
@@ -73,7 +75,7 @@ A project's pages of Markdown ([ADR-0009](../adr/0009-documents-are-pages-of-mar
73
75
 
74
76
  ## Output and exit codes
75
77
 
76
- Plain text on standard output: one line per thing in a list, a few lines for one thing in full, with ids given, since the commands take them, and the vocabulary's own words (`in_progress`, `waiting`), since the options take them. What went wrong goes to standard error as the console's stable code and its words (`run.task_taken: an agent is at work on the task already`), with a hint when there is something to do about it.
78
+ Plain text on standard output: one line per thing in a list, a few lines for one thing in full, with ids given, since the commands take them, and the vocabulary's own words (`in_progress`, `waiting`), since the options take them. What went wrong goes to standard error as the console's stable code and its words (`run.task_closed: the task is done or dropped`), with a hint when there is something to do about it.
77
79
 
78
80
  | Exit code | Meaning |
79
81
  |---|---|
@@ -20,6 +20,8 @@ The API is the board as its members see it: the people, the projects, and in eac
20
20
  |---|---|---|
21
21
  | 400 | `request.invalid` | A query parameter or the body does not parse; the message names the first field at fault. |
22
22
  | 400 | `project.invalid_address` | The skills address is not one, as the standard spells them. |
23
+ | 400 | `project.invalid_icon` | The image for the mark is not a PNG, a JPEG, a GIF or a WebP, by its first bytes. |
24
+ | 404 | `project.no_icon` | The project has no image for its mark. |
23
25
  | 400 | `member.invalid_person` | The person to list is not one of the workspace. |
24
26
  | 400 | `task.invalid_assignee` | The assignee is not a person who may work in the project. |
25
27
  | 400 | `task.invalid_parent` | The parent is not a task of the project, is the task itself, or would make a loop. |
@@ -55,7 +57,7 @@ The API is the board as its members see it: the people, the projects, and in eac
55
57
  | 409 | `connect.approved` | The connection was approved already; the command claims its token. |
56
58
  | 429 | `connect.too_many` | As many agents ask to connect as the console holds at once; try again in a few minutes. |
57
59
  | 409 | `person.last_admin` | The change would leave the board without an administrator. |
58
- | 409 | `run.over`, `run.task_taken`, `run.task_closed` | The run has ended already; an agent is at work on the task already; the task is done or dropped. |
60
+ | 409 | `run.over`, `run.task_closed` | The run has ended already; the task is done or dropped. A run open on a task is no refusal: a new run takes it over ([ADR-0020](../adr/0020-the-console-does-not-claim-an-agent-is-at-work-a-run-says-when-it-was-last-heard-from-and-a-new-run-takes-over.md)). |
59
61
  | 413 | `request.too_large` | The body is over the limit; for a file handed in or attached, the file is over `MAX_FILE_BYTES`. |
60
62
  | 503 | `file.store_unavailable` | The bytes of files are kept in a bucket, and it did not answer or refused the console; `retry-after` says when to try again. Nothing was written on the run or the page. |
61
63
 
@@ -75,11 +77,11 @@ Takes `role` and answers the person. For an administrator signed in on the conso
75
77
 
76
78
  ### `GET /api/v1/projects`
77
79
 
78
- `{ "items": [project, ...] }`: the projects the asker may see, by name. A project carries its `key` (what paths and the command say; lowercase letters, digits and hyphens; immutable), `name`, `description`, `visibility` (`workspace`: everyone of the workspace is a member; `private`: only those listed), `skillsAddress` (canonical, or `null` for the organization's), `role` (what the asker is in it: `owner`, who configures it, or `member`, who works in it), `openDecisions` and `openRuns` (what waits in it), `createdAt` and `updatedAt`. A workspace administrator sees every project, as its owner; a member sees those open to the workspace, as a member, and those they are listed in, as what they are listed.
80
+ `{ "items": [project, ...] }`: the projects the asker may see, by name. A project carries its `key` (what paths and the command say; lowercase letters, digits and hyphens; immutable), `name`, `description`, `visibility` (`workspace`: everyone of the workspace is a member; `private`: only those listed), `skillsAddress` (canonical, or `null` for the organization's), its mark, `color` (one of `COLOR_NAMES`, or `null`) and `symbol` (up to `MAX_PROJECT_SYMBOL_LENGTH` characters with no whitespace, or `null`; where either is `null` the pages choose one from the key and the name, `projectColorOf` and `projectSymbolOf`, so that projects are told apart either way), `icon` (the project's own image for its mark, shown in place of the symbol: its `sha256` and its `contentType`, one of `PROJECT_ICON_TYPES`; or `null`), `role` (what the asker is in it: `owner`, who configures it, or `member`, who works in it), `openDecisions` (the decisions waiting in it) and `openRuns` (the runs open in it: begun and not ended, which says nothing of whether an agent is still at them, [ADR-0020](../adr/0020-the-console-does-not-claim-an-agent-is-at-work-a-run-says-when-it-was-last-heard-from-and-a-new-run-takes-over.md)), `createdAt` and `updatedAt`. A workspace administrator sees every project, as its owner; a member sees those open to the workspace, as a member, and those they are listed in, as what they are listed.
79
81
 
80
82
  ### `POST /api/v1/projects`
81
83
 
82
- Takes `key` and `name`, and optionally `description`, `visibility` (default `private`) and `skillsAddress` (checked as the standard spells one, kept canonical; `project.invalid_address`). For whoever is signed in on the console's own pages, never with a token; they become the project's owner. Answers `201` with the project and writes a `project.created` event in it, with `key` and `name`. A key taken is `project.key_taken`.
84
+ Takes `key` and `name`, and optionally `description`, `visibility` (default `private`), `skillsAddress` (checked as the standard spells one, kept canonical; `project.invalid_address`), `color` and `symbol`. For whoever is signed in on the console's own pages, never with a token; they become the project's owner. Answers `201` with the project and writes a `project.created` event in it, with `key` and `name`. A key taken is `project.key_taken`.
83
85
 
84
86
  ### `GET /api/v1/events?after=&limit=`
85
87
 
@@ -99,7 +101,7 @@ Kept by an administrator signed in on the console's own pages, never with a toke
99
101
 
100
102
  ### `GET /api/v1/work`
101
103
 
102
- `{ "tasks": [{ "project": { "key", "name" }, "task" }, ...], "decisions": [{ "project", "decision" }, ...] }`: what is on the asker's plate across the projects they may see ([ADR-0019](../adr/0019-a-task-is-planned-in-days-the-board-keeps-its-moments-and-goals-and-categories-give-the-work-its-shape.md)): the open tasks assigned to them, the sooner due first and those with no day last, and the decisions that wait for a person, the longest waiting first; each with the project it belongs to, so that a page says and links it. At most `LIST_LIMIT` of each.
104
+ `{ "tasks": [{ "project": { "key", "name", "color", "symbol", "icon" }, "task" }, ...], "decisions": [{ "project", "decision" }, ...] }`: what is on the asker's plate across the projects they may see ([ADR-0019](../adr/0019-a-task-is-planned-in-days-the-board-keeps-its-moments-and-goals-and-categories-give-the-work-its-shape.md)): the open tasks assigned to them, the sooner due first and those with no day last, and the decisions that wait for a person, the longest waiting first; each with the project it belongs to, so that a page says and links it. At most `LIST_LIMIT` of each.
103
105
 
104
106
  ### `GET /api/v1/tokens` and `POST /api/v1/tokens`
105
107
 
@@ -129,7 +131,11 @@ Everything below is under `/api/v1/projects/<key>`, for whoever may see the proj
129
131
 
130
132
  ### `GET /api/v1/projects/<key>` and `PATCH /api/v1/projects/<key>`
131
133
 
132
- The project, as the asker sees it. A patch, for an owner signed in, takes any of `name`, `description`, `visibility` and `skillsAddress` (`null` for the organization's), and writes a `project.updated` event naming the `fields`; what is already so writes nothing. The key does not change.
134
+ The project, as the asker sees it. A patch, for an owner signed in, takes any of `name`, `description`, `visibility`, `skillsAddress` (`null` for the organization's), `color` and `symbol` (`null` for none), and writes a `project.updated` event naming the `fields`; what is already so writes nothing. The key does not change.
135
+
136
+ ### `POST /api/v1/projects/<key>/icon`, `DELETE .../icon` and `GET .../icon`
137
+
138
+ The project's own image for its mark, in place of the symbol on the colour. `POST`, for an owner signed in on the console's own pages, takes a form with a part called `file`: a PNG, a JPEG, a GIF or a WebP of at most `MAX_PROJECT_ICON_BYTES`, known by its first bytes and never by what the form says of it (`project.invalid_icon`; `request.too_large`). The bytes are kept by their hash, the project is answered with its `icon`, and the board hears `project.updated` with `fields: ["icon"]`; the same image again writes nothing. `DELETE` takes the image away and answers the project. `GET`, for whoever may see the project, answers the bytes with their type, the hash as `ETag` and `Cache-Control: private, max-age=86400`, or `304` to a matching `If-None-Match`; a project without an image is `project.no_icon`. The pages read it at `projectIconPath(key, icon)`, which carries the hash as a version.
133
139
 
134
140
  ### `GET /api/v1/projects/<key>/members` and `POST /api/v1/projects/<key>/members`
135
141
 
@@ -139,9 +145,9 @@ The project, as the asker sees it. A patch, for an owner signed in, takes any of
139
145
 
140
146
  For an owner signed in. The patch takes `role` and writes `project.member_changed`; the removal answers `204` and writes `project.member_removed`. A person not listed is `member.not_found`. A project may be left without an owner listed; the administrators own it then.
141
147
 
142
- ### `GET /api/v1/projects/<key>/tasks?state=&open=&category=&goal=&assignee=&dueBy=`
148
+ ### `GET /api/v1/projects/<key>/tasks?state=&open=&category=&goal=&assignee=&dueBy=&limit=&offset=`
143
149
 
144
- `{ "items": [task, ...] }`: the project's board, newest first, at most `LIST_LIMIT` tasks. `state` keeps one state of it; `open=true` the tasks not done or dropped, `open=false` the rest; `category=<key>` those sorted into a category; `goal=<id>` those that work towards a goal; `assignee=<person id>` those assigned to one person; `dueBy=<day>` (`2026-10-24`) those due on that day or before it. A task carries its `number` (the one people say out loud, numbered per project), `title`, `body` (Markdown, to be shown as text or rendered to elements, never as HTML), `state`, `priority`, `owner`, `assignee` (a person, or `null`), `parentId`, `category` (its `key`, `name` and `color` as the workspace keeps it, or `null`), `goalId` (or `null`), its plan ([ADR-0019](../adr/0019-a-task-is-planned-in-days-the-board-keeps-its-moments-and-goals-and-categories-give-the-work-its-shape.md)): `startsOn` and `dueOn`, each a day (`2026-10-24`) or `null`; its moments, which the board keeps itself: `startedAt`, when it was first moved past `ready`, and `doneAt`, when it was moved to `done`, each an instant or `null`; `links` (`url` and `label`), `openDecisions` (how many decisions about it wait for a person), `openRuns` (how many agents are at work on it, or waiting), `createdAt` and `updatedAt`.
150
+ `{ "items": [task, ...], "more": boolean }`: the project's board, newest first, a page at a time: `limit` (1 to `LIST_LIMIT`, the default) and `offset` (from 0) say which page, and `more` whether there is one after it; a `limit` or an `offset` that is not a number in range is `request.invalid`. `state` keeps one state of it; `open=true` the tasks not done or dropped, `open=false` the rest; `category=<key>` those sorted into a category; `goal=<id>` those that work towards a goal; `assignee=<person id>` those assigned to one person; `dueBy=<day>` (`2026-10-24`) those due on that day or before it. A task carries its `number` (the one people say out loud, numbered per project), `title`, `body` (Markdown, to be shown as text or rendered to elements, never as HTML), `state`, `priority`, `owner`, `assignee` (a person, or `null`), `parentId`, `category` (its `key`, `name` and `color` as the workspace keeps it, or `null`), `goalId` (or `null`), its plan ([ADR-0019](../adr/0019-a-task-is-planned-in-days-the-board-keeps-its-moments-and-goals-and-categories-give-the-work-its-shape.md)): `startsOn` and `dueOn`, each a day (`2026-10-24`) or `null`; its moments, which the board keeps itself: `startedAt`, when it was first moved past `ready`, and `doneAt`, when it was moved to `done`, each an instant or `null`; `links` (`url` and `label`), `openDecisions` (how many decisions about it wait for a person), `openRuns` (how many runs on it are open: begun and not ended, which says nothing of whether an agent is still at it, [ADR-0020](../adr/0020-the-console-does-not-claim-an-agent-is-at-work-a-run-says-when-it-was-last-heard-from-and-a-new-run-takes-over.md)), `createdAt` and `updatedAt`.
145
151
 
146
152
  ### `POST /api/v1/projects/<key>/tasks`
147
153
 
@@ -151,9 +157,9 @@ Takes `title`, and optionally `body`, `state` (default `idea`), `priority` (defa
151
157
 
152
158
  One task, by its id or by its number in the project (`/tasks/7`): the pages hold ids, people and their agents say numbers. A patch takes the id and names only what changes, with the same fields as a creation; a field set to `null` clears it. A change of `state` is a `task.moved` event with `from` and `to`; any other change is one `task.updated` event naming the `fields` (`category`, `goalId`, `startsOn` and `dueOn` among them, as the input names them). A patch that changes nothing writes nothing and answers the task as it is. The moments follow the moves: the first move past `ready` sets `startedAt`, a move to `done` sets `doneAt`, and a move back out of `done` clears it.
153
159
 
154
- ### `GET /api/v1/projects/<key>/decisions?open=&task=`
160
+ ### `GET /api/v1/projects/<key>/decisions?open=&task=&limit=&offset=`
155
161
 
156
- `{ "items": [decision, ...] }`: the project's, the ones that wait first, newest first within each; `open=true` keeps only those that wait, `task=<id>` only those about one task. A decision carries its `question`, `body` (Markdown: the context it rests on), `options` (each an `id` and a `label`), `taskId` and `taskNumber` (or `null`), `raisedBy`, `run` (the `id` and `agent` of the run that raised it, when an agent asked, or `null`), `answer`: `null` while it waits, else the chosen `option`, a `note` or `null` (the rationale given with it), `by` and `at`; and `outcome`, what followed, in Markdown, or `null` until someone writes it. Context, rationale and outcome make the decision a record ([ADR-0009](../adr/0009-documents-are-pages-of-markdown-in-a-project-addressed-by-path-versioned-and-linked-both-ways.md)); a link in any of them to a document's path is kept both ways, so that the document says the decision refers to it.
162
+ `{ "items": [decision, ...], "more": boolean }`: the project's, the ones that wait first, newest first within each, a page at a time as the tasks are (`limit`, `offset`, `more`); `open=true` keeps only those that wait, `task=<id>` only those about one task. A decision carries its `question`, `body` (Markdown: the context it rests on), `options` (each an `id` and a `label`), `taskId` and `taskNumber` (or `null`), `raisedBy`, `run` (the `id` and `agent` of the run that raised it, when an agent asked, or `null`), `answer`: `null` while it waits, else the chosen `option`, a `note` or `null` (the rationale given with it), `by` and `at`; and `outcome`, what followed, in Markdown, or `null` until someone writes it. Context, rationale and outcome make the decision a record ([ADR-0009](../adr/0009-documents-are-pages-of-markdown-in-a-project-addressed-by-path-versioned-and-linked-both-ways.md)); a link in any of them to a document's path is kept both ways, so that the document says the decision refers to it.
157
163
 
158
164
  ### `POST /api/v1/projects/<key>/decisions`
159
165
 
@@ -177,13 +183,13 @@ The roadmap ([ADR-0019](../adr/0019-a-task-is-planned-in-days-the-board-keeps-it
177
183
 
178
184
  One goal, for whoever may see the project. The patch, for an owner, takes any of `title`, `body`, `state` and `dueOn` (`null` for none), answers the goal and writes `goal.updated` naming the `fields`; a patch that changes nothing writes nothing. A goal is never deleted: one let go is `dropped`. A task works towards a goal through its `goalId`; the goal's events are read from the project's feed with `goal=<id>`.
179
185
 
180
- ### `GET /api/v1/projects/<key>/runs?task=&open=&mine=` and `GET .../runs/<id>`
186
+ ### `GET /api/v1/projects/<key>/runs?task=&open=&mine=&limit=&offset=` and `GET .../runs/<id>`
181
187
 
182
- `{ "items": [run, ...] }`: the project's runs, newest first, at most `LIST_LIMIT`; `task=<id>` keeps those on one task, `open=true` those not over (`running` or `waiting`) and `open=false` those over, `mine=true` the asker's own: with a token, the runs begun with that token, which is one agent's; on a session, the runs for the person. A run is one agent at work on one task for one person: `taskId` and `taskNumber` (the one people say), `person` (whom the agent acts for), `agent` (what it calls itself), `status` (`running`, `waiting` for a decision, `finished`, `failed`, `abandoned`), `startedAt`, `endedAt` or `null`, `summary` (Markdown, what the agent said at the end, or `null`), `reports` (each `id`, `body` in Markdown, `createdAt`; oldest first), `artifacts` (each `id`, `kind`, `url` for a link or `null`, `label` or `null`, `file` for a file or `null`, and `createdAt`; a file is its `name`, `size` in bytes, `contentType` and `sha256`), and `waitingFor`, the id of the decision the run waits for, or `null`.
188
+ `{ "items": [run, ...], "more": boolean }`: the project's runs, newest first, a page at a time as the tasks are (`limit`, `offset`, `more`); `task=<id>` keeps those on one task, `open=true` those not over (`running` or `waiting`) and `open=false` those over, `mine=true` the asker's own: with a token, the runs begun with that token, which is one agent's; on a session, the runs for the person. A run is one agent's work on one task for one person: `taskId` and `taskNumber` (the one people say), `person` (whom the agent acts for), `agent` (what it calls itself), `status` (`running`, that is open, `waiting` for a decision, `finished`, `failed`, `abandoned`), `startedAt`, `endedAt` or `null`, `lastHeardAt` (when the agent was last heard from through the run: its start, then its latest report, hand-in or question; the console knows nothing of an agent but what it sends, and claims nothing more, [ADR-0020](../adr/0020-the-console-does-not-claim-an-agent-is-at-work-a-run-says-when-it-was-last-heard-from-and-a-new-run-takes-over.md)), `summary` (Markdown, what the agent said at the end, or `null`), `reports` (each `id`, `body` in Markdown, `createdAt`; oldest first), `artifacts` (each `id`, `kind`, `url` for a link or `null`, `label` or `null`, `file` for a file or `null`, and `createdAt`; a file is its `name`, `size` in bytes, `contentType` and `sha256`), and `waitingFor`, the id of the decision the run waits for, or `null`.
183
189
 
184
190
  ### `POST /api/v1/projects/<key>/runs`
185
191
 
186
- Takes `taskId` (a task of the project; `run.task_not_found` otherwise), and optionally `agent` (what people see at work, `MAX_AGENT_LENGTH`; left out, the name of the token presented, or the person's login on a session). The run begins: the task becomes the asker's and `in_progress`, and the board is told (`run.started`). One agent at a time per task (`run.task_taken`); a task that is `done` or `dropped` cannot be taken (`run.task_closed`). Answers `201` with the run, whose `id` the rest take.
192
+ Takes `taskId` (a task of the project; `run.task_not_found` otherwise), and optionally `agent` (what people see on the run, `MAX_AGENT_LENGTH`; left out, the name of the token presented, or the person's login on a session). The run begins: the task becomes the asker's and `in_progress`, and the board is told (`run.started`). A run open on the task is taken over ([ADR-0020](../adr/0020-the-console-does-not-claim-an-agent-is-at-work-a-run-says-when-it-was-last-heard-from-and-a-new-run-takes-over.md)): it ends as `abandoned`, with a summary that says who took over, and the board is told (`run.taken_over`, by the one who took over, with the `agent` that was at it), since the console cannot tell a run still at work from one whose agent is gone. A task that is `done` or `dropped` cannot be taken (`run.task_closed`). Answers `201` with the run, whose `id` the rest take.
187
193
 
188
194
  ### `POST .../runs/<id>/reports` and `POST .../runs/<id>/artifacts`
189
195
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skillcdn/console",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "What a custom SkillCDN console is built from: the API client and schemas, the components, the composition of the default console, and the console command agents work the board with.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/skillcdn/console#readme",
@@ -66,11 +66,11 @@ Produces the **plan** and the first build. Install first, in the new folder, so
66
66
 
67
67
  ### Phase 2: Configure before replacing
68
68
 
69
- Produces the **configuration**. `createConsole(config)` takes `title`; `brand`, the addresses of a symbol and a wordmark; `languages`, the packs the pages speak, where a language of one's own is `src/i18n/en.ts` copied whole and translated; `components`, the pieces to replace, with the props `ConsoleComponents` names; `baseUrl`, the API's origin, the page's own when left out; and `client`, for tests. A console with marks of its own imports their files in `main.tsx`, so that the build takes them as assets, and passes their addresses through `brand`; `build: { assetsInlineLimit: 0 }` in `vite.config.ts` keeps every picture and font a file, since the policy allows a small picture inlined as a `data:` address but no `data:` font, and the default console only happens to have none small enough; sets `index.html`'s `<title>`, its `lang` and its icons, the mark as `favicon.svg` linked directly, with an `.ico` and a touch icon when the person has them (the default console links three from its brand package through a small build plugin); and loads the faces its languages need. Most wants are configuration or the styles: re-skinning starts and mostly ends with the tokens of `console.css` (the colours, the glass, the field), overridden by a stylesheet of the console's own loaded after the package's, with the mapping of the plan. Replace a component only when configuration cannot do it: start from its source under `src/components/`, keep its props, and keep or add `sc-` classes. With the look in place, show the pages and stop for the look checkpoint.
69
+ Produces the **configuration**. `createConsole(config)` takes `title`; `brand`, the addresses of a symbol and a wordmark; `languages`, the packs the pages speak, where a language of one's own is `src/i18n/en.ts` copied whole and translated; `components`, the pieces to replace, with the props `ConsoleComponents` names; `pages`, tabs of the console's own, each a `CustomPage` with a key, a label by language, a level (the workspace's pages or every project's) and `render`, given the data, the client, the navigation and the words; `baseUrl`, the API's origin, the page's own when left out; and `client`, for tests. The look is the stylesheet's tokens, `--sc-*` on `:root`, set in a stylesheet of the console's own loaded after `console.css`, and the blocks are styled by their `sc-` classes; the package's README lists the tokens by group. A console with marks of its own imports their files in `main.tsx`, so that the build takes them as assets, and passes their addresses through `brand`; `build: { assetsInlineLimit: 0 }` in `vite.config.ts` keeps every picture and font a file, since the policy allows a small picture inlined as a `data:` address but no `data:` font, and the default console only happens to have none small enough; sets `index.html`'s `<title>`, its `lang` and its icons, the mark as `favicon.svg` linked directly, with an `.ico` and a touch icon when the person has them (the default console links three from its brand package through a small build plugin); and loads the faces its languages need. Most wants are configuration or the styles: re-skinning starts and mostly ends with the tokens of `console.css` (the colours, the glass, the field), overridden by a stylesheet of the console's own loaded after the package's, with the mapping of the plan. Replace a component only when configuration cannot do it: start from its source under `src/components/`, keep its props, and keep or add `sc-` classes. With the look in place, show the pages and stop for the look checkpoint.
70
70
 
71
71
  ### Phase 3: Compose pages of one's own
72
72
 
73
- Produces the **pages**. For a page the default console lacks: `useConsoleData(client, projectKey)` answers the workspace and the project the page is on, kept current by the project's stream, with the actions; the components take their data as props and nothing from the network; `matchRoute`, `PATHS`, `projectHref`, `taskHref`, `decisionHref` and `docHref` name the addresses; `Shell` is the header with the tabs and the person's menu. A composition of one's own is `src/console.tsx` with pages added or taken away. A request without a page of its own skips this phase.
73
+ Produces the **pages**. For a page the default console lacks: `useConsoleData(client, projectKey)` answers the workspace and the project the page is on, kept current by the project's stream, with the actions; the components take their data as props and nothing from the network; `matchRoute`, `PATHS`, `projectHref`, `taskHref`, `decisionHref` and `docHref` name the addresses; `Shell` is the header with the tabs and the person's menu. A page as a tab is `pages` in the configuration, its `render` composing the components over what it is given; a composition of one's own is `src/console.tsx` with pages added or taken away. A request without a page of its own skips this phase.
74
74
 
75
75
  ### Phase 4: Run it against the board
76
76
 
@@ -47,7 +47,7 @@ Each phase ends with something on the board. Only phase 4 waits for a person.
47
47
 
48
48
  ### Phase 1: Read
49
49
 
50
- `console task <number>` shows the task in full, with its plan, the decisions about it and the runs on it. A run open on it by another agent means the task is taken: say so and stop. A task that is past its due day is still the task; the first report says so, and what the day was for is kept in mind. Read what the body links to, with `console doc <path>` for the project's pages and `console skills` for the organization's skills, before anything is made.
50
+ `console task <number>` shows the task in full, with its plan, the decisions about it and the runs on it. A run open on it means another agent was at it, and may still be: the console cannot tell ([ADR-0020](/docs/adr/0020-the-console-does-not-claim-an-agent-is-at-work-a-run-says-when-it-was-last-heard-from-and-a-new-run-takes-over.md)), so read its reports and when it was last heard from, and take the task only when the person said to, since taking it ends that run as taken over; otherwise say so and stop. A task that is past its due day is still the task; the first report says so, and what the day was for is kept in mind. Read what the body links to, with `console doc <path>` for the project's pages and `console skills` for the organization's skills, before anything is made.
51
51
 
52
52
  ### Phase 2: Take
53
53
 
package/src/api.ts CHANGED
@@ -12,6 +12,7 @@ export {
12
12
  type EventFilter,
13
13
  type FetchLike,
14
14
  type FileUpload,
15
+ type Page,
15
16
  type ProjectClient,
16
17
  type TaskFilter,
17
18
  } from "./client.js";
@@ -31,6 +32,7 @@ export {
31
32
  nameOf,
32
33
  } from "./documents.js";
33
34
  export {
35
+ DEFAULT_PAGE_LIMIT,
34
36
  DEFAULT_TOKEN_DAYS,
35
37
  EVENTS_PAGE_LIMIT,
36
38
  LIST_LIMIT,
@@ -53,8 +55,10 @@ export {
53
55
  MAX_OPTION_LABEL_LENGTH,
54
56
  MAX_OPTIONS,
55
57
  MAX_PROJECT_DESCRIPTION_LENGTH,
58
+ MAX_PROJECT_ICON_BYTES,
56
59
  MAX_PROJECT_KEY_LENGTH,
57
60
  MAX_PROJECT_NAME_LENGTH,
61
+ MAX_PROJECT_SYMBOL_LENGTH,
58
62
  MAX_QUESTION_LENGTH,
59
63
  MAX_REPORTS_PER_RUN,
60
64
  MAX_SEARCH_LENGTH,
@@ -69,6 +73,7 @@ export {
69
73
  MAX_VERSIONS_PER_DOCUMENT,
70
74
  MIN_OPTIONS,
71
75
  } from "./limits.js";
76
+ export { projectColorOf, projectSymbolOf } from "./marks.js";
72
77
  export {
73
78
  AUTH_ROUTES,
74
79
  CLAIM_PATH,
@@ -79,6 +84,7 @@ export {
79
84
  PROJECT_COLLECTIONS,
80
85
  type ProjectCollection,
81
86
  personTokensPath,
87
+ projectIconPath,
82
88
  projectPath,
83
89
  REST_ROUTES,
84
90
  RETURN_TO_PARAM,
@@ -133,6 +139,7 @@ export {
133
139
  type RestPerson,
134
140
  type RestPersonPatch,
135
141
  type RestProject,
142
+ type RestProjectIcon,
136
143
  type RestProjectInput,
137
144
  type RestProjectPatch,
138
145
  type RestProjects,
@@ -202,6 +209,7 @@ export {
202
209
  restPeopleSchema,
203
210
  restPersonPatchSchema,
204
211
  restPersonSchema,
212
+ restProjectIconSchema,
205
213
  restProjectInputSchema,
206
214
  restProjectPatchSchema,
207
215
  restProjectSchema,
@@ -236,6 +244,8 @@ export {
236
244
  type ArtifactKind,
237
245
  CATEGORY_COLORS,
238
246
  type CategoryColor,
247
+ COLOR_NAMES,
248
+ type ColorName,
239
249
  EVENT_KINDS,
240
250
  type EventKind,
241
251
  GOAL_STATES,
@@ -246,9 +256,11 @@ export {
246
256
  OPEN_TASK_STATES,
247
257
  PERSON_ROLES,
248
258
  type PersonRole,
259
+ PROJECT_ICON_TYPES,
249
260
  PROJECT_ROLES,
250
261
  PROJECT_VISIBILITIES,
251
262
  PROVIDER_KEYS,
263
+ type ProjectIconType,
252
264
  type ProjectRole,
253
265
  type ProjectVisibility,
254
266
  type ProviderKey,
package/src/cli/cli.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  type ProjectClient,
9
9
  } from "../client.js";
10
10
  import { addDays, dayOf as dayOfDate, isDay } from "../dates.js";
11
- import { MAX_OPTIONS, MIN_OPTIONS } from "../limits.js";
11
+ import { DEFAULT_PAGE_LIMIT, LIST_LIMIT, MAX_OPTIONS, MIN_OPTIONS } from "../limits.js";
12
12
  import type { RestClaim, RestDecision, RestTaskPatch } from "../schemas.js";
13
13
  import {
14
14
  GOAL_STATES,
@@ -30,6 +30,7 @@ import {
30
30
  formatDocuments,
31
31
  formatGoal,
32
32
  formatGoalLine,
33
+ formatMore,
33
34
  formatOptions,
34
35
  formatProjectLine,
35
36
  formatRun,
@@ -351,6 +352,26 @@ function secondsOf(value: string | undefined, fallback: number): number {
351
352
  return seconds;
352
353
  }
353
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
+
354
375
  /** One positional, and nothing else. */
355
376
  function theOne(positionals: readonly string[], ask: string): string {
356
377
  const [first] = positionals;
@@ -546,10 +567,12 @@ const tasks: Command = async (args, io) => {
546
567
  category: { type: "string" },
547
568
  goal: { type: "string" },
548
569
  due: { type: "string" },
570
+ ...PAGE_OPTIONS,
549
571
  },
550
572
  false,
551
573
  );
552
574
  const state = stateOf(text(values, "state"));
575
+ const page = pageOf(values);
553
576
  const session = await open(io, values);
554
577
  const project = await session.project();
555
578
  // What is due: a day, or the words for the usual windows; overdue keeps only the open ones.
@@ -573,9 +596,13 @@ const tasks: Command = async (args, io) => {
573
596
  ...(text(values, "category") === undefined ? {} : { category: text(values, "category") }),
574
597
  ...(text(values, "goal") === undefined ? {} : { goal: text(values, "goal") }),
575
598
  ...(dueBy === undefined ? {} : { dueBy }),
599
+ ...page,
576
600
  });
577
601
  answer(io, session, found, () =>
578
- 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),
579
606
  );
580
607
  return EXIT.ok;
581
608
  };
@@ -734,7 +761,7 @@ const take: Command = async (args, io) => {
734
761
  });
735
762
  answer(io, session, { run, task: found }, () =>
736
763
  [
737
- `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.`,
738
765
  "",
739
766
  formatTask(found),
740
767
  "",
@@ -1154,12 +1181,19 @@ const goal: Command = async (args, io) => {
1154
1181
  };
1155
1182
 
1156
1183
  const decisions: Command = async (args, io) => {
1157
- const { values } = parse(args, { open: { type: "boolean" } }, false);
1184
+ const { values } = parse(args, { open: { type: "boolean" }, ...PAGE_OPTIONS }, false);
1185
+ const page = pageOf(values);
1158
1186
  const session = await open(io, values);
1159
1187
  const project = await session.project();
1160
- 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
+ });
1161
1192
  answer(io, session, found, () =>
1162
- 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),
1163
1197
  );
1164
1198
  return EXIT.ok;
1165
1199
  };
@@ -1212,9 +1246,15 @@ const abandon: Command = async (args, io) => {
1212
1246
  const runs: Command = async (args, io) => {
1213
1247
  const { values } = parse(
1214
1248
  args,
1215
- { 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
+ },
1216
1255
  false,
1217
1256
  );
1257
+ const page = pageOf(values);
1218
1258
  const session = await open(io, values);
1219
1259
  const project = await session.project();
1220
1260
  const ref = text(values, "task");
@@ -1223,9 +1263,12 @@ const runs: Command = async (args, io) => {
1223
1263
  ...(taskId === undefined ? {} : { task: taskId }),
1224
1264
  ...(on(values, "open") ? { open: true } : {}),
1225
1265
  ...(on(values, "mine") ? { mine: true } : {}),
1266
+ ...page,
1226
1267
  });
1227
1268
  answer(io, session, found, () =>
1228
- 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),
1229
1272
  );
1230
1273
  return EXIT.ok;
1231
1274
  };
package/src/cli/format.ts CHANGED
@@ -39,12 +39,14 @@ export function formatProjectLine(project: RestProject): string {
39
39
  if (project.openDecisions > 0) {
40
40
  notes.push(`${plural(project.openDecisions, "decision")} waiting`);
41
41
  }
42
- if (project.openRuns > 0) {
43
- notes.push(`${plural(project.openRuns, "agent")} at work`);
44
- }
45
42
  return `${project.key} ${project.name} (${notes.join("; ")})`;
46
43
  }
47
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
+
48
50
  export function formatTaskLine(task: RestTask): string {
49
51
  const notes = [`owner ${task.owner.login}`];
50
52
  if (task.assignee !== null) {
@@ -59,9 +61,6 @@ export function formatTaskLine(task: RestTask): string {
59
61
  if (task.openDecisions > 0) {
60
62
  notes.push(`${plural(task.openDecisions, "decision")} waiting`);
61
63
  }
62
- if (task.openRuns > 0) {
63
- notes.push("an agent at work");
64
- }
65
64
  return `#${task.number} ${task.state} ${task.priority} ${task.title} (${notes.join("; ")})`;
66
65
  }
67
66
 
@@ -128,6 +127,9 @@ export function formatRunLine(run: RestRun): string {
128
127
  if (run.waitingFor !== null) {
129
128
  notes.push(`waiting for decision ${run.waitingFor}`);
130
129
  }
130
+ if (run.endedAt === null) {
131
+ notes.push(`last heard ${run.lastHeardAt}`);
132
+ }
131
133
  return `${run.id} ${run.status} ${run.agent} for ${run.person.login} task #${run.taskNumber} (${notes.join(", ")})`;
132
134
  }
133
135
 
package/src/cli/help.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ DEFAULT_PAGE_LIMIT,
2
3
  MAX_AGENT_LENGTH,
3
4
  MAX_ARTIFACTS_PER_RUN,
4
5
  MAX_BODY_LENGTH,
@@ -105,9 +106,8 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
105
106
  },
106
107
  tasks: {
107
108
  usage:
108
- "tasks [--state <state>] [--open] [--mine] [--category <key>] [--goal <id>] [--due <day|today|week|overdue>]",
109
- about:
110
- "The tasks on the board, newest first: number, state, priority, title, who owns and works on each, its category and its due day, and what waits on it. --open keeps the ones not done or dropped, --mine those assigned to you, --category and --goal those sorted into one, --due those due by a day: today, within the week, or overdue (the open ones past their day). States: idea, ready, in_progress, in_review, done, dropped.",
109
+ "tasks [--state <state>] [--open] [--mine] [--category <key>] [--goal <id>] [--due <day|today|week|overdue>] [--limit <n>] [--offset <n>]",
110
+ about: `The tasks on the board, newest first: number, state, priority, title, who owns and works on each, its category and its due day, and what waits on it. --open keeps the ones not done or dropped, --mine those assigned to you, --category and --goal those sorted into one, --due those due by a day: today, within the week, or overdue (the open ones past their day). A page at a time: ${DEFAULT_PAGE_LIMIT} unless --limit says otherwise, from --offset on; a last line says when there is more. States: idea, ready, in_progress, in_review, done, dropped.`,
111
111
  },
112
112
  task: {
113
113
  usage: `task <number|id> | task new <title> [--body <markdown>|--file <path>] ${TASK_FIELD_FLAGS} | task edit <number|id> [--title <title>] [--body <markdown>|--file <path>] ${TASK_FIELD_FLAGS}`,
@@ -153,11 +153,10 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
153
153
  take: {
154
154
  usage: "take <number|id> [--agent <name>]",
155
155
  about:
156
- "Takes a task: a run begins, and the task is in progress and the person's. One agent at a time per task. --agent is what people see at work; left out, it is what the person called the token.",
156
+ "Takes a task: a run begins, and the task is in progress and the person's. A run still open on the task is taken over, since the board cannot know whether its agent still runs: it ends as abandoned, the feed says who took over, and that agent is refused on its next command. Look before taking (console task <number> lists the runs, with when each was last heard from), and take a task another agent was heard from on lately only when told to. --agent is what people see at work; left out, it is what the person called the token.",
157
157
  limits: [`agent: one line, up to ${MAX_AGENT_LENGTH} characters`],
158
158
  refusals: [
159
159
  "task.not_found: no task has that number or id",
160
- "run.task_taken: a run is open on the task already; console runs --open --mine shows whether it is yours",
161
160
  "run.task_closed: the task is done or dropped",
162
161
  ],
163
162
  },
@@ -207,8 +206,8 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
207
206
  refusals: ["decision.not_found: no decision has that id"],
208
207
  },
209
208
  decisions: {
210
- usage: "decisions [--open]",
211
- about: "The decisions on the board, the waiting first; --open keeps only those.",
209
+ usage: "decisions [--open] [--limit <n>] [--offset <n>]",
210
+ about: `The decisions on the board, the waiting first; --open keeps only those. A page at a time: ${DEFAULT_PAGE_LIMIT} unless --limit says otherwise, from --offset on.`,
212
211
  },
213
212
  finish: {
214
213
  usage: 'finish [--summary "<markdown>"|--file <path>]',
@@ -231,9 +230,8 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
231
230
  refusals: RUN_REFUSALS,
232
231
  },
233
232
  runs: {
234
- usage: "runs [--task <number|id>] [--open] [--mine]",
235
- about:
236
- "The runs, newest first: agents at work and what they did. --mine keeps those begun with this token.",
233
+ usage: "runs [--task <number|id>] [--open] [--mine] [--limit <n>] [--offset <n>]",
234
+ about: `The runs, newest first, each with when its agent was last heard from: --open keeps the ones not over, --mine those begun with this token. A page at a time: ${DEFAULT_PAGE_LIMIT} unless --limit says otherwise, from --offset on.`,
237
235
  },
238
236
  run: {
239
237
  usage: "run <id>",
@@ -312,6 +310,8 @@ Usage: console <command> [arguments] [--json] [--url <origin>] [--project <key>]
312
310
 
313
311
  const CLOSING = `Options everywhere: --json answers with the console's own JSON; --url names another console;
314
312
  --project names the project to work in, over CONSOLE_PROJECT and over what console use kept here.
313
+ A listing (tasks, decisions, runs) is a page at a time: --limit <n> and --offset <n>, and a last line
314
+ says when there is more, so that nothing is read that is not needed.
315
315
  Commands on a run (report, hand-in, ask, finish, fail) act on your one open run in the project;
316
316
  with several open, say which with --run <id>. A Markdown body given as - is read from standard input.
317
317
  console help <command> says what each command takes, the limits the console holds it to, and
package/src/client.ts CHANGED
@@ -150,8 +150,14 @@ export interface EventFilter {
150
150
  readonly document?: string | undefined;
151
151
  }
152
152
 
153
+ /** One page of a listing: how many items, from which one on; the server's bounds apply. */
154
+ export interface Page {
155
+ readonly limit?: number | undefined;
156
+ readonly offset?: number | undefined;
157
+ }
158
+
153
159
  /** What the board is narrowed to: one state, the open ones, a category, a goal, an assignee, or what is due by a day. */
154
- export interface TaskFilter {
160
+ export interface TaskFilter extends Page {
155
161
  readonly state?: TaskState | undefined;
156
162
  /** True for the tasks not done or dropped; false for the rest. */
157
163
  readonly open?: boolean | undefined;
@@ -183,6 +189,10 @@ export interface ProjectClient {
183
189
  get(signal?: AbortSignal): Promise<RestProject>;
184
190
  /** Changes the project's settings; for an owner. */
185
191
  update(patch: RestProjectPatch): Promise<RestProject>;
192
+ /** Sets the project's own image for its mark, sent as a form; for an owner on the console's own pages. */
193
+ setIcon(input: FileUpload): Promise<RestProject>;
194
+ /** Takes the image away: the mark is the symbol on the colour again. */
195
+ removeIcon(): Promise<RestProject>;
186
196
  /** Those listed in the project, by login. */
187
197
  members(signal?: AbortSignal): Promise<RestMembers>;
188
198
  /** Lists a person of the workspace in the project; for an owner. */
@@ -194,9 +204,9 @@ export interface ProjectClient {
194
204
  task(ref: string, signal?: AbortSignal): Promise<RestTask>;
195
205
  createTask(input: RestTaskInput): Promise<RestTask>;
196
206
  updateTask(id: string, patch: RestTaskPatch): Promise<RestTask>;
197
- /** The decisions, or only those that wait, or only those about one task. */
207
+ /** The decisions, or only those that wait, or only those about one task; a page of them. */
198
208
  decisions(
199
- filter?: { readonly open?: boolean; readonly task?: string },
209
+ filter?: { readonly open?: boolean; readonly task?: string } & Page,
200
210
  signal?: AbortSignal,
201
211
  ): Promise<RestDecisions>;
202
212
  decision(id: string, signal?: AbortSignal): Promise<RestDecision>;
@@ -209,9 +219,9 @@ export interface ProjectClient {
209
219
  answerDecision(id: string, input: RestAnswerInput): Promise<RestDecision>;
210
220
  /** Grows a decision's record: its context, or what followed. */
211
221
  updateDecision(id: string, patch: RestDecisionPatch): Promise<RestDecision>;
212
- /** The runs, newest first; or only those on one task, only the open ones, only the asker's own. */
222
+ /** The runs, newest first; or only those on one task, only the open ones, only the asker's own; a page of them. */
213
223
  runs(
214
- filter?: { readonly task?: string; readonly open?: boolean; readonly mine?: boolean },
224
+ filter?: { readonly task?: string; readonly open?: boolean; readonly mine?: boolean } & Page,
215
225
  signal?: AbortSignal,
216
226
  ): Promise<RestRuns>;
217
227
  run(id: string, signal?: AbortSignal): Promise<RestRun>;
@@ -381,6 +391,8 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
381
391
 
382
392
  const flag = (value: boolean | undefined): string | undefined =>
383
393
  value === undefined ? undefined : String(value);
394
+ const number = (value: number | undefined): string | undefined =>
395
+ value === undefined ? undefined : String(value);
384
396
 
385
397
  /** A file as the parts of a form: the bytes with their name and type, and the label. */
386
398
  const formOf = (input: FileUpload): FormData => {
@@ -405,6 +417,8 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
405
417
  key,
406
418
  get: (signal) => request("GET", `${base}${at()}`, undefined, restProjectSchema, signal),
407
419
  update: (patch) => request("PATCH", `${base}${at()}`, patch, restProjectSchema),
420
+ setIcon: (input) => request("POST", `${base}${at()}/icon`, formOf(input), restProjectSchema),
421
+ removeIcon: () => request("DELETE", `${base}${at()}/icon`, undefined, restProjectSchema),
408
422
  members: (signal) =>
409
423
  request("GET", `${base}${at("members")}`, undefined, restMembersSchema, signal),
410
424
  addMember: (input) => request("POST", `${base}${at("members")}`, input, restMemberSchema),
@@ -422,6 +436,8 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
422
436
  goal: filter.goal,
423
437
  assignee: filter.assignee,
424
438
  dueBy: filter.dueBy,
439
+ limit: number(filter.limit),
440
+ offset: number(filter.offset),
425
441
  }),
426
442
  undefined,
427
443
  restTasksSchema,
@@ -435,7 +451,12 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
435
451
  decisions: (filter = {}, signal) =>
436
452
  request(
437
453
  "GET",
438
- withQuery(at("decisions"), { open: flag(filter.open), task: filter.task }),
454
+ withQuery(at("decisions"), {
455
+ open: flag(filter.open),
456
+ task: filter.task,
457
+ limit: number(filter.limit),
458
+ offset: number(filter.offset),
459
+ }),
439
460
  undefined,
440
461
  restDecisionsSchema,
441
462
  signal,
@@ -463,6 +484,8 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
463
484
  task: filter.task,
464
485
  open: flag(filter.open),
465
486
  mine: flag(filter.mine),
487
+ limit: number(filter.limit),
488
+ offset: number(filter.offset),
466
489
  }),
467
490
  undefined,
468
491
  restRunsSchema,