@starlove/orbit 1.4.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +10 -8
- package/README.md +146 -164
- package/dist/full/csp.txt +1 -0
- package/dist/full/fonts/JetBrains_Mono/JetBrainsMono-subset.woff2 +0 -0
- package/dist/full/fonts/JetBrains_Mono/README.txt +5 -0
- package/dist/full/fonts/Mona_Sans/MonaSans-subset.woff2 +0 -0
- package/{public/fonts/Open_Sans → dist/full/fonts/Mona_Sans}/OFL.txt +93 -93
- package/dist/full/fonts/Mona_Sans/README.txt +10 -0
- package/{public/fonts/JetBrains_Mono → dist/full/fonts/Newsreader}/OFL.txt +93 -93
- package/dist/full/fonts/Newsreader/README.txt +8 -0
- package/dist/full/fonts/Newsreader/newsreader.woff2 +0 -0
- package/dist/full/index.html +36 -653
- package/dist/full/js/app.BmuponPJ.js +32 -0
- package/dist/full/js/app.BmuponPJ.js.map +1 -0
- package/dist/full/js/chunk.1fJGQGbW.js +2 -0
- package/dist/full/js/chunk.1fJGQGbW.js.map +1 -0
- package/dist/full/js/chunk.5D7JO2zp.js +2 -0
- package/dist/full/js/chunk.5D7JO2zp.js.map +1 -0
- package/dist/full/js/chunk.BrNn_D79.js +2 -0
- package/dist/full/js/chunk.BrNn_D79.js.map +1 -0
- package/dist/full/js/chunk.CokJXIqM.js +2 -0
- package/dist/full/js/chunk.CokJXIqM.js.map +1 -0
- package/dist/full/js/chunk.D_Haqy6R.js +2 -0
- package/dist/full/js/chunk.D_Haqy6R.js.map +1 -0
- package/dist/full/js/chunk.RO0jsH6I.js +15 -0
- package/dist/full/js/chunk.RO0jsH6I.js.map +1 -0
- package/dist/full/js/chunk.dqWOEtBM.js +2 -0
- package/dist/full/js/chunk.dqWOEtBM.js.map +1 -0
- package/dist/full/js/warm.f260bbbc.js +15 -0
- package/dist/full/orbits-favicon.png +0 -0
- package/dist/full/orbits-logo-bg-wide.png +0 -0
- package/dist/full/orbits-logo-bg.png +0 -0
- package/dist/full/orbits-logo.png +0 -0
- package/dist/full/styles.2b154ffe.css +1 -0
- package/dist/full/vendor/starlove-ui/theme-init.js +66 -0
- package/dist/full/version.json +2 -2
- package/docs/AGENT_GUIDE.md +114 -0
- package/docs/UPDATING.md +249 -64
- package/lib/adapters/indexeddb/blobs.d.ts +3 -0
- package/lib/adapters/indexeddb/blobs.js +54 -0
- package/lib/adapters/indexeddb/database.d.ts +60 -0
- package/lib/adapters/indexeddb/database.js +126 -0
- package/lib/adapters/indexeddb/index.d.ts +5 -0
- package/lib/adapters/indexeddb/index.js +3 -0
- package/lib/adapters/indexeddb/locks.d.ts +14 -0
- package/lib/adapters/indexeddb/locks.js +40 -0
- package/lib/adapters/indexeddb/store.d.ts +40 -0
- package/lib/adapters/indexeddb/store.js +424 -0
- package/lib/adapters/indexeddb/tables.d.ts +43 -0
- package/lib/adapters/indexeddb/tables.js +127 -0
- package/lib/adapters/memory/blobs.d.ts +3 -0
- package/lib/adapters/memory/blobs.js +44 -0
- package/lib/adapters/memory/board.d.ts +41 -0
- package/lib/adapters/memory/board.js +521 -0
- package/lib/adapters/memory/index.d.ts +2 -0
- package/lib/adapters/memory/index.js +2 -0
- package/lib/adapters/memory/search.d.ts +6 -0
- package/lib/adapters/memory/search.js +30 -0
- package/lib/adapters/memory/store.d.ts +34 -0
- package/lib/adapters/memory/store.js +266 -0
- package/lib/adapters/sqlite/backups.d.ts +53 -0
- package/lib/adapters/sqlite/backups.js +232 -0
- package/lib/adapters/sqlite/blobs.d.ts +14 -0
- package/lib/adapters/sqlite/blobs.js +109 -0
- package/lib/adapters/sqlite/board.d.ts +77 -0
- package/lib/adapters/sqlite/board.js +328 -0
- package/lib/adapters/sqlite/database.d.ts +67 -0
- package/lib/adapters/sqlite/database.js +285 -0
- package/lib/adapters/sqlite/index.d.ts +3 -0
- package/lib/adapters/sqlite/index.js +2 -0
- package/lib/adapters/sqlite/lock.d.ts +11 -0
- package/lib/adapters/sqlite/lock.js +34 -0
- package/lib/adapters/sqlite/records.d.ts +54 -0
- package/lib/adapters/sqlite/records.js +546 -0
- package/lib/adapters/sqlite/schema.d.ts +54 -0
- package/lib/adapters/sqlite/schema.js +276 -0
- package/lib/adapters/sqlite/store.d.ts +39 -0
- package/lib/adapters/sqlite/store.js +385 -0
- package/lib/browser/in-page.d.ts +31 -0
- package/lib/browser/in-page.js +46 -0
- package/lib/browser/old-preview.d.ts +13 -0
- package/lib/browser/old-preview.js +80 -0
- package/lib/browser/preview-board.d.ts +8 -0
- package/lib/browser/preview-board.js +69 -0
- package/lib/cli/main.d.ts +7 -0
- package/lib/cli/main.js +343 -0
- package/lib/cli/orbit.d.ts +8 -0
- package/lib/cli/orbit.js +25 -0
- package/lib/client/http.d.ts +38 -0
- package/lib/client/http.js +334 -0
- package/lib/client/local.d.ts +26 -0
- package/lib/client/local.js +190 -0
- package/lib/client/object-url.d.ts +8 -0
- package/lib/client/object-url.js +21 -0
- package/lib/client/types.d.ts +268 -0
- package/lib/client/types.js +8 -0
- package/lib/commands/agent-tools.d.ts +31 -0
- package/lib/commands/agent-tools.js +100 -0
- package/lib/commands/index.d.ts +56 -0
- package/lib/commands/index.js +298 -0
- package/lib/commands/parse.d.ts +73 -0
- package/lib/commands/parse.js +231 -0
- package/lib/commands/present.d.ts +134 -0
- package/lib/commands/present.js +198 -0
- package/lib/commands/session.d.ts +85 -0
- package/lib/commands/session.js +138 -0
- package/lib/commands/verbs.d.ts +35 -0
- package/lib/commands/verbs.js +842 -0
- package/lib/core/actors.d.ts +18 -0
- package/lib/core/actors.js +45 -0
- package/lib/core/agent.d.ts +128 -0
- package/lib/core/agent.js +323 -0
- package/lib/core/boards.d.ts +113 -0
- package/lib/core/boards.js +288 -0
- package/lib/core/bootstrap.d.ts +47 -0
- package/lib/core/bootstrap.js +100 -0
- package/lib/core/bytes.d.ts +10 -0
- package/lib/core/bytes.js +52 -0
- package/lib/core/card-paths.d.ts +25 -0
- package/lib/core/card-paths.js +44 -0
- package/lib/core/context.d.ts +60 -0
- package/lib/core/context.js +42 -0
- package/lib/core/cross-board.d.ts +81 -0
- package/lib/core/cross-board.js +228 -0
- package/lib/core/describe.d.ts +21 -0
- package/lib/core/describe.js +81 -0
- package/lib/core/errors.d.ts +159 -0
- package/lib/core/errors.js +160 -0
- package/lib/core/files.d.ts +79 -0
- package/lib/core/files.js +262 -0
- package/lib/core/history.d.ts +20 -0
- package/lib/core/history.js +53 -0
- package/lib/core/image-types.d.ts +16 -0
- package/lib/core/image-types.js +36 -0
- package/lib/core/model.d.ts +263 -0
- package/lib/core/model.js +34 -0
- package/lib/core/nebula-link.d.ts +14 -0
- package/lib/core/nebula-link.js +39 -0
- package/lib/core/relations.d.ts +58 -0
- package/lib/core/relations.js +112 -0
- package/lib/core/search.d.ts +38 -0
- package/lib/core/search.js +92 -0
- package/lib/core/sessions.d.ts +44 -0
- package/lib/core/sessions.js +158 -0
- package/lib/core/snapshot-schema.d.ts +295 -0
- package/lib/core/snapshot-schema.js +82 -0
- package/lib/core/snapshots.d.ts +75 -0
- package/lib/core/snapshots.js +409 -0
- package/lib/core/states.d.ts +25 -0
- package/lib/core/states.js +126 -0
- package/lib/core/text.d.ts +40 -0
- package/lib/core/text.js +113 -0
- package/lib/core/tickets.d.ts +69 -0
- package/lib/core/tickets.js +311 -0
- package/lib/core/trello.d.ts +17 -0
- package/lib/core/trello.js +269 -0
- package/lib/core/verdicts.d.ts +28 -0
- package/lib/core/verdicts.js +92 -0
- package/lib/core/views.d.ts +95 -0
- package/lib/core/views.js +186 -0
- package/lib/http/app.d.ts +46 -0
- package/lib/http/app.js +491 -0
- package/lib/mcp-server.d.ts +10 -0
- package/lib/mcp-server.js +120 -0
- package/lib/node/assets.d.ts +7 -0
- package/lib/node/assets.js +68 -0
- package/lib/node/data-folder.d.ts +21 -0
- package/lib/node/data-folder.js +34 -0
- package/lib/node/env.d.ts +38 -0
- package/lib/node/env.js +49 -0
- package/lib/node/host-check.d.ts +24 -0
- package/lib/node/host-check.js +53 -0
- package/lib/node/host-services.d.ts +9 -0
- package/lib/node/host-services.js +150 -0
- package/lib/node/local.d.ts +22 -0
- package/lib/node/local.js +61 -0
- package/lib/node/migrate.d.ts +53 -0
- package/lib/node/migrate.js +635 -0
- package/lib/node/package.d.ts +7 -0
- package/lib/node/package.js +14 -0
- package/lib/node/repo-board.d.ts +49 -0
- package/lib/node/repo-board.js +245 -0
- package/lib/node/serve.d.ts +8 -0
- package/lib/node/serve.js +106 -0
- package/lib/orbit.d.ts +47 -0
- package/lib/orbit.js +44 -0
- package/lib/quiet-sqlite-warning.d.ts +1 -0
- package/lib/quiet-sqlite-warning.js +18 -0
- package/lib/store/blob-path.d.ts +6 -0
- package/lib/store/blob-path.js +12 -0
- package/lib/store/conformance.d.ts +52 -0
- package/lib/store/conformance.js +1449 -0
- package/lib/store/search-words.d.ts +10 -0
- package/lib/store/search-words.js +19 -0
- package/lib/store/types.d.ts +452 -0
- package/lib/store/types.js +34 -0
- package/llms.txt +63 -0
- package/package.json +109 -25
- package/src/cli/orbit.js +11 -658
- package/src/mcp-server.js +16 -876
- package/SKILL-ORBIT.md +0 -151
- package/build.mjs +0 -210
- package/dist/full/js/app.4e6fbe20.js +0 -952
- package/dist/full/js/app.4e6fbe20.js.map +0 -7
- package/dist/full/styles.4e60d824.css +0 -1
- package/docs/AGENT_PROTOCOL.md +0 -424
- package/docs/MCP_SETUP.md +0 -54
- package/docs/ORBIT_DISPATCH.md +0 -280
- package/docs/SS-Orbit-Dark.png +0 -0
- package/docs/SS-Orbit-Light.png +0 -0
- package/docs/mcp-client.example.json +0 -13
- package/docs/setup/orbit-init-wizard.md +0 -174
- package/public/apple-touch-icon.png +0 -0
- package/public/cursors/live_arrow.cur +0 -0
- package/public/cursors/live_link.cur +0 -0
- package/public/cursors/live_move.cur +0 -0
- package/public/cursors/live_select.cur +0 -0
- package/public/cursors/live_unavail.cur +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Bold.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-BoldItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Italic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Light.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-LightItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Medium.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-MediumItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Regular.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-SemiBold.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-SemiBoldItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/OFL.txt +0 -93
- package/public/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf +0 -0
- package/public/fonts/Inter/Inter-VariableFont_opsz,wght.ttf +0 -0
- package/public/fonts/Inter/OFL.txt +0 -93
- package/public/fonts/Inter/README.txt +0 -118
- package/public/fonts/JetBrains_Mono/JetBrainsMono-Italic-VariableFont_wght.ttf +0 -0
- package/public/fonts/JetBrains_Mono/JetBrainsMono-VariableFont_wght.ttf +0 -0
- package/public/fonts/JetBrains_Mono/README.txt +0 -79
- package/public/fonts/OpenRing-Regular.ttf +0 -0
- package/public/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf +0 -0
- package/public/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf +0 -0
- package/public/fonts/Open_Sans/README.txt +0 -100
- package/public/fonts/Orbitron/OFL.txt +0 -93
- package/public/fonts/Orbitron/Orbitron-VariableFont_wght.ttf +0 -0
- package/public/fonts/Orbitron/README.txt +0 -68
- package/public/fonts/Rajdhani/OFL.txt +0 -93
- package/public/fonts/Rajdhani/Rajdhani-Bold.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-Light.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-Medium.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-Regular.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-SemiBold.ttf +0 -0
- package/public/fonts/runescape-uf.ttf +0 -0
- package/public/index.html +0 -769
- package/public/js/actor-labels.js +0 -65
- package/public/js/api.js +0 -39
- package/public/js/app.js +0 -45
- package/public/js/artifact-drawer.js +0 -79
- package/public/js/board-export.js +0 -161
- package/public/js/board-menu.js +0 -374
- package/public/js/card-actions.js +0 -545
- package/public/js/config.js +0 -20
- package/public/js/create-card.js +0 -190
- package/public/js/dom.js +0 -19
- package/public/js/drawer.js +0 -273
- package/public/js/font-preference.js +0 -230
- package/public/js/format.js +0 -433
- package/public/js/help-menu.js +0 -31
- package/public/js/icons.js +0 -16
- package/public/js/kanban.js +0 -694
- package/public/js/local-backend.js +0 -1015
- package/public/js/main.js +0 -161
- package/public/js/modal.js +0 -33
- package/public/js/motion-preference.js +0 -64
- package/public/js/panel.js +0 -58
- package/public/js/router.js +0 -134
- package/public/js/search.js +0 -56
- package/public/js/settings-tabs.js +0 -7
- package/public/js/settings.js +0 -1634
- package/public/js/sse.js +0 -159
- package/public/js/starscape.js +0 -332
- package/public/js/state.js +0 -129
- package/public/js/text-editing.js +0 -128
- package/public/js/theme-preference.js +0 -95
- package/public/js/ticket-detail.js +0 -1516
- package/public/js/ticket-typeahead.js +0 -49
- package/public/js/toast.js +0 -89
- package/public/js/topbar-notes.js +0 -155
- package/public/js/transport.js +0 -22
- package/public/js/typeahead.js +0 -206
- package/public/js/unread.js +0 -121
- package/public/js/update-check.js +0 -58
- package/public/js/url-routes.js +0 -94
- package/public/orbits-favicon.png +0 -0
- package/public/orbits-logo-bg-wide.png +0 -0
- package/public/orbits-logo-bg.png +0 -0
- package/public/orbits-logo.png +0 -0
- package/public/styles.css +0 -7709
- package/src/core/agent.js +0 -387
- package/src/core/agents-md.js +0 -109
- package/src/core/artifacts.js +0 -149
- package/src/core/attachments.js +0 -238
- package/src/core/auth.js +0 -85
- package/src/core/backups.js +0 -142
- package/src/core/board.js +0 -148
- package/src/core/boards.js +0 -409
- package/src/core/bootstrap.js +0 -170
- package/src/core/css-bundle.js +0 -30
- package/src/core/db.js +0 -315
- package/src/core/delete-board.js +0 -163
- package/src/core/dispatch.js +0 -1007
- package/src/core/events.js +0 -314
- package/src/core/http.js +0 -115
- package/src/core/migrate.js +0 -198
- package/src/core/paths.js +0 -55
- package/src/core/provision-repo-board.js +0 -119
- package/src/core/queries.js +0 -246
- package/src/core/registry.js +0 -211
- package/src/core/relations.js +0 -163
- package/src/core/review-verdicts.js +0 -138
- package/src/core/router.js +0 -556
- package/src/core/search.js +0 -183
- package/src/core/seed.js +0 -234
- package/src/core/snapshots.js +0 -692
- package/src/core/states.js +0 -167
- package/src/core/system-picker.js +0 -109
- package/src/core/tickets.js +0 -400
- package/src/core/util.js +0 -106
- package/src/core/version-check.js +0 -154
- package/src/mcp/orbit-client.js +0 -287
- package/src/server.js +0 -115
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each command does: reads the board through the port, changes it
|
|
3
|
+
* through the core's use cases (never by writing records itself), and says
|
|
4
|
+
* what happened in Orbit's words (./present.ts), with JSON data for --json.
|
|
5
|
+
*/
|
|
6
|
+
import { CLAIM_NOTE, checkpointTicket, claimNext, claimTicket, completeTicket } from "../core/agent.js";
|
|
7
|
+
import { createBoard, createEntry } from "../core/boards.js";
|
|
8
|
+
import { crossBlockers, crossLinksFrom, linkAcrossBoards, unlinkAcrossBoards } from "../core/cross-board.js";
|
|
9
|
+
import { onBoard } from "../core/context.js";
|
|
10
|
+
import { describeEvent } from "../core/describe.js";
|
|
11
|
+
import { OrbitError } from "../core/errors.js";
|
|
12
|
+
import { parseNebulaUrl } from "../core/nebula-link.js";
|
|
13
|
+
import { createRelation, deleteRelation } from "../core/relations.js";
|
|
14
|
+
import { searchTickets } from "../core/search.js";
|
|
15
|
+
import { archiveTicket, createComment, createTicket, restoreTicket, updateTicket } from "../core/tickets.js";
|
|
16
|
+
import { Lanes, WORK_EVENTS, blockersFromSets, currentHolder, holderFrom, isClosedLane, isHolder, labelsByTicket, unresolvedBlockers } from "../core/views.js";
|
|
17
|
+
import { fail, flagList, flagOn, flagString, freeWords, noMoreWords, parseCardRef, cardLink, usageText } from "./parse.js";
|
|
18
|
+
import { PRIORITY_WORDS, age, authorKey, authorText, block, cardRef, labelled, laneText as L, oneLine, plural, quoted, renderCard, renderView } from "./present.js";
|
|
19
|
+
import { DEFAULT_BOARD, boardList, boardRowFor, callerPlace, hint, inShell, noBoardsText, repoChoice, repoProblemText, resolveBoard, savedDefault, setsComputerDefault } from "./session.js";
|
|
20
|
+
import { asText } from "../core/text.js";
|
|
21
|
+
/** A write's answer is one line of at most this many characters. */
|
|
22
|
+
const ANSWER_BUDGET = 100;
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Lanes, priorities, types and text
|
|
25
|
+
const squash = (value) => asText(value ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
26
|
+
/** A lane by name (any case or spacing), its role, a unique start (2+ characters) or a unique part (4+). */
|
|
27
|
+
function resolveLane(states, input, slug) {
|
|
28
|
+
const wanted = asText(input ?? "").trim();
|
|
29
|
+
const names = () => states.map((s) => L(s.name)).join(", ");
|
|
30
|
+
if (!wanted)
|
|
31
|
+
fail(`Which lane? Lanes: ${names()}.`);
|
|
32
|
+
const key = squash(wanted);
|
|
33
|
+
const pick = (matches) => (matches.length === 1 ? (matches[0] ?? null) : null);
|
|
34
|
+
const found = pick(states.filter((s) => squash(s.name) === key)) ??
|
|
35
|
+
pick(states.filter((s) => s.role && squash(s.role) === key)) ??
|
|
36
|
+
(key.length >= 2 ? pick(states.filter((s) => squash(s.name).startsWith(key))) : null) ??
|
|
37
|
+
(key.length >= 4 ? pick(states.filter((s) => squash(s.name).includes(key))) : null);
|
|
38
|
+
if (!found)
|
|
39
|
+
fail(`No lane "${oneLine(wanted, 60)}" on board ${slug}. Lanes: ${names()}.`);
|
|
40
|
+
return found;
|
|
41
|
+
}
|
|
42
|
+
async function laneOn(bc, input) {
|
|
43
|
+
return resolveLane(await bc.data.read((r) => r.states.list()), input, bc.board.slug);
|
|
44
|
+
}
|
|
45
|
+
const PRIORITY_INPUT = { urgent: 4, high: 3, med: 2, medium: 2, normal: 2, low: 1, maybe: 0 };
|
|
46
|
+
function parsePriority(value) {
|
|
47
|
+
const key = value.trim().toLowerCase();
|
|
48
|
+
const priority = PRIORITY_INPUT[key];
|
|
49
|
+
if (priority === undefined)
|
|
50
|
+
fail("Priority is a word: urgent, high, med, low, maybe.");
|
|
51
|
+
return priority;
|
|
52
|
+
}
|
|
53
|
+
const TYPES = ["task", "feature", "bug", "epic"];
|
|
54
|
+
function parseType(value) {
|
|
55
|
+
const key = value.trim().toLowerCase();
|
|
56
|
+
if (!TYPES.includes(key))
|
|
57
|
+
fail(`Type is one of: ${TYPES.join(", ")}.`);
|
|
58
|
+
return key;
|
|
59
|
+
}
|
|
60
|
+
export const TEXT_TWICE_MESSAGE = "Text given twice: as -m/--text (or the text field) and as words on the line. Send it once: quote it, or put it all in text.";
|
|
61
|
+
/**
|
|
62
|
+
* The command's long text: the text field (or -m), else the words left on the
|
|
63
|
+
* line. Both at once means a word was read as an option (`note #1 see the -m
|
|
64
|
+
* flag`), so it is refused rather than half saved.
|
|
65
|
+
*/
|
|
66
|
+
function textOf(args, from) {
|
|
67
|
+
const text = flagString(args, "text");
|
|
68
|
+
if (text !== undefined && args.positional.length > from)
|
|
69
|
+
fail(TEXT_TWICE_MESSAGE);
|
|
70
|
+
if (text !== undefined)
|
|
71
|
+
return text.trim();
|
|
72
|
+
return freeWords(args, from, "text").join(" ").trim();
|
|
73
|
+
}
|
|
74
|
+
function sendText(session) {
|
|
75
|
+
return session.transport === "cli" ? 'Add it with -m "...".' : "Send it as text.";
|
|
76
|
+
}
|
|
77
|
+
/** A write command written for this transport: `finish #6`, or `orbit finish 6 -m "..."`. */
|
|
78
|
+
function writeHint(session, verb, n, placeholder = "") {
|
|
79
|
+
return session.transport === "cli" ? `orbit ${verb} ${n} -m "${placeholder}"` : `${verb} #${n}`;
|
|
80
|
+
}
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Cards
|
|
83
|
+
async function requireRef(session, word, usage) {
|
|
84
|
+
const shell = inShell(session);
|
|
85
|
+
if (word === undefined)
|
|
86
|
+
fail(`Which card? Usage: ${usageText(shell, usage)}${shell ? " (in a shell #12 starts a comment, so write 12)" : ""}`);
|
|
87
|
+
const ref = parseCardRef(word);
|
|
88
|
+
if (!ref) {
|
|
89
|
+
const first = (await session.c.store.boards.list())[0]?.slug ?? "board";
|
|
90
|
+
const cards = shell ? `numbers (12, or ${first}#12)` : "#n (or board#n)";
|
|
91
|
+
fail(`"${oneLine(word, 60)}" isn't a card. Cards are ${cards}. Usage: ${usageText(shell, usage)}`);
|
|
92
|
+
}
|
|
93
|
+
return ref;
|
|
94
|
+
}
|
|
95
|
+
/** The card a ref names, on its own board (which doesn't change the session's board). */
|
|
96
|
+
async function resolveCard(session, word, args, usage) {
|
|
97
|
+
const ref = await requireRef(session, word, usage);
|
|
98
|
+
const row = await resolveBoard(session, ref.board ?? flagString(args, "board"));
|
|
99
|
+
const bc = await onBoard(session.c, row);
|
|
100
|
+
const found = await bc.data.read(async (r) => {
|
|
101
|
+
const ticket = await r.tickets.byNumber(ref.n);
|
|
102
|
+
if (!ticket)
|
|
103
|
+
return { ticket: null, top: await r.tickets.lastNumber() };
|
|
104
|
+
const lanes = await Lanes.of(r);
|
|
105
|
+
return { ticket: { ...ticket, state_name: lanes.nameOf(ticket), state_role: lanes.roleOf(ticket) }, top: 0 };
|
|
106
|
+
});
|
|
107
|
+
if (!found.ticket)
|
|
108
|
+
fail(`No card #${ref.n} on board ${row.slug}${found.top ? ` (cards go up to #${found.top})` : " (it has no cards yet)"}.`);
|
|
109
|
+
return { bc, ticket: found.ticket };
|
|
110
|
+
}
|
|
111
|
+
/** Comments an agent may see: not the claim note, not one a person hid from agents. */
|
|
112
|
+
const visible = (comment) => !comment.omitted_for_ai && comment.body !== CLAIM_NOTE;
|
|
113
|
+
async function visibleComments(bc, ticketId) {
|
|
114
|
+
const comments = await bc.data.read((r) => r.comments.list(ticketId));
|
|
115
|
+
return comments.filter(visible).map((comment) => ({ author: comment.author, kind: comment.kind, body: comment.body, at: comment.created_at }));
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* A card's links to other boards' cards, as `card` lists them: what it waits
|
|
119
|
+
* on and what it relates to. Read inside the card's own read: these read the
|
|
120
|
+
* other boards, never this one again. What waits on it from other boards
|
|
121
|
+
* shows in the person's app (it would read every board on each `card`).
|
|
122
|
+
*/
|
|
123
|
+
async function acrossBoards(bc, r, ticketId) {
|
|
124
|
+
const outgoing = await r.crossBoardRelations.list(ticketId);
|
|
125
|
+
if (!outgoing.length)
|
|
126
|
+
return { blockedBy: [], related: [] };
|
|
127
|
+
const brief = (card) => (card ? [{ n: card.number, title: card.title, lane: card.state_name, board: card.board_slug }] : []);
|
|
128
|
+
const [waiting, links] = await Promise.all([crossBlockers(bc, outgoing), crossLinksFrom(bc, outgoing, [], true)]);
|
|
129
|
+
return {
|
|
130
|
+
blockedBy: (waiting.get(ticketId) ?? []).flatMap(brief),
|
|
131
|
+
related: links.filter((link) => link.type === "relates_to").flatMap((link) => brief(link.other_ticket))
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
async function cardModel(r, bc, ticket) {
|
|
135
|
+
const lanes = await Lanes.of(r);
|
|
136
|
+
const brief = (row) => ({ n: row.number, title: row.title, lane: lanes.nameOf(row) });
|
|
137
|
+
const parent = ticket.parent_ticket_id ? await r.tickets.get(ticket.parent_ticket_id) : null;
|
|
138
|
+
const children = await r.tickets.list({ parent: ticket.id, archived: false });
|
|
139
|
+
const relations = await r.relations.list(ticket.id);
|
|
140
|
+
const others = new Map((await r.tickets.list({ ids: relations.map((x) => (x.source_ticket_id === ticket.id ? x.target_ticket_id : x.source_ticket_id)), archived: false })).map((t) => [t.id, t]));
|
|
141
|
+
const linked = relations
|
|
142
|
+
.map((relation) => ({ relation, other: others.get(relation.source_ticket_id === ticket.id ? relation.target_ticket_id : relation.source_ticket_id) }))
|
|
143
|
+
.filter((row) => row.other !== undefined)
|
|
144
|
+
.sort((a, b) => a.other.number - b.other.number);
|
|
145
|
+
const blocks = linked.filter(({ relation }) => (relation.type === "blocks" && relation.source_ticket_id === ticket.id) || (relation.type === "blocked_by" && relation.target_ticket_id === ticket.id));
|
|
146
|
+
const related = linked.filter(({ relation }) => relation.type === "relates_to");
|
|
147
|
+
const labels = (await labelsByTicket(r, [ticket.id])).get(ticket.id) ?? [];
|
|
148
|
+
const comments = await r.comments.list(ticket.id);
|
|
149
|
+
const role = lanes.roleOf(ticket);
|
|
150
|
+
const elsewhere = await acrossBoards(bc, r, ticket.id);
|
|
151
|
+
return {
|
|
152
|
+
n: ticket.number,
|
|
153
|
+
title: ticket.title,
|
|
154
|
+
type: ticket.type,
|
|
155
|
+
priority: ticket.priority,
|
|
156
|
+
lane: lanes.nameOf(ticket),
|
|
157
|
+
archived: Boolean(ticket.archived_at),
|
|
158
|
+
labels: labels.map((label) => label.name),
|
|
159
|
+
author: ticket.created_by,
|
|
160
|
+
holder: role === "in_progress" ? await currentHolder(r, ticket.id) : null,
|
|
161
|
+
link: cardLink(bc.board.slug, ticket.number),
|
|
162
|
+
parent: parent ? { n: parent.number, title: parent.title, lane: lanes.nameOf(parent) } : null,
|
|
163
|
+
children: children.map(brief),
|
|
164
|
+
blockedBy: [...(await unresolvedBlockers(r, ticket.id, lanes)).map(brief), ...elsewhere.blockedBy],
|
|
165
|
+
blocks: blocks.map(({ other }) => brief(other)),
|
|
166
|
+
related: [...related.map(({ other }) => brief(other)), ...elsewhere.related],
|
|
167
|
+
description: ticket.description,
|
|
168
|
+
plan: ticket.ai_plan,
|
|
169
|
+
summary: ticket.implementation_summary,
|
|
170
|
+
revisions: ticket.implementation_updates,
|
|
171
|
+
nebula: ticket.nebula_url,
|
|
172
|
+
commentCount: comments.filter(visible).length
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function loadCard(bc, ticket) {
|
|
176
|
+
return bc.data.read((r) => cardModel(r, bc, ticket));
|
|
177
|
+
}
|
|
178
|
+
function cardJson(card, comments) {
|
|
179
|
+
const { priority, ...rest } = card;
|
|
180
|
+
return {
|
|
181
|
+
...rest,
|
|
182
|
+
author: authorKey(card.author),
|
|
183
|
+
priority: PRIORITY_WORDS[priority],
|
|
184
|
+
...(comments ? { comments: comments.map((comment) => ({ ...comment, author: authorKey(comment.author) })) } : {})
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// The board: everything the text view needs, in one read.
|
|
189
|
+
async function loadBoardModel(bc) {
|
|
190
|
+
const { model, states, cross } = await bc.data.read(async (r) => {
|
|
191
|
+
const [board, states, tickets, labels, comments, relations, entries, cross] = await Promise.all([
|
|
192
|
+
r.board.get(),
|
|
193
|
+
r.states.list(),
|
|
194
|
+
r.tickets.list({ archived: false }),
|
|
195
|
+
labelsByTicket(r),
|
|
196
|
+
r.comments.countByTicket({ forAgents: true, exceptBody: CLAIM_NOTE }),
|
|
197
|
+
r.relations.list(),
|
|
198
|
+
r.entries.list(),
|
|
199
|
+
r.crossBoardRelations.list()
|
|
200
|
+
]);
|
|
201
|
+
const lanes = new Lanes(states);
|
|
202
|
+
const inProgress = tickets.filter((t) => lanes.roleOf(t) === "in_progress").map((t) => t.id);
|
|
203
|
+
const latest = inProgress.length ? await r.events.latest(inProgress, WORK_EVENTS) : [];
|
|
204
|
+
const lastWork = new Map(latest.map((event) => [event.ticket_id, event]));
|
|
205
|
+
const open = new Map(tickets.map((t) => [t.id, t]));
|
|
206
|
+
const childrenOf = new Map();
|
|
207
|
+
for (const t of tickets)
|
|
208
|
+
if (t.parent_ticket_id)
|
|
209
|
+
childrenOf.set(t.parent_ticket_id, [...(childrenOf.get(t.parent_ticket_id) ?? []), t]);
|
|
210
|
+
const commentCount = new Map(comments.map((row) => [row.ticket_id, row.count]));
|
|
211
|
+
const columns = states.map((state) => ({ name: state.name, role: state.role, closed: isClosedLane(state), cards: [] }));
|
|
212
|
+
const columnOf = new Map(states.map((state, i) => [state.id, columns[i]]));
|
|
213
|
+
for (const t of tickets) {
|
|
214
|
+
const column = columnOf.get(t.state_id);
|
|
215
|
+
if (!column)
|
|
216
|
+
continue;
|
|
217
|
+
const holder = lanes.roleOf(t) === "in_progress" ? holderFrom(lastWork.get(t.id)) : null;
|
|
218
|
+
column.cards.push({
|
|
219
|
+
id: t.id,
|
|
220
|
+
n: t.number,
|
|
221
|
+
title: t.title,
|
|
222
|
+
type: t.type,
|
|
223
|
+
priority: t.priority,
|
|
224
|
+
labels: (labels.get(t.id) ?? []).map((label) => label.name),
|
|
225
|
+
parent: t.parent_ticket_id ? (open.get(t.parent_ticket_id)?.number ?? null) : null,
|
|
226
|
+
sub: childrenOf.get(t.id)?.length ?? 0,
|
|
227
|
+
blockedBy: column.closed ? [] : blockersFromSets(t.id, relations, open, childrenOf, lanes).map((b) => b.number),
|
|
228
|
+
holder: holder ? { name: holder.name, since: holder.since } : null,
|
|
229
|
+
comments: commentCount.get(t.id) ?? 0
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
for (const column of columns)
|
|
233
|
+
column.cards.sort((a, b) => b.priority - a.priority || a.n - b.n);
|
|
234
|
+
const model = {
|
|
235
|
+
slug: bc.board.slug,
|
|
236
|
+
name: board.name,
|
|
237
|
+
open: columns.filter((column) => !column.closed).reduce((sum, column) => sum + column.cards.length, 0),
|
|
238
|
+
hasRules: Boolean(board.agent_instructions.trim()),
|
|
239
|
+
journalCount: entries.filter((entry) => !entry.struck_at).length,
|
|
240
|
+
lanes: columns
|
|
241
|
+
};
|
|
242
|
+
return { model, states, cross };
|
|
243
|
+
});
|
|
244
|
+
// Cards waiting on other boards' cards, after this board's read: one read per other board.
|
|
245
|
+
if (cross.length) {
|
|
246
|
+
const waiting = await crossBlockers(bc, cross);
|
|
247
|
+
for (const column of model.lanes) {
|
|
248
|
+
if (column.closed)
|
|
249
|
+
continue;
|
|
250
|
+
for (const card of column.cards)
|
|
251
|
+
card.blockedBy.push(...(waiting.get(card.id) ?? []).map((other) => `${other.board_slug}#${other.number}`));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { model, states };
|
|
255
|
+
}
|
|
256
|
+
function viewJson(model) {
|
|
257
|
+
return {
|
|
258
|
+
board: model.slug,
|
|
259
|
+
name: model.name,
|
|
260
|
+
open: model.open,
|
|
261
|
+
lanes: model.lanes.map((lane) => ({
|
|
262
|
+
name: lane.name,
|
|
263
|
+
role: lane.role,
|
|
264
|
+
closed: lane.closed,
|
|
265
|
+
count: lane.cards.length,
|
|
266
|
+
cards: lane.cards.map((card) => ({ n: card.n, title: card.title, type: card.type, labels: card.labels, parent: card.parent, sub: card.sub, blockedBy: card.blockedBy, holder: card.holder, comments: card.comments, priority: PRIORITY_WORDS[card.priority] }))
|
|
267
|
+
}))
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Commands
|
|
272
|
+
export async function cmdView(session, args) {
|
|
273
|
+
noMoreWords(args, 1, "view [board] [--all] [--lane L]");
|
|
274
|
+
const named = flagString(args, "board") ?? args.positional[0];
|
|
275
|
+
const row = await resolveBoard(session, named);
|
|
276
|
+
if (named)
|
|
277
|
+
session.board = row.slug;
|
|
278
|
+
const bc = await onBoard(session.c, row);
|
|
279
|
+
const { model, states } = await loadBoardModel(bc);
|
|
280
|
+
const laneFlag = flagString(args, "lane");
|
|
281
|
+
const lane = laneFlag ? resolveLane(states, laneFlag, row.slug).name : null;
|
|
282
|
+
const data = viewJson(model);
|
|
283
|
+
if (lane)
|
|
284
|
+
data.lanes = data.lanes.filter((column) => column.name === lane);
|
|
285
|
+
return { text: renderView(model, { all: flagOn(args, "all"), lane, hint: (line) => hint(session, line), shell: inShell(session) }), data };
|
|
286
|
+
}
|
|
287
|
+
export async function cmdCard(session, args) {
|
|
288
|
+
noMoreWords(args, 1, "card #n [--comments]");
|
|
289
|
+
const { bc, ticket } = await resolveCard(session, args.positional[0], args, "card #n [--comments]");
|
|
290
|
+
const card = await loadCard(bc, ticket);
|
|
291
|
+
const comments = flagOn(args, "comments") ? await visibleComments(bc, ticket.id) : null;
|
|
292
|
+
return { text: renderCard(card, { comments, hint: (line) => hint(session, line) }), data: cardJson(card, comments) };
|
|
293
|
+
}
|
|
294
|
+
const NO_RULES = "No board rules yet. People set them in the app: Settings, AI, Agent Instructions.";
|
|
295
|
+
/** What an agent does with code when the board's rules say nothing. */
|
|
296
|
+
const WORK_RULES = "work on a branch, commit locally, and never push, merge or deploy";
|
|
297
|
+
/** An agent that didn't say its name writes as "agent", so its cards can't say which agent did what. */
|
|
298
|
+
function isUnnamed(session) {
|
|
299
|
+
return session.actor.type === "agent" && session.actor.name === "agent";
|
|
300
|
+
}
|
|
301
|
+
/** How an unnamed agent names itself, in its transport's words. */
|
|
302
|
+
function nameYourself(session) {
|
|
303
|
+
const how = {
|
|
304
|
+
cli: "export ORBIT_AGENT_NAME=<your name>",
|
|
305
|
+
mcp: "add ORBIT_AGENT_NAME to this server's env in the MCP config.",
|
|
306
|
+
http: "send the x-orbit-agent-name header."
|
|
307
|
+
};
|
|
308
|
+
return `Name yourself so cards say which agent did what: ${how[session.transport] ?? how.mcp ?? ""}`;
|
|
309
|
+
}
|
|
310
|
+
/** The overview's first lines for an agent: how to name itself (while it has no name) and the default work rules. */
|
|
311
|
+
export function agentLines(session) {
|
|
312
|
+
const lines = isUnnamed(session) ? [nameYourself(session)] : [];
|
|
313
|
+
lines.push(`For code, unless the board's rules say otherwise (${hint(session, "journal")}): ${WORK_RULES}.`);
|
|
314
|
+
return lines;
|
|
315
|
+
}
|
|
316
|
+
async function nothingToTake(session, bc) {
|
|
317
|
+
const named = `Take a named card with \`${hint(session, "take #n")}\`.`;
|
|
318
|
+
return bc.data.read(async (r) => {
|
|
319
|
+
const lanes = await Lanes.of(r);
|
|
320
|
+
const ready = lanes.forRole("ai_ready", "AI Ready");
|
|
321
|
+
if (!ready)
|
|
322
|
+
return `Board ${bc.board.slug} has no AI Ready lane. ${named}`;
|
|
323
|
+
const waiting = (await r.tickets.list({ state: ready.id, archived: false })).sort((a, b) => b.priority - a.priority || a.number - b.number);
|
|
324
|
+
if (!waiting.length)
|
|
325
|
+
return `${L(ready.name)} is empty. ${named}`;
|
|
326
|
+
const why = [];
|
|
327
|
+
for (const t of waiting) {
|
|
328
|
+
if (t.type === "epic") {
|
|
329
|
+
why.push(`#${t.number} is an epic`);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const blockers = await unresolvedBlockers(r, t.id, lanes);
|
|
333
|
+
why.push(blockers.length ? `#${t.number} is blocked by ${blockers.map((b) => `#${b.number}`).join(",")}` : `#${t.number} can't be taken`);
|
|
334
|
+
}
|
|
335
|
+
return `Nothing in ${L(ready.name)} can be taken now: ${why.join("; ")}. ${named}`;
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
export async function cmdTake(session, args) {
|
|
339
|
+
const usage = "take [#n] [--force]";
|
|
340
|
+
noMoreWords(args, 1, usage);
|
|
341
|
+
const force = flagOn(args, "force");
|
|
342
|
+
let bc;
|
|
343
|
+
let number;
|
|
344
|
+
if (args.positional[0] !== undefined) {
|
|
345
|
+
const found = await resolveCard(session, args.positional[0], args, usage);
|
|
346
|
+
bc = found.bc;
|
|
347
|
+
const ticket = found.ticket;
|
|
348
|
+
number = ticket.number;
|
|
349
|
+
const again = `\`${hint(session, `take #${ticket.number} --force`)}\``;
|
|
350
|
+
if (!force) {
|
|
351
|
+
if (ticket.type === "epic") {
|
|
352
|
+
const open = await bc.data.read(async (r) => {
|
|
353
|
+
const lanes = await Lanes.of(r);
|
|
354
|
+
return (await r.tickets.list({ parent: ticket.id, archived: false })).filter((t) => !isClosedLane({ name: lanes.nameOf(t), role: lanes.roleOf(t) })).map((t) => `#${t.number}`);
|
|
355
|
+
});
|
|
356
|
+
const first = open[0];
|
|
357
|
+
if (first)
|
|
358
|
+
fail(`#${ticket.number} is an epic with open sub-cards: ${open.join(", ")}. Take one of them (\`${hint(session, `take ${first}`)}\`), or ${again} to work the epic itself.`);
|
|
359
|
+
}
|
|
360
|
+
const holder = ticket.state_role === "in_progress" ? await bc.data.read((r) => currentHolder(r, ticket.id)) : null;
|
|
361
|
+
if (holder && !isHolder(holder, session.actor)) {
|
|
362
|
+
const since = age(holder.since);
|
|
363
|
+
fail(`#${ticket.number} is held by @${oneLine(holder.name, 40)}${since ? ` (${since})` : ""}. ${again} takes it over.`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const result = await claimTicket(bc, { ticket_id: ticket.id, force }, { becomeHolder: true });
|
|
367
|
+
if (!result.claimed && result.reason === "blocked") {
|
|
368
|
+
const blockers = result.blockers.map((b) => cardRef({ n: b.number, title: b.title, lane: b.state_name, board: b.board_slug && b.board_id !== bc.board.id ? b.board_slug : undefined })).join("; ");
|
|
369
|
+
fail(`#${ticket.number} is blocked by ${blockers}. Finish that first, or \`${hint(session, `take #${ticket.number} --force`)}\`.`);
|
|
370
|
+
}
|
|
371
|
+
if (!result.claimed && result.reason === "already_done")
|
|
372
|
+
fail(`#${ticket.number} is in ${L(ticket.state_name)}. \`${hint(session, `take #${ticket.number} --force`)}\` reopens it.`);
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
const row = await resolveBoard(session, flagString(args, "board"));
|
|
376
|
+
bc = await onBoard(session.c, row);
|
|
377
|
+
const type = flagString(args, "type");
|
|
378
|
+
const result = await claimNext(bc, { type: type ? parseType(type) : "" });
|
|
379
|
+
if (!result.claimed)
|
|
380
|
+
fail(await nothingToTake(session, bc));
|
|
381
|
+
const claimed = result.ticket_id;
|
|
382
|
+
number = await bc.data.read(async (r) => (await r.tickets.get(claimed))?.number ?? 0);
|
|
383
|
+
}
|
|
384
|
+
const { card, journal, rules } = await bc.data.read(async (r) => {
|
|
385
|
+
const fresh = await r.tickets.byNumber(number);
|
|
386
|
+
if (!fresh)
|
|
387
|
+
fail(`No card #${number} on board ${bc.board.slug}.`);
|
|
388
|
+
const [board, entries] = await Promise.all([r.board.get(), r.entries.list()]);
|
|
389
|
+
return { card: await cardModel(r, bc, fresh), journal: entries.filter((entry) => !entry.struck_at).length, rules: board.agent_instructions.trim() };
|
|
390
|
+
});
|
|
391
|
+
const n = card.n;
|
|
392
|
+
const ask = session.transport === "cli" ? `\`${writeHint(session, "ask", n, "what's missing")}\`` : `\`${writeHint(session, "ask", n)}\` with what's missing as text`;
|
|
393
|
+
const finish = session.transport === "cli" ? `\`${writeHint(session, "finish", n, "what changed; how you checked")}\`` : `\`${writeHint(session, "finish", n)}\` with what changed and how you checked it`;
|
|
394
|
+
const unnamed = isUnnamed(session) ? ` ${nameYourself(session)}` : "";
|
|
395
|
+
const lines = [`Took #${n}: now ${L(card.lane)}, held by you (${oneLine(session.actor.name, 40)}).${unnamed}`, renderCard(card, { hint: (line) => hint(session, line) }), ""];
|
|
396
|
+
lines.push(rules ? labelled("Board rules:", rules) : `No board rules yet, so for code: ${WORK_RULES}.`);
|
|
397
|
+
if (journal)
|
|
398
|
+
lines.push(`Journal: ${plural(journal, "entry", "entries")} (\`${hint(session, "journal")}\`).`);
|
|
399
|
+
lines.push(`Next: do the work. Goal or "done" unclear? ${ask}, then stop.`);
|
|
400
|
+
lines.push(`Milestones: \`${writeHint(session, "note", n, "...")}\`. Finished: ${finish} (it goes to Review; never Done unless the user says so).`);
|
|
401
|
+
return { text: lines.join("\n"), data: { took: n, card: cardJson(card), rules }, board: bc };
|
|
402
|
+
}
|
|
403
|
+
export async function cmdMove(session, args) {
|
|
404
|
+
const usage = "move #n <lane>";
|
|
405
|
+
const laneWords = freeWords(args, 1, "lane name");
|
|
406
|
+
const { bc, ticket } = await resolveCard(session, args.positional[0], args, usage);
|
|
407
|
+
if (!laneWords.length)
|
|
408
|
+
fail(`Move #${ticket.number} where? Usage: ${usageText(inShell(session), usage)}`);
|
|
409
|
+
const lane = await laneOn(bc, laneWords.join(" "));
|
|
410
|
+
if (lane.id === ticket.state_id)
|
|
411
|
+
return { text: `#${ticket.number} is already in ${L(lane.name)}.`, data: { card: ticket.number, lane: lane.name }, board: bc };
|
|
412
|
+
// A move ends an agent's hold on the card (WORK_EVENTS), so the answer says when there was one.
|
|
413
|
+
const held = (await bc.data.read((r) => currentHolder(r, ticket.id))) !== null;
|
|
414
|
+
await updateTicket(bc, ticket.id, { state_id: lane.id });
|
|
415
|
+
return { text: `#${ticket.number} ${L(ticket.state_name)} -> ${L(lane.name)}${held ? " (released)" : ""}.`, data: { card: ticket.number, from: ticket.state_name, lane: lane.name, ...(held ? { released: true } : {}) }, board: bc };
|
|
416
|
+
}
|
|
417
|
+
export async function cmdFinish(session, args) {
|
|
418
|
+
const { bc, ticket } = await resolveCard(session, args.positional[0], args, "finish #n (summary as text) [--pr URL] [--lane L]");
|
|
419
|
+
const summary = textOf(args, 1);
|
|
420
|
+
if (!summary)
|
|
421
|
+
fail(`finish needs a summary: what changed and how you checked it. ${sendText(session)}`);
|
|
422
|
+
const input = { ticket_id: ticket.id, summary };
|
|
423
|
+
const pr = flagString(args, "pr");
|
|
424
|
+
if (pr)
|
|
425
|
+
input.pr_url = pr;
|
|
426
|
+
const laneFlag = flagString(args, "lane");
|
|
427
|
+
if (laneFlag)
|
|
428
|
+
input.next_state = (await laneOn(bc, laneFlag)).name;
|
|
429
|
+
const after = (await completeTicket(bc, input)).ticket;
|
|
430
|
+
return { text: `#${ticket.number} ${L(ticket.state_name)} -> ${L(after.state_name)}. Summary saved.`, data: { card: ticket.number, from: ticket.state_name, lane: after.state_name }, board: bc };
|
|
431
|
+
}
|
|
432
|
+
export async function cmdAsk(session, args) {
|
|
433
|
+
const { bc, ticket } = await resolveCard(session, args.positional[0], args, "ask #n (what's missing, as text)");
|
|
434
|
+
const message = textOf(args, 1);
|
|
435
|
+
if (!message)
|
|
436
|
+
fail(`ask needs what's missing: the question for a person. ${sendText(session)}`);
|
|
437
|
+
const after = (await checkpointTicket(bc, { ticket_id: ticket.id, message })).ticket;
|
|
438
|
+
return { text: `#${ticket.number} ${L(ticket.state_name)} -> ${L(after.state_name)}, waiting on a person.`, data: { card: ticket.number, from: ticket.state_name, lane: after.state_name }, board: bc };
|
|
439
|
+
}
|
|
440
|
+
export async function cmdNote(session, args) {
|
|
441
|
+
const kind = flagOn(args, "decision") ? "decision" : flagOn(args, "lesson") ? "lesson" : null;
|
|
442
|
+
const { positional } = args;
|
|
443
|
+
if (kind) {
|
|
444
|
+
// Only #n or a link attaches the entry: a title may start with a number or a word like "C#5".
|
|
445
|
+
const attaches = positional.length > 1 && /^(\[\[|orbit:|#\d)/i.test(positional[0] ?? "") && parseCardRef(positional[0]) !== null;
|
|
446
|
+
const words = [...(attaches ? positional.slice(0, 1) : []), ...freeWords(args, attaches ? 1 : 0, "title")];
|
|
447
|
+
let bc;
|
|
448
|
+
let ticketId = null;
|
|
449
|
+
if (attaches) {
|
|
450
|
+
const found = await resolveCard(session, words.shift(), args, "note [#n] --decision <title>");
|
|
451
|
+
bc = found.bc;
|
|
452
|
+
ticketId = found.ticket.id;
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
bc = await onBoard(session.c, await resolveBoard(session, flagString(args, "board")));
|
|
456
|
+
}
|
|
457
|
+
const title = words.join(" ").trim();
|
|
458
|
+
if (!title)
|
|
459
|
+
fail(`A journal entry needs a title: ${hint(session, `note --${kind} <title>`)}, with any detail as text.`);
|
|
460
|
+
const entry = await createEntry(bc, { type: kind, title, body: flagString(args, "text") ?? "", ticket_id: ticketId });
|
|
461
|
+
return { text: `Journal: ${kind} ${quoted(title, 60)} saved.`, data: { journal: entry.id, type: kind, title }, board: bc };
|
|
462
|
+
}
|
|
463
|
+
const { bc, ticket } = await resolveCard(session, positional[0], args, "note #n (text) | note --decision|--lesson <title>");
|
|
464
|
+
const body = textOf(args, 1);
|
|
465
|
+
if (!body)
|
|
466
|
+
fail(`note needs its text. ${sendText(session)}`);
|
|
467
|
+
await createComment(bc, ticket.id, { body });
|
|
468
|
+
return { text: `Noted on #${ticket.number}.`, data: { card: ticket.number }, board: bc };
|
|
469
|
+
}
|
|
470
|
+
async function parentId(session, bc, value) {
|
|
471
|
+
if (/^(none|-|null)$/i.test(value))
|
|
472
|
+
return null;
|
|
473
|
+
const ref = await requireRef(session, value, "--parent #n");
|
|
474
|
+
const parent = await bc.data.read((r) => r.tickets.byNumber(ref.n));
|
|
475
|
+
if (!parent || (ref.board && ref.board !== bc.board.slug))
|
|
476
|
+
fail(`No card #${ref.n} on board ${bc.board.slug} to be the parent.`);
|
|
477
|
+
return parent.id;
|
|
478
|
+
}
|
|
479
|
+
export async function cmdNew(session, args) {
|
|
480
|
+
const usage = "new <title> [--lane L] [--type T] [--priority P] [--label a,b] [--parent #n] (description as text)";
|
|
481
|
+
const title = freeWords(args, 0, "title").join(" ").trim();
|
|
482
|
+
if (!title)
|
|
483
|
+
fail(`new needs a title. Usage: ${usageText(inShell(session), usage)}`);
|
|
484
|
+
const bc = await onBoard(session.c, await resolveBoard(session, flagString(args, "board")));
|
|
485
|
+
const input = { title, description: flagString(args, "text") ?? "", labels: flagList(args, "label") ?? [] };
|
|
486
|
+
const laneFlag = flagString(args, "lane");
|
|
487
|
+
if (laneFlag)
|
|
488
|
+
input.state_id = (await laneOn(bc, laneFlag)).id;
|
|
489
|
+
const type = flagString(args, "type");
|
|
490
|
+
if (type)
|
|
491
|
+
input.type = parseType(type);
|
|
492
|
+
const priority = flagString(args, "priority");
|
|
493
|
+
if (priority)
|
|
494
|
+
input.priority = parsePriority(priority);
|
|
495
|
+
const parent = flagString(args, "parent");
|
|
496
|
+
if (parent)
|
|
497
|
+
input.parent_ticket_id = await parentId(session, bc, parent);
|
|
498
|
+
const created = await createTicket(bc, input);
|
|
499
|
+
const rest = `Created #${created.number} in ${L(created.state_name)}.`;
|
|
500
|
+
const lines = [`Created #${created.number} ${quoted(created.title, Math.max(20, ANSWER_BUDGET - rest.length - 2))} in ${L(created.state_name)}.`];
|
|
501
|
+
// An unnamed agent hears how to name itself on its first card, before it makes the rest as "agent".
|
|
502
|
+
if (isUnnamed(session)) {
|
|
503
|
+
const made = await bc.data.read(async (r) => (await r.tickets.list()).filter((t) => t.created_by === session.actor.name).length);
|
|
504
|
+
if (made === 1)
|
|
505
|
+
lines.push(nameYourself(session));
|
|
506
|
+
}
|
|
507
|
+
return { text: lines.join("\n"), data: { card: created.number, lane: created.state_name, link: cardLink(bc.board.slug, created.number) }, board: bc };
|
|
508
|
+
}
|
|
509
|
+
export async function cmdEdit(session, args) {
|
|
510
|
+
const usage = "edit #n [--title T] [--type T] [--priority P] [--label a,b | --add-label x --drop-label y] [--parent #n|none] [--nebula URL|none] [--plan|--summary|--revisions] (text replaces the description, or the field named)";
|
|
511
|
+
noMoreWords(args, 1, usage, `A new description goes in text${session.transport === "cli" ? ' (-m "...")' : ""}.`);
|
|
512
|
+
const { bc, ticket } = await resolveCard(session, args.positional[0], args, usage);
|
|
513
|
+
const patch = {};
|
|
514
|
+
const changed = [];
|
|
515
|
+
const title = flagString(args, "title");
|
|
516
|
+
if (title !== undefined) {
|
|
517
|
+
patch.title = title.trim();
|
|
518
|
+
changed.push("title");
|
|
519
|
+
}
|
|
520
|
+
const type = flagString(args, "type");
|
|
521
|
+
if (type !== undefined) {
|
|
522
|
+
patch.type = parseType(type);
|
|
523
|
+
changed.push("type");
|
|
524
|
+
}
|
|
525
|
+
const priority = flagString(args, "priority");
|
|
526
|
+
if (priority !== undefined) {
|
|
527
|
+
patch.priority = parsePriority(priority);
|
|
528
|
+
changed.push("priority");
|
|
529
|
+
}
|
|
530
|
+
const set = flagList(args, "label");
|
|
531
|
+
const add = flagList(args, "add-label");
|
|
532
|
+
const drop = flagList(args, "drop-label");
|
|
533
|
+
if (set || add || drop) {
|
|
534
|
+
const current = set ?? (await bc.data.read(async (r) => ((await labelsByTicket(r, [ticket.id])).get(ticket.id) ?? []).map((label) => label.name)));
|
|
535
|
+
const dropped = new Set((drop ?? []).map((name) => name.toLowerCase()));
|
|
536
|
+
const next = [...current, ...(add ?? [])].filter((name) => !dropped.has(name.toLowerCase()));
|
|
537
|
+
patch.labels = [...new Map(next.map((name) => [name.toLowerCase(), name])).values()];
|
|
538
|
+
changed.push("labels");
|
|
539
|
+
}
|
|
540
|
+
const parent = flagString(args, "parent");
|
|
541
|
+
if (parent !== undefined) {
|
|
542
|
+
patch.parent_ticket_id = await parentId(session, bc, parent);
|
|
543
|
+
changed.push("parent");
|
|
544
|
+
}
|
|
545
|
+
const nebula = flagString(args, "nebula");
|
|
546
|
+
if (nebula !== undefined) {
|
|
547
|
+
const value = nebula.trim();
|
|
548
|
+
const kept = /^none$/i.test(value) ? "" : value ? parseNebulaUrl(value) : null;
|
|
549
|
+
if (kept === null)
|
|
550
|
+
fail("--nebula takes a web link (https://...) to a Nebula file or folder, or none to remove it.");
|
|
551
|
+
patch.nebula_url = kept;
|
|
552
|
+
changed.push("Nebula link");
|
|
553
|
+
}
|
|
554
|
+
const field = flagOn(args, "plan") ? ["ai_plan", "plan"] : flagOn(args, "summary") ? ["implementation_summary", "summary"] : flagOn(args, "revisions") ? ["implementation_updates", "revisions"] : ["description", "description"];
|
|
555
|
+
const text = flagString(args, "text");
|
|
556
|
+
if (text !== undefined) {
|
|
557
|
+
patch[field[0]] = text;
|
|
558
|
+
changed.push(field[1]);
|
|
559
|
+
}
|
|
560
|
+
else if (flagOn(args, "plan") || flagOn(args, "summary") || flagOn(args, "revisions")) {
|
|
561
|
+
fail(`--${field[1]} takes its new text as text.`);
|
|
562
|
+
}
|
|
563
|
+
if (!changed.length)
|
|
564
|
+
fail(`Nothing to change. Usage: ${usageText(inShell(session), usage)}`);
|
|
565
|
+
await updateTicket(bc, ticket.id, patch);
|
|
566
|
+
// Show the new title in what room the answer has left.
|
|
567
|
+
const plain = `#${ticket.number} updated: ${changed.join(", ")}.`;
|
|
568
|
+
const shown = typeof patch.title === "string" ? changed.map((name) => (name === "title" ? `title ${quoted(patch.title, Math.max(20, ANSWER_BUDGET - plain.length - 3))}` : name)) : changed;
|
|
569
|
+
return { text: `#${ticket.number} updated: ${shown.join(", ")}.`, data: { card: ticket.number, changed }, board: bc };
|
|
570
|
+
}
|
|
571
|
+
export async function cmdJournal(session, args) {
|
|
572
|
+
noMoreWords(args, 1, "journal [board]");
|
|
573
|
+
const row = await resolveBoard(session, flagString(args, "board") ?? args.positional[0]);
|
|
574
|
+
const bc = await onBoard(session.c, row);
|
|
575
|
+
const { rules, entries } = await bc.data.read(async (r) => {
|
|
576
|
+
const [board, all] = await Promise.all([r.board.get(), r.entries.list()]);
|
|
577
|
+
const live = all.filter((entry) => !entry.struck_at);
|
|
578
|
+
const ids = live.map((entry) => entry.ticket_id).filter((id) => id !== null);
|
|
579
|
+
const numbers = new Map((ids.length ? await r.tickets.list({ ids }) : []).map((t) => [t.id, t.number]));
|
|
580
|
+
return { rules: board.agent_instructions.trim(), entries: live.map((entry) => ({ ...entry, number: entry.ticket_id ? (numbers.get(entry.ticket_id) ?? null) : null })) };
|
|
581
|
+
});
|
|
582
|
+
const lines = [rules ? labelled("Board rules:", rules) : NO_RULES, ""];
|
|
583
|
+
if (!entries.length)
|
|
584
|
+
lines.push("Journal: empty.");
|
|
585
|
+
else {
|
|
586
|
+
const shown = entries.slice(-30);
|
|
587
|
+
lines.push(`Journal (${entries.length}):`);
|
|
588
|
+
if (entries.length > shown.length)
|
|
589
|
+
lines.push(`(${entries.length - shown.length} earlier entries not shown)`);
|
|
590
|
+
for (const entry of shown) {
|
|
591
|
+
const meta = [authorText(entry.created_by), entry.created_at.slice(0, 10), entry.number ? `#${entry.number}` : ""].filter(Boolean).join(", ");
|
|
592
|
+
lines.push(`- ${entry.type}: ${oneLine(entry.title, 160)} (${meta})`);
|
|
593
|
+
if (block(entry.body, ""))
|
|
594
|
+
lines.push(block(entry.body));
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return {
|
|
598
|
+
text: lines.join("\n"),
|
|
599
|
+
data: { rules, journal: entries.map((e) => ({ type: e.type, title: e.title, body: e.body, by: authorKey(e.created_by), at: e.created_at, card: e.number })) }
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
export async function cmdFind(session, args) {
|
|
603
|
+
const q = freeWords(args, 0, "search").join(" ").trim();
|
|
604
|
+
if (!q)
|
|
605
|
+
fail(`Find what? Usage: ${usageText(inShell(session), "find <words>")}`);
|
|
606
|
+
const row = await resolveBoard(session, flagString(args, "board"));
|
|
607
|
+
const bc = await onBoard(session.c, row);
|
|
608
|
+
const { results } = await searchTickets(bc, { q, limit: 20, mode: "summary", max_chars_per_field: 160 });
|
|
609
|
+
if (!results.length)
|
|
610
|
+
return { text: `No card on board ${row.slug} matches "${oneLine(q, 80)}".`, data: { query: q, cards: [] } };
|
|
611
|
+
const lines = [`${plural(results.length, "card")} on board ${row.slug} ${results.length === 1 ? "matches" : "match"} "${oneLine(q, 80)}":`];
|
|
612
|
+
const hits = results.map((hit) => ({ n: Number(hit.number), title: String(hit.title), lane: String(hit.state_name), snippet: typeof hit.snippet === "string" ? hit.snippet : "" }));
|
|
613
|
+
for (const hit of hits) {
|
|
614
|
+
const snippet = oneLine(hit.snippet, 100);
|
|
615
|
+
lines.push(`${cardRef(hit)}${snippet && snippet !== hit.title ? `: ${snippet}` : ""}`);
|
|
616
|
+
}
|
|
617
|
+
return { text: lines.join("\n"), data: { query: q, cards: hits } };
|
|
618
|
+
}
|
|
619
|
+
/** "30m", "2h", "3d", or a date. */
|
|
620
|
+
function parseSince(value, nowMs) {
|
|
621
|
+
const text = value.trim();
|
|
622
|
+
const match = /^(\d+)\s*(m|min|h|d|w)$/i.exec(text);
|
|
623
|
+
if (match) {
|
|
624
|
+
const units = { m: 60_000, min: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
|
|
625
|
+
return new Date(nowMs - Number(match[1]) * (units[(match[2] ?? "m").toLowerCase()] ?? 60_000)).toISOString();
|
|
626
|
+
}
|
|
627
|
+
if (/^\d{4}-\d{2}-\d{2}/.test(text) && Number.isFinite(Date.parse(text)))
|
|
628
|
+
return new Date(Date.parse(text)).toISOString();
|
|
629
|
+
fail("--since takes a time like 30m, 2h, 3d or 2026-10-22.");
|
|
630
|
+
}
|
|
631
|
+
const shortStamp = (iso) => `${iso.slice(5, 10)} ${iso.slice(11, 16)}`;
|
|
632
|
+
export async function cmdChanges(session, args) {
|
|
633
|
+
noMoreWords(args, 1, "changes [board] [--since 2h]");
|
|
634
|
+
const row = await resolveBoard(session, flagString(args, "board") ?? args.positional[0]);
|
|
635
|
+
const bc = await onBoard(session.c, row);
|
|
636
|
+
const since = parseSince(flagString(args, "since") ?? "24h", Date.parse(session.c.clock.now()));
|
|
637
|
+
const events = await bc.data.read(async (r) => {
|
|
638
|
+
const list = await r.events.list({ since });
|
|
639
|
+
const ids = [...new Set(list.map((e) => e.ticket_id).filter((id) => id !== null))];
|
|
640
|
+
const tickets = new Map((ids.length ? await r.tickets.list({ ids }) : []).map((t) => [t.id, t]));
|
|
641
|
+
return list.map((e) => {
|
|
642
|
+
const ticket = e.ticket_id ? tickets.get(e.ticket_id) : undefined;
|
|
643
|
+
return { ...e, number: ticket?.number ?? null, title: ticket?.title ?? null };
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
const shown = events.slice(-50);
|
|
647
|
+
if (!events.length)
|
|
648
|
+
return { text: `No changes on board ${row.slug} since ${shortStamp(since)}.`, data: { since, changes: [] } };
|
|
649
|
+
const lines = [`Changes on board ${row.slug} since ${shortStamp(since)}: ${events.length}`];
|
|
650
|
+
if (events.length > shown.length)
|
|
651
|
+
lines.push(`(${events.length - shown.length} earlier not shown; narrow --since)`);
|
|
652
|
+
for (const event of shown)
|
|
653
|
+
lines.push(`${shortStamp(event.created_at)} ${authorText(event.actor)} ${describeEvent(event)}`);
|
|
654
|
+
return {
|
|
655
|
+
text: lines.join("\n"),
|
|
656
|
+
data: { since, changes: shown.map((event) => ({ at: event.created_at, by: authorKey(event.actor), type: event.type, card: event.number, what: describeEvent(event) })) }
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
/** A board's open cards; "missing" when its file is gone, null when it can't be read for another reason. */
|
|
660
|
+
async function openCount(session, id) {
|
|
661
|
+
try {
|
|
662
|
+
const data = await session.c.store.board(id);
|
|
663
|
+
return await data.read(async (r) => {
|
|
664
|
+
const lanes = await Lanes.of(r);
|
|
665
|
+
return (await r.tickets.list({ archived: false })).filter((t) => !isClosedLane({ name: lanes.nameOf(t), role: lanes.roleOf(t) })).length;
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
catch (error) {
|
|
669
|
+
return error instanceof OrbitError && error.code === "board_file_missing" ? "missing" : null;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
export const BOARDS_USAGE = "boards | boards new <name> [--slug s] | boards use <board> | boards link <board> | boards unlink";
|
|
673
|
+
export async function cmdBoards(session, args) {
|
|
674
|
+
const [sub, ...rest] = args.positional;
|
|
675
|
+
if (sub === "use") {
|
|
676
|
+
noMoreWords(args, 2, "boards use <board>");
|
|
677
|
+
const slug = rest[0];
|
|
678
|
+
if (!slug)
|
|
679
|
+
fail("Use which board? boards use <board>");
|
|
680
|
+
const row = await boardRowFor(session, slug);
|
|
681
|
+
if (!row)
|
|
682
|
+
fail(`No board "${oneLine(slug, 60)}". Boards: ${(await boardList(session)) || "none yet"}.`);
|
|
683
|
+
session.board = row.slug;
|
|
684
|
+
if (setsComputerDefault(session)) {
|
|
685
|
+
await session.c.store.settings.set(DEFAULT_BOARD, row.slug);
|
|
686
|
+
return { text: `Board ${row.slug} is now the default for every folder and agent on this computer.`, data: { board: row.slug, default: true } };
|
|
687
|
+
}
|
|
688
|
+
// One agent's choice must never move another agent's commands.
|
|
689
|
+
const text = session.transport === "cli"
|
|
690
|
+
? `Board ${row.slug} chosen for this command only. This Orbit is shared, so set ORBIT_BOARD=${row.slug} or name the board in each command.`
|
|
691
|
+
: `Board ${row.slug} chosen for this session; the default board stays as it was.`;
|
|
692
|
+
return { text, data: { board: row.slug, default: false } };
|
|
693
|
+
}
|
|
694
|
+
if (sub === "new") {
|
|
695
|
+
const name = freeWords(args, 1, "board name").join(" ").trim();
|
|
696
|
+
if (!name)
|
|
697
|
+
fail("boards new needs a name: boards new <name> [--slug s] [--no-ai]");
|
|
698
|
+
const input = { name };
|
|
699
|
+
const slug = flagString(args, "slug");
|
|
700
|
+
if (slug)
|
|
701
|
+
input.slug = slug;
|
|
702
|
+
if (flagOn(args, "no-ai"))
|
|
703
|
+
input.ai_enabled = false;
|
|
704
|
+
const created = await createBoard(session.c, input);
|
|
705
|
+
const row = await session.c.store.boards.get(created.id);
|
|
706
|
+
if (!row)
|
|
707
|
+
fail(`Board ${created.slug} was made but isn't listed.`);
|
|
708
|
+
session.board = row.slug;
|
|
709
|
+
const bc = await onBoard(session.c, row);
|
|
710
|
+
const lanes = (await bc.data.read((r) => r.states.list())).map((s) => L(s.name));
|
|
711
|
+
// A shell forgets between commands, and an MCP session ends, so with several boards the answer says how a repo keeps it.
|
|
712
|
+
const made = `Created board ${row.slug} (${quoted(created.name, 60)})`;
|
|
713
|
+
const several = (await session.c.store.boards.list()).length > 1;
|
|
714
|
+
const text = session.transport !== "cli"
|
|
715
|
+
? `${made} and chose it. Lanes: ${lanes.join(", ")}.${several ? ` For later sessions, let ${callerPlace(session)} name it (boards link ${row.slug} says how).` : ""}`
|
|
716
|
+
: `${made}. Lanes: ${lanes.join(", ")}.${several ? ` Name it in commands (orbit view ${row.slug}) or let ${callerPlace(session)} name it: orbit boards link ${row.slug}.` : ""}`;
|
|
717
|
+
return { text, data: { board: row.slug, lanes }, board: bc };
|
|
718
|
+
}
|
|
719
|
+
if (sub === "link" || sub === "unlink")
|
|
720
|
+
return boardsLink(session, args, sub, rest);
|
|
721
|
+
if (sub !== undefined)
|
|
722
|
+
fail(`boards takes: ${BOARDS_USAGE}.`);
|
|
723
|
+
const rows = await session.c.store.boards.list();
|
|
724
|
+
if (!rows.length)
|
|
725
|
+
return { text: noBoardsText(session), data: { boards: [] } };
|
|
726
|
+
const lines = [`Boards (${rows.length}):`];
|
|
727
|
+
const data = [];
|
|
728
|
+
const saved = (await savedDefault(session))?.slug ?? null;
|
|
729
|
+
// The board a command naming none would use, as resolveBoard picks it.
|
|
730
|
+
const repo = repoChoice(session);
|
|
731
|
+
const repoRow = repo.board ? rows.find((row) => row.slug === repo.board) : undefined;
|
|
732
|
+
const inUse = session.board ?? (repo.board || repo.problem ? repoRow?.slug : (saved ?? (rows.length === 1 ? rows[0]?.slug : null)));
|
|
733
|
+
for (const row of rows) {
|
|
734
|
+
const open = await openCount(session, row.id);
|
|
735
|
+
const current = row.slug === inUse ? "*" : " ";
|
|
736
|
+
const fromRepo = Boolean(repoRow && row.slug === repoRow.slug);
|
|
737
|
+
const notes = [row.slug === saved ? "default" : "", fromRepo ? "this repo's orbit.json" : ""].filter(Boolean);
|
|
738
|
+
const count = open === "missing" ? "file missing" : `${open ?? "?"} open`;
|
|
739
|
+
lines.push(`${current} ${row.slug} ${quoted(row.name, 60)}: ${count}${notes.length ? ` (${notes.join("; ")})` : ""}`);
|
|
740
|
+
data.push({ board: row.slug, name: row.name, open: typeof open === "number" ? open : null, ...(open === "missing" ? { file_missing: true } : {}), current: current === "*", default: row.slug === saved, repo: fromRepo });
|
|
741
|
+
}
|
|
742
|
+
if (repo.problem)
|
|
743
|
+
lines.push(repoProblemText(repo));
|
|
744
|
+
else if (repo.board && !repoRow)
|
|
745
|
+
lines.push(`This repo's orbit.json names board ${oneLine(repo.board, 60)}, which this Orbit doesn't have.`);
|
|
746
|
+
return { text: lines.join("\n"), data: { boards: data } };
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* `boards link <board>`: the repo names its board in orbit.json. Only a shell
|
|
750
|
+
* on this computer writes the file (a remote shell's client writes it after
|
|
751
|
+
* the server checks the board); elsewhere Orbit writes no files, so the
|
|
752
|
+
* answer says how.
|
|
753
|
+
*/
|
|
754
|
+
async function boardsLink(session, args, sub, rest) {
|
|
755
|
+
noMoreWords(args, sub === "link" ? 2 : 1, sub === "link" ? "boards link <board>" : "boards unlink");
|
|
756
|
+
const writer = setsComputerDefault(session) ? session.repo?.link : undefined;
|
|
757
|
+
let slug = null;
|
|
758
|
+
if (sub === "link") {
|
|
759
|
+
const ident = rest[0];
|
|
760
|
+
if (!ident)
|
|
761
|
+
fail(`Link which board? ${hint(session, "boards link <board>")} (boards: ${(await boardList(session)) || "none yet"}).`);
|
|
762
|
+
const row = await boardRowFor(session, ident);
|
|
763
|
+
if (!row)
|
|
764
|
+
fail(`No board "${oneLine(ident, 60)}". Boards: ${(await boardList(session)) || "none yet"}.`);
|
|
765
|
+
slug = row.slug;
|
|
766
|
+
}
|
|
767
|
+
if (writer) {
|
|
768
|
+
const result = writer(sub, slug);
|
|
769
|
+
if (!result.ok)
|
|
770
|
+
fail(result.text);
|
|
771
|
+
return { text: result.text, data: result.data };
|
|
772
|
+
}
|
|
773
|
+
const text = sub === "link"
|
|
774
|
+
? `Orbit writes no files from here. To have the repo name board ${slug ?? ""}, run orbit boards link ${slug ?? ""} in a terminal there, or commit an orbit.json at the repo's top holding {"board": "${slug ?? ""}"}.`
|
|
775
|
+
: 'Orbit writes no files from here. To unlink, run orbit boards unlink in a terminal in the repo, or remove "board" from its orbit.json.';
|
|
776
|
+
return { text, data: { board: slug, linked: false } };
|
|
777
|
+
}
|
|
778
|
+
const LINK_TYPES = { blocks: "blocks", "blocked-by": "blocked_by", blockedby: "blocked_by", relates: "relates_to", "relates-to": "relates_to", related: "relates_to" };
|
|
779
|
+
const LINK_WORDS = { blocks: "blocks", blocked_by: "is blocked by", relates_to: "relates to" };
|
|
780
|
+
export async function cmdLink(session, args) {
|
|
781
|
+
const usage = "link #a blocks|blocked-by|relates #b";
|
|
782
|
+
noMoreWords(args, 3, usage);
|
|
783
|
+
const [first, how, second] = args.positional;
|
|
784
|
+
const type = LINK_TYPES[(how ?? "").toLowerCase()];
|
|
785
|
+
if (!type || second === undefined)
|
|
786
|
+
fail(`Link how? ${usage}`);
|
|
787
|
+
const a = await resolveCard(session, first, args, usage);
|
|
788
|
+
const b = await resolveCard(session, second, args, usage);
|
|
789
|
+
if (a.bc.board.id !== b.bc.board.id) {
|
|
790
|
+
// Across boards a link can block or relate; it lives on the card that waits.
|
|
791
|
+
await linkAcrossBoards(session.c, a.bc, a.ticket.id, b.bc, b.ticket.id, type);
|
|
792
|
+
const [from, to] = [`${a.bc.board.slug}#${a.ticket.number}`, `${b.bc.board.slug}#${b.ticket.number}`];
|
|
793
|
+
return { text: `${from} ${LINK_WORDS[type] ?? type} ${to}.`, data: { from, type, to }, board: a.bc };
|
|
794
|
+
}
|
|
795
|
+
await createRelation(a.bc, { source_ticket_id: a.ticket.id, target_ticket_id: b.ticket.id, type });
|
|
796
|
+
return { text: `#${a.ticket.number} ${LINK_WORDS[type] ?? type} #${b.ticket.number}.`, data: { from: a.ticket.number, type, to: b.ticket.number }, board: a.bc };
|
|
797
|
+
}
|
|
798
|
+
/** The links across boards between two cards, each with the board it lives on. */
|
|
799
|
+
async function crossLinksBetween(a, b) {
|
|
800
|
+
const found = [];
|
|
801
|
+
for (const [here, there] of [[a, b], [b, a]]) {
|
|
802
|
+
const ids = await here.bc.data.read(async (r) => (await r.crossBoardRelations.list(here.ticket.id)).filter((x) => x.target_board_id === there.bc.board.id && x.target_ticket_id === there.ticket.id).map((x) => x.id));
|
|
803
|
+
for (const id of ids)
|
|
804
|
+
found.push({ bc: here.bc, id });
|
|
805
|
+
}
|
|
806
|
+
return found;
|
|
807
|
+
}
|
|
808
|
+
export async function cmdUnlink(session, args) {
|
|
809
|
+
const usage = "unlink #a #b";
|
|
810
|
+
noMoreWords(args, 2, usage);
|
|
811
|
+
const a = await resolveCard(session, args.positional[0], args, usage);
|
|
812
|
+
const b = await resolveCard(session, args.positional[1], args, usage);
|
|
813
|
+
if (a.bc.board.id !== b.bc.board.id) {
|
|
814
|
+
const [from, to] = [`${a.bc.board.slug}#${a.ticket.number}`, `${b.bc.board.slug}#${b.ticket.number}`];
|
|
815
|
+
const links = await crossLinksBetween(a, b);
|
|
816
|
+
if (!links.length)
|
|
817
|
+
fail(`${from} and ${to} aren't linked.`);
|
|
818
|
+
for (const link of links)
|
|
819
|
+
await unlinkAcrossBoards(link.bc, link.id);
|
|
820
|
+
return { text: `${from} and ${to} unlinked.`, data: { unlinked: [from, to] }, board: a.bc };
|
|
821
|
+
}
|
|
822
|
+
const ids = await a.bc.data.read(async (r) => (await r.relations.list(a.ticket.id))
|
|
823
|
+
.filter((x) => (x.source_ticket_id === a.ticket.id && x.target_ticket_id === b.ticket.id) || (x.source_ticket_id === b.ticket.id && x.target_ticket_id === a.ticket.id))
|
|
824
|
+
.map((x) => x.id));
|
|
825
|
+
if (!ids.length)
|
|
826
|
+
fail(`#${a.ticket.number} and #${b.ticket.number} aren't linked.`);
|
|
827
|
+
for (const id of ids)
|
|
828
|
+
await deleteRelation(a.bc, id);
|
|
829
|
+
return { text: `#${a.ticket.number} and #${b.ticket.number} unlinked.`, data: { unlinked: [a.ticket.number, b.ticket.number] }, board: a.bc };
|
|
830
|
+
}
|
|
831
|
+
export async function cmdArchive(session, args) {
|
|
832
|
+
noMoreWords(args, 1, "archive #n");
|
|
833
|
+
const { bc, ticket } = await resolveCard(session, args.positional[0], args, "archive #n");
|
|
834
|
+
await archiveTicket(bc, ticket.id);
|
|
835
|
+
return { text: `#${ticket.number} archived.`, data: { card: ticket.number, archived: true }, board: bc };
|
|
836
|
+
}
|
|
837
|
+
export async function cmdRestore(session, args) {
|
|
838
|
+
noMoreWords(args, 1, "restore #n");
|
|
839
|
+
const { bc, ticket } = await resolveCard(session, args.positional[0], args, "restore #n");
|
|
840
|
+
const after = await restoreTicket(bc, ticket.id);
|
|
841
|
+
return { text: `#${ticket.number} restored to ${L(after.state_name)}.`, data: { card: ticket.number, lane: after.state_name }, board: bc };
|
|
842
|
+
}
|