@gavin-hu/my-pi-agent 0.20.0 → 0.22.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/README.md +46 -30
- package/README.zh-CN.md +40 -25
- package/assets/screens/board.png +0 -0
- package/assets/screens/chrome.png +0 -0
- package/assets/screens/file-browse.png +0 -0
- package/assets/screens/plan-review.png +0 -0
- package/assets/screens/plan.png +0 -0
- package/docs/extensions/README.md +22 -23
- package/docs/extensions/board.md +397 -168
- package/docs/extensions/bytefly.md +234 -197
- package/docs/extensions/check.md +15 -17
- package/docs/extensions/context.md +35 -15
- package/docs/extensions/file-browse.md +57 -63
- package/docs/extensions/file-open.md +9 -11
- package/docs/extensions/file-read.md +12 -14
- package/docs/extensions/goal.md +15 -17
- package/docs/extensions/job.md +54 -42
- package/docs/extensions/memory.md +17 -19
- package/docs/extensions/plan.md +197 -140
- package/docs/extensions/question.md +16 -18
- package/docs/extensions/rewind.md +17 -19
- package/docs/extensions/serve.md +145 -0
- package/docs/extensions/session-bus.md +38 -16
- package/docs/extensions/subagent.md +23 -25
- package/docs/extensions/team.md +83 -44
- package/docs/extensions/todo.md +18 -18
- package/docs/extensions/watch.md +31 -31
- package/docs/extensions/web-access.md +2 -4
- package/docs/extensions/wechat.md +34 -35
- package/docs/extensions/worktree.md +56 -50
- package/extensions/board/commands.ts +79 -25
- package/extensions/board/format.ts +43 -24
- package/extensions/board/index.ts +17 -4
- package/extensions/board/input.ts +4 -9
- package/extensions/board/join.ts +5 -20
- package/extensions/board/lazy-page.ts +40 -0
- package/extensions/board/page.ts +65 -93
- package/extensions/board/result.ts +30 -39
- package/extensions/board/rows.ts +62 -25
- package/extensions/board/schema.ts +49 -11
- package/extensions/board/snapshot.ts +8 -6
- package/extensions/board/tone.ts +22 -0
- package/extensions/board/tools.ts +154 -55
- package/extensions/board/transport/router.ts +3 -5
- package/extensions/board/transport/server.ts +59 -28
- package/extensions/board/types.ts +36 -42
- package/extensions/board/view/card.ts +229 -0
- package/extensions/board/view/client.ts +35 -16
- package/extensions/board/view/dialog.ts +169 -6
- package/extensions/board/view/empty.ts +47 -0
- package/extensions/board/view/keys.ts +13 -2
- package/extensions/board/view/live.ts +90 -28
- package/extensions/board/view/names.ts +13 -4
- package/extensions/board/view/page.ts +38 -13
- package/extensions/board/view/render.ts +219 -277
- package/extensions/board/view/styles.ts +130 -32
- package/extensions/board/view/team.ts +297 -76
- package/extensions/bytefly/block/fallback.ts +1 -1
- package/extensions/bytefly/doctor/runtime.ts +5 -2
- package/extensions/bytefly/footer/collect/label.ts +60 -0
- package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
- package/extensions/bytefly/footer/config.ts +7 -8
- package/extensions/bytefly/footer/register.ts +33 -4
- package/extensions/bytefly/footer/render/footer.ts +18 -4
- package/extensions/bytefly/footer/render/key.ts +46 -0
- package/extensions/bytefly/footer/render/layout.ts +13 -3
- package/extensions/bytefly/footer/render/lines.ts +10 -7
- package/extensions/bytefly/footer/render/segments.ts +41 -38
- package/extensions/bytefly/footer/render/status.ts +0 -11
- package/extensions/bytefly/footer/types.ts +7 -0
- package/extensions/bytefly/header/component.ts +4 -20
- package/extensions/bytefly/header/config.ts +1 -1
- package/extensions/bytefly/index.ts +32 -6
- package/extensions/bytefly/mode/editor.ts +115 -0
- package/extensions/bytefly/panel/commands.ts +210 -120
- package/extensions/bytefly/panel/format.ts +4 -4
- package/extensions/bytefly/panel/settings.ts +73 -6
- package/extensions/bytefly/panel/tui/keys.ts +2 -6
- package/extensions/bytefly/update/cache.ts +1 -1
- package/extensions/bytefly/update/register.ts +4 -4
- package/extensions/bytefly/working/config.ts +5 -7
- package/extensions/bytefly/working/format.ts +10 -13
- package/extensions/bytefly/working/register.ts +22 -27
- package/extensions/check/commands.ts +3 -2
- package/extensions/check/render.ts +10 -1
- package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
- package/extensions/context/commands.ts +2 -2
- package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
- package/extensions/file-browse/config/config.ts +4 -19
- package/extensions/file-browse/index.ts +89 -79
- package/extensions/file-browse/live/live.ts +1 -1
- package/extensions/file-browse/transport/router.ts +12 -4
- package/extensions/file-browse/transport/server.ts +61 -28
- package/extensions/file-browse/view/chrome.ts +4 -4
- package/extensions/file-browse/view/client.ts +3 -1
- package/extensions/file-browse/view/html.ts +73 -40
- package/extensions/file-browse/view/live-client.ts +2 -2
- package/extensions/file-browse/view/markdown.ts +13 -14
- package/extensions/file-browse/view/palette.ts +2 -2
- package/extensions/file-browse/view/raw-html.ts +9 -9
- package/extensions/file-browse/view/tree.ts +1 -1
- package/extensions/file-browse/view/urls.ts +20 -6
- package/extensions/file-read/render.ts +4 -4
- package/extensions/goal/plan-route.ts +2 -2
- package/extensions/goal/tui.ts +12 -2
- package/extensions/job/child.ts +527 -0
- package/extensions/job/clock.ts +142 -0
- package/extensions/job/commands.ts +4 -2
- package/extensions/job/config.ts +8 -1
- package/extensions/job/format.ts +22 -1
- package/extensions/job/log-writer.ts +13 -2
- package/extensions/job/registry.ts +63 -7
- package/extensions/job/render.ts +34 -0
- package/extensions/job/runtime.ts +296 -655
- package/extensions/job/session.ts +132 -19
- package/extensions/job/store.ts +7 -7
- package/extensions/job/tools.ts +21 -9
- package/extensions/job/types.ts +47 -0
- package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -99
- package/extensions/job/waiters.ts +44 -1
- package/extensions/memory/commands.ts +4 -2
- package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
- package/extensions/plan/commands.ts +12 -9
- package/extensions/plan/feedback.ts +62 -16
- package/extensions/plan/format.ts +8 -2
- package/extensions/plan/git-bash.ts +115 -16
- package/extensions/plan/index.ts +58 -51
- package/extensions/plan/plan-index.ts +4 -2
- package/extensions/plan/plan-text.ts +114 -0
- package/extensions/plan/plans.ts +5 -54
- package/extensions/plan/policy.ts +8 -10
- package/extensions/plan/review-lazy.ts +85 -0
- package/extensions/plan/review.ts +2 -2
- package/extensions/plan/runtime.ts +61 -41
- package/extensions/plan/sections.ts +1 -1
- package/extensions/plan/steps.ts +3 -3
- package/extensions/plan/todos.ts +1 -1
- package/extensions/plan/tools/cancel.ts +27 -62
- package/extensions/plan/tools/enter.ts +55 -81
- package/extensions/plan/tools/exit.ts +221 -238
- package/extensions/plan/tools/refusal.ts +18 -0
- package/extensions/plan/tools/steps.ts +75 -92
- package/extensions/plan/tools/write.ts +77 -120
- package/extensions/plan/tools.ts +188 -30
- package/extensions/plan/tui.ts +5 -5
- package/extensions/plan/types.ts +48 -5
- package/extensions/plan/web-blocks.ts +76 -5
- package/extensions/plan/web-chrome.ts +4 -0
- package/extensions/plan/web-client.ts +102 -2
- package/extensions/plan/web-collab.ts +4 -4
- package/extensions/plan/web-comments.ts +39 -8
- package/extensions/plan/web-composer.ts +14 -3
- package/extensions/plan/web-keys.ts +149 -25
- package/extensions/plan/web-lazy.ts +53 -0
- package/extensions/plan/web-markdown.ts +10 -68
- package/extensions/plan/web-outline.ts +22 -2
- package/extensions/plan/web-page.ts +56 -86
- package/extensions/plan/web-payload.ts +188 -0
- package/extensions/plan/web-plan.ts +23 -13
- package/extensions/plan/web-router.ts +18 -9
- package/extensions/plan/web-settle.ts +14 -7
- package/extensions/plan/web-share-session.ts +24 -55
- package/extensions/plan/web-share.ts +19 -37
- package/extensions/plan/web-write.ts +112 -0
- package/extensions/plan/web.ts +67 -131
- package/extensions/question/tools.ts +6 -2
- package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
- package/extensions/rewind/commands/commands.ts +6 -3
- package/extensions/rewind/state/runtime.ts +1 -1
- package/extensions/rewind/{view/status.ts → status.ts} +4 -4
- package/extensions/rewind/view/review-tui.ts +4 -4
- package/extensions/serve/config.ts +39 -0
- package/extensions/serve/index.ts +85 -0
- package/extensions/serve/registry.ts +260 -0
- package/extensions/session-bus/commands.ts +11 -6
- package/extensions/session-bus/join-request.ts +17 -9
- package/extensions/session-bus/mail-watch.ts +26 -17
- package/extensions/session-bus/protocol.ts +15 -3
- package/extensions/session-bus/resolve.ts +5 -7
- package/extensions/session-bus/result.ts +1 -27
- package/extensions/session-bus/runtime.ts +40 -12
- package/extensions/session-bus/store.ts +101 -19
- package/extensions/session-bus/tools.ts +5 -4
- package/extensions/session-bus/types.ts +19 -0
- package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
- package/extensions/subagent/agents.ts +1 -1
- package/extensions/subagent/commands.ts +4 -2
- package/extensions/subagent/format.ts +15 -15
- package/extensions/subagent/index.ts +1 -1
- package/extensions/subagent/render.ts +3 -3
- package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
- package/extensions/team/commands.ts +24 -5
- package/extensions/team/dispatch.ts +18 -4
- package/extensions/team/format.ts +17 -2
- package/extensions/team/index.ts +9 -8
- package/extensions/team/schema.ts +6 -5
- package/extensions/team/tools.ts +107 -45
- package/extensions/team/types.ts +3 -2
- package/extensions/todo/commands.ts +4 -2
- package/extensions/todo/format.ts +11 -0
- package/extensions/todo/render.ts +57 -0
- package/extensions/todo/runtime.ts +1 -1
- package/extensions/todo/tools.ts +1 -1
- package/extensions/todo/{tui.ts → view/todo-screen.ts} +18 -90
- package/extensions/todo/widget.ts +47 -0
- package/extensions/watch/commands.ts +4 -2
- package/extensions/watch/format.ts +36 -7
- package/extensions/watch/render.ts +57 -0
- package/extensions/watch/runtime.ts +27 -22
- package/extensions/watch/tools.ts +4 -4
- package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
- package/extensions/web-access/fetch/tool.ts +12 -4
- package/extensions/wechat/commands/commands.ts +18 -14
- package/extensions/wechat/index.ts +72 -37
- package/extensions/wechat/state/credentials.ts +1 -1
- package/extensions/wechat/state/state.ts +1 -1
- package/extensions/wechat/text/status.ts +112 -0
- package/extensions/wechat/types.ts +2 -2
- package/extensions/wechat/view/status-screen.ts +4 -103
- package/extensions/worktree/commands.ts +11 -9
- package/extensions/worktree/enter.ts +2 -2
- package/extensions/worktree/exit.ts +2 -2
- package/extensions/worktree/index.ts +5 -5
- package/extensions/worktree/screen.ts +3 -3
- package/extensions/worktree/status.ts +2 -2
- package/extensions/worktree/tools.ts +113 -116
- package/lib/README.md +28 -13
- package/lib/board/actions.ts +111 -0
- package/lib/board/artifact.ts +149 -0
- package/lib/board/card.ts +163 -52
- package/lib/board/config.ts +58 -0
- package/lib/board/contract.ts +113 -0
- package/lib/board/join.ts +38 -0
- package/lib/board/lanes.ts +182 -0
- package/lib/board/lead.ts +130 -0
- package/lib/board/limits.ts +78 -0
- package/lib/board/links.ts +31 -10
- package/lib/board/page-pointer.ts +30 -5
- package/lib/board/read.ts +83 -0
- package/lib/board/reason.ts +102 -0
- package/lib/board/round-text.ts +68 -6
- package/lib/board/round.ts +31 -15
- package/lib/board/rules.ts +151 -307
- package/lib/board/seat.ts +35 -0
- package/lib/board/session.ts +199 -51
- package/lib/board/skipped.ts +22 -0
- package/lib/board/steps.ts +487 -0
- package/lib/board/store.ts +119 -36
- package/lib/board/tone.ts +92 -0
- package/lib/board/view.ts +81 -63
- package/lib/bus-request.ts +83 -0
- package/{extensions/watch → lib}/child.ts +40 -21
- package/lib/env.ts +65 -7
- package/lib/format.ts +13 -0
- package/lib/http/index.ts +17 -11
- package/lib/http/mount.ts +142 -0
- package/lib/http/port.ts +30 -0
- package/lib/http/serve.ts +170 -46
- package/lib/http/server.ts +3 -3
- package/lib/lazy.ts +45 -0
- package/lib/plan-entry.ts +46 -21
- package/lib/plans/paths.ts +82 -0
- package/lib/policy.ts +9 -6
- package/lib/report.ts +3 -2
- package/lib/requirements.ts +10 -10
- package/lib/session-label.ts +129 -7
- package/lib/session-roster.ts +43 -1
- package/lib/session-tool.ts +39 -22
- package/lib/stray-fields.ts +7 -2
- package/lib/tool-names.ts +5 -10
- package/lib/tui/README.md +29 -11
- package/lib/tui/chrome.ts +16 -15
- package/lib/tui/cursor.ts +18 -3
- package/lib/tui/glyphs.ts +11 -11
- package/lib/tui/render-cache.ts +29 -0
- package/lib/tui/tokens.ts +33 -2
- package/lib/tui/tool.ts +33 -4
- package/lib/web/README.md +21 -0
- package/lib/web/client-runtime.ts +27 -1
- package/lib/web/components.ts +5 -1
- package/lib/web/index.ts +1 -0
- package/lib/web/markdown.ts +83 -0
- package/lib/web/markup.ts +20 -2
- package/lib/web/runtime.ts +9 -10
- package/package.json +8 -2
- package/skills/init/SKILL.md +5 -5
- package/skills/next/SKILL.md +3 -5
- package/extensions/board/config.ts +0 -36
- package/extensions/board/seat.ts +0 -28
- package/lib/tui/index.ts +0 -34
- package/skills/project-docs/SKILL.md +0 -133
- package/skills/project-docs/references/audit.md +0 -131
- package/skills/project-docs/references/changelog.md +0 -105
- package/skills/project-docs/references/discovery.md +0 -127
- package/skills/project-docs/references/readme.md +0 -80
- package/skills/project-docs/references/release.md +0 -84
- package/skills/project-docs/references/roadmap.md +0 -93
- package/skills/project-docs/scripts/state.ts +0 -316
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each operation does: the gates, the lane changes, and the sentence a refusal carries.
|
|
3
|
+
*
|
|
4
|
+
* Split from `rules.ts`, which owns the operation table, the dispatch, and the one write.
|
|
5
|
+
* This module answers "may this happen, and what would it change" and never touches the
|
|
6
|
+
* store: keeping the two apart is what lets the dispatch read as one switch and the rules
|
|
7
|
+
* read as one decision per operation.
|
|
8
|
+
*
|
|
9
|
+
* Two shapes of answer, and the difference matters. `{ error }` is a refusal, and the card
|
|
10
|
+
* is returned untouched. `{ changed: false }` is a no-op: the operation's preconditions
|
|
11
|
+
* already hold (claiming a card you already hold), so nothing is written and the revision
|
|
12
|
+
* does not move. `{ fields: null, changed: true }` is an operation that changes the board
|
|
13
|
+
* without changing the record — `archive` moves the file, and that is the store's write.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { listOf } from "../format.ts";
|
|
17
|
+
import {
|
|
18
|
+
BLOCKERS,
|
|
19
|
+
type Card,
|
|
20
|
+
type CardOwner,
|
|
21
|
+
cardRef,
|
|
22
|
+
dependsOf,
|
|
23
|
+
holds,
|
|
24
|
+
MAX_NOTES,
|
|
25
|
+
normalizeBlock,
|
|
26
|
+
normalizeNote,
|
|
27
|
+
normalizePathHints,
|
|
28
|
+
normalizePlanPath,
|
|
29
|
+
normalizePriority,
|
|
30
|
+
normalizeTitle,
|
|
31
|
+
priorityOf,
|
|
32
|
+
} from "./card.ts";
|
|
33
|
+
import { type ArtifactCheck, contractRoute } from "./artifact.ts";
|
|
34
|
+
import { CONTRACT_FIELDS, mergedContract, missingContractFields, normalizeContractField } from "./contract.ts";
|
|
35
|
+
import { type BoardConfig } from "./config.ts";
|
|
36
|
+
import { holderRefusal, missingSign, signRefusal } from "./lead.ts";
|
|
37
|
+
import { type WipLimits, wipRefusal } from "./limits.ts";
|
|
38
|
+
import { LANE_RULES, type Lane, type Requirement, targetsFrom, transitionOf } from "./lanes.ts";
|
|
39
|
+
import { type BoardIndex, dependedOn, normalizeDepends, waitingOn } from "./links.ts";
|
|
40
|
+
import type { Actor, CardOp, Prepared } from "./rules.ts";
|
|
41
|
+
|
|
42
|
+
/** Pull a backlog card into `plan`: the one door into the work. */
|
|
43
|
+
export function claim(card: Card, actor: Actor, board: BoardIndex, limits: WipLimits): Prepared {
|
|
44
|
+
if (LANE_RULES[card.lane].terminal) return { error: `${cardRef(card.id)} is done; it cannot be claimed.` };
|
|
45
|
+
if (holds(card, actor)) return { changed: false, fields: null };
|
|
46
|
+
if (card.owner !== undefined) return { error: heldBy(card) };
|
|
47
|
+
// The wait is asked here rather than read off the table: a claim enters `plan` from the pool,
|
|
48
|
+
// which is not a lane change `move` can make, so `backlog → plan`'s requirements would be
|
|
49
|
+
// read by nobody.
|
|
50
|
+
const waiting = waitingOn(card, board);
|
|
51
|
+
if (waiting !== undefined) return { error: waiting };
|
|
52
|
+
return enter(card, "plan", board, limits, { owner: ownerOf(actor), offeredAt: undefined });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Hand a pool card to somebody: the owner is recorded and the card enters `plan`.
|
|
57
|
+
*
|
|
58
|
+
* A held card is refused rather than handed over, because the owner a handoff would skip is
|
|
59
|
+
* the one the rules protect: changing hands is `reclaim` and a second assignment. Naming the
|
|
60
|
+
* session that already holds it is the one held case left, and it is a no-op.
|
|
61
|
+
*/
|
|
62
|
+
export function assign(card: Card, to: Actor, board: BoardIndex, limits: WipLimits): Prepared {
|
|
63
|
+
if (LANE_RULES[card.lane].terminal) return { error: `${cardRef(card.id)} is done; it cannot be assigned.` };
|
|
64
|
+
if (card.owner !== undefined) {
|
|
65
|
+
return holds(card, to) ? { changed: false, fields: null } : { error: heldBy(card) };
|
|
66
|
+
}
|
|
67
|
+
const waiting = waitingOn(card, board);
|
|
68
|
+
if (waiting !== undefined) return { error: waiting };
|
|
69
|
+
// A pool card is the only card this moves, so it comes with the lane a claim would give it:
|
|
70
|
+
// the owner inherits the work from its first step.
|
|
71
|
+
return enter(card, "plan", board, limits, { owner: ownerOf(to), offeredAt: undefined });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Hand a card back to the pool. The contract and its approval stay: the plan did not change. */
|
|
75
|
+
export function release(card: Card, actor: Actor): Prepared {
|
|
76
|
+
// Finished work is history: every other transition refuses it, and a release that did
|
|
77
|
+
// not would be the one way a `done` card could go backwards — reopening the order a
|
|
78
|
+
// dependency was recorded against.
|
|
79
|
+
if (LANE_RULES[card.lane].terminal) return { error: `${cardRef(card.id)} is done; it cannot be released.` };
|
|
80
|
+
if (card.owner === undefined) return { error: `${cardRef(card.id)} is not claimed.` };
|
|
81
|
+
if (!holds(card, actor)) return { error: `${heldBy(card)} Release is the owner's decision.` };
|
|
82
|
+
return { changed: true, fields: backToPool() };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Take a card back from whoever holds it: the lead's repair, so no holder check. */
|
|
86
|
+
export function reclaim(card: Card): Prepared {
|
|
87
|
+
if (LANE_RULES[card.lane].terminal) {
|
|
88
|
+
return { error: `${cardRef(card.id)} is done; there is nothing to reclaim.` };
|
|
89
|
+
}
|
|
90
|
+
if (card.owner === undefined) return { error: `${cardRef(card.id)} has no owner to reclaim.` };
|
|
91
|
+
return { changed: true, fields: backToPool() };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** What a card looks like back in the pool: nobody's, and no longer spot-checked. */
|
|
95
|
+
function backToPool(): Partial<Card> {
|
|
96
|
+
return {
|
|
97
|
+
lane: "backlog",
|
|
98
|
+
owner: undefined,
|
|
99
|
+
offeredAt: undefined,
|
|
100
|
+
// A review is a statement about work finished in `execute`; a card back in the pool
|
|
101
|
+
// is not finished, so the sign cannot travel with it.
|
|
102
|
+
review: undefined,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function move(
|
|
107
|
+
card: Card,
|
|
108
|
+
op: Extract<CardOp, { kind: "move" }>,
|
|
109
|
+
actor: Actor,
|
|
110
|
+
now: number,
|
|
111
|
+
board: BoardIndex,
|
|
112
|
+
config: BoardConfig,
|
|
113
|
+
): Prepared {
|
|
114
|
+
const target = op.lane;
|
|
115
|
+
if (LANE_RULES[card.lane].terminal) return { error: `${cardRef(card.id)} is done; it cannot be moved.` };
|
|
116
|
+
if (card.owner === undefined) {
|
|
117
|
+
return { error: `${cardRef(card.id)} is unclaimed; claim it before moving it to ${target}.` };
|
|
118
|
+
}
|
|
119
|
+
if (!holds(card, actor)) return { error: `${heldBy(card)} Only its owner can move it.` };
|
|
120
|
+
if (target === "backlog") {
|
|
121
|
+
return { error: `${cardRef(card.id)} cannot be moved to backlog; release it instead.` };
|
|
122
|
+
}
|
|
123
|
+
// A block's kind and sentence belong to a move into `blocked`, so a caller that sent them
|
|
124
|
+
// with any other target hears that instead of a success that recorded nothing.
|
|
125
|
+
if (target !== "blocked" && (op.blocker !== undefined || op.blockedBy !== undefined)) {
|
|
126
|
+
return { error: `a blocker is recorded by a move to blocked, not to ${target}.` };
|
|
127
|
+
}
|
|
128
|
+
// A block's reason and kind are checked before the no-op: a caller that repeats a move
|
|
129
|
+
// with a new sentence meant to change it, and a refusal is clearer than a silent nothing.
|
|
130
|
+
const block = target === "blocked" ? blockFields(op.blocker, op.blockedBy) : undefined;
|
|
131
|
+
if (block !== undefined && "error" in block) return { error: block.error };
|
|
132
|
+
// A reason is what the card was parked on, so it is fixed for as long as the card is: changing
|
|
133
|
+
// it means leaving the lane, and a caller asking to is told which way.
|
|
134
|
+
if (card.lane === "blocked" && target === "blocked") {
|
|
135
|
+
return {
|
|
136
|
+
error: `${cardRef(card.id)} is blocked; its reason is fixed while the card is parked — resume it, then park it again with the new sentence.`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (card.lane === target) return { changed: false, fields: null };
|
|
140
|
+
const transition = transitionOf(card.lane, target);
|
|
141
|
+
if (transition === undefined) return { error: noRoute(card, target) };
|
|
142
|
+
for (const need of transition.needs) {
|
|
143
|
+
const refusal = unmet(need, card, board, op.artifact, config);
|
|
144
|
+
if (refusal !== undefined) return { error: refusal };
|
|
145
|
+
}
|
|
146
|
+
const fields: Partial<Card> = { lane: target };
|
|
147
|
+
if (block !== undefined && "fields" in block) Object.assign(fields, block.fields);
|
|
148
|
+
// A spot-check signs the work as it stood, and `done` is the one lane that sign is evidence
|
|
149
|
+
// for. Parking a card is somebody deciding it is not finished, and re-planning changes the
|
|
150
|
+
// agreement, so those two exits drop it — and `review` refuses a finished card, so a move
|
|
151
|
+
// that dropped the sign here could never be signed again.
|
|
152
|
+
if (card.lane === "execute" && (target === "blocked" || target === "plan")) fields.review = undefined;
|
|
153
|
+
if (target === "done" && op.landed !== undefined) fields.landed = { commit: op.landed.commit, at: now };
|
|
154
|
+
if (target === "execute" || target === "plan") return enter(card, target, board, config.wip, fields);
|
|
155
|
+
return { changed: true, fields };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Enter a pulled lane, if it has room.
|
|
160
|
+
*
|
|
161
|
+
* One place for the two lanes a card is pulled into, so the limit is asked once and the
|
|
162
|
+
* refusal names the card and the count: `claim`, `assign`, and `move` all come through here.
|
|
163
|
+
*/
|
|
164
|
+
function enter(
|
|
165
|
+
card: Card,
|
|
166
|
+
lane: "plan" | "execute",
|
|
167
|
+
board: BoardIndex,
|
|
168
|
+
limits: WipLimits,
|
|
169
|
+
fields: Partial<Card>,
|
|
170
|
+
): { error: string } | { fields: Partial<Card>; changed: true } {
|
|
171
|
+
const full = wipRefusal(lane, [...board.values()], limits, card.lane);
|
|
172
|
+
if (full !== undefined) return { error: `${cardRef(card.id)} cannot enter ${lane}: ${full}` };
|
|
173
|
+
return { changed: true, fields: { ...fields, lane } };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** What a lane change to `blocked` records: one block, holding the kind and the sentence. */
|
|
177
|
+
function blockFields(rawBlocker: unknown, rawSentence: unknown): { error: string } | { fields: Partial<Card> } {
|
|
178
|
+
if (rawBlocker === undefined) {
|
|
179
|
+
return { error: `a block needs a blocker: ${BLOCKERS.join(", ")}.` };
|
|
180
|
+
}
|
|
181
|
+
const block = normalizeBlock(rawBlocker, rawSentence);
|
|
182
|
+
if ("error" in block) return { error: block.error };
|
|
183
|
+
return { fields: { blocked: block.blocked } };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Why a transition's requirement is not met, or `undefined` when it is. */
|
|
187
|
+
function unmet(
|
|
188
|
+
need: Requirement,
|
|
189
|
+
card: Card,
|
|
190
|
+
board: BoardIndex,
|
|
191
|
+
artifact: ArtifactCheck | undefined,
|
|
192
|
+
config: BoardConfig,
|
|
193
|
+
): string | undefined {
|
|
194
|
+
switch (need) {
|
|
195
|
+
case "waiting":
|
|
196
|
+
return waitingOn(card, board);
|
|
197
|
+
case "contract": {
|
|
198
|
+
const missing = missingContractFields(card.contract);
|
|
199
|
+
if (missing.length === 0) return undefined;
|
|
200
|
+
return `${cardRef(card.id)} is in ${card.lane}; its contract needs ${listOf(CONTRACT_FIELDS, "and")} — missing: ${missing.join(", ")}.`;
|
|
201
|
+
}
|
|
202
|
+
case "approval":
|
|
203
|
+
return card.contract?.approved === undefined ? missingSign(card, config.lead, "approve") : undefined;
|
|
204
|
+
case "review":
|
|
205
|
+
return card.review === undefined ? missingSign(card, config.lead, "review") : undefined;
|
|
206
|
+
case "artifact": {
|
|
207
|
+
if (card.planPath === undefined) return noArtifact(card);
|
|
208
|
+
// Fail closed: a lane that requires a proved plan refuses when the caller did not
|
|
209
|
+
// hand one in, so a surface added later cannot pass the gate by forgetting it. A
|
|
210
|
+
// check that failed speaks with its own sentence, built where the file was read.
|
|
211
|
+
if (artifact === undefined) return unprovedPlan(card);
|
|
212
|
+
if ("error" in artifact) return artifact.error;
|
|
213
|
+
// The proof is bound to the record it was computed for: a fact about one path cannot
|
|
214
|
+
// open a card whose path is another, and a plan re-recorded between the read and the
|
|
215
|
+
// write is a card to read again rather than one to force. A fact that names no
|
|
216
|
+
// record is bound by its own path, which is what a caller holding one file means.
|
|
217
|
+
return (artifact.recorded ?? artifact.path) === card.planPath ? undefined : stalePlan(card);
|
|
218
|
+
}
|
|
219
|
+
case "blocker":
|
|
220
|
+
// Asked and answered by `blockFields`, which needs the op this gate cannot see: the
|
|
221
|
+
// requirement is named in the table so a reader sees the whole of what `blocked` wants.
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The refusal for a lane a card may not move to from where it is. */
|
|
227
|
+
function noRoute(card: Card, target: Lane): string {
|
|
228
|
+
const targets = targetsFrom(card.lane);
|
|
229
|
+
const where = card.lane === "done" ? "it is finished" : `it can move to ${listOf(targets)}`;
|
|
230
|
+
return `${cardRef(card.id)} is in ${card.lane}; ${where}, not ${target}.`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function noArtifact(card: Card): string {
|
|
234
|
+
return `${cardRef(card.id)} has no plan recorded; ${contractRoute(card)}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The refusal for a caller that recorded a plan but did not prove it. */
|
|
238
|
+
function unprovedPlan(card: Card): string {
|
|
239
|
+
return `${cardRef(card.id)}'s plan was not checked; ${contractRoute(card)}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The refusal for a proof that belongs to a different recorded path. */
|
|
243
|
+
function stalePlan(card: Card): string {
|
|
244
|
+
return `${cardRef(card.id)}'s plan changed while it was being read; ${contractRoute(card)}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Add a note to the card's history: the holder's, or anyone's while nobody holds it.
|
|
249
|
+
*
|
|
250
|
+
* A note is content, not a sign, so it follows `edit` and not `approve` — and freeing the gate
|
|
251
|
+
* is also what keeps the rollup's idle clock honest: `updatedAt` moves on every write, so a
|
|
252
|
+
* note from a session that does not hold the card would hide an idle card from the lead.
|
|
253
|
+
*/
|
|
254
|
+
export function note(card: Card, raw: unknown, actor: Actor, now: number): Prepared {
|
|
255
|
+
if (card.owner !== undefined && !holds(card, actor)) {
|
|
256
|
+
return { error: `${heldBy(card)} Only its owner can add a note.` };
|
|
257
|
+
}
|
|
258
|
+
const parsed = normalizeNote(raw);
|
|
259
|
+
if ("error" in parsed) return { error: parsed.error };
|
|
260
|
+
return { changed: true, fields: appendNote(card, actor, now, parsed.text) };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** A card's notes after one more, with the oldest dropped and counted. */
|
|
264
|
+
function appendNote(card: Card, actor: Actor, now: number, text: string): Pick<Card, "notes" | "notesDropped"> {
|
|
265
|
+
const notes = [...card.notes, { by: actor.label, at: now, text }];
|
|
266
|
+
const overflow = Math.max(0, notes.length - MAX_NOTES);
|
|
267
|
+
return {
|
|
268
|
+
notes: overflow === 0 ? notes : notes.slice(overflow),
|
|
269
|
+
notesDropped: card.notesDropped + overflow,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Write the contract, and the plan file it is executed against.
|
|
275
|
+
*
|
|
276
|
+
* The plan lane's one act: a card is planned by saying what it promises, and the wording and
|
|
277
|
+
* the file it names are what the approval covers, so a change to either drops the signature.
|
|
278
|
+
* The plan is required —
|
|
279
|
+
* a contract is a promise plus the agreement it will be read against, and a card whose
|
|
280
|
+
* contract names no artifact has nothing to execute. A contract call that names nothing at
|
|
281
|
+
* all is refused rather than quietly changing nothing.
|
|
282
|
+
*/
|
|
283
|
+
export function contractOp(card: Card, op: Extract<CardOp, { kind: "contract" }>, actor: Actor): Prepared {
|
|
284
|
+
if (LANE_RULES[card.lane].terminal) return { error: `${cardRef(card.id)} is done; its plan cannot change.` };
|
|
285
|
+
if (card.lane !== "plan") {
|
|
286
|
+
return { error: `${cardRef(card.id)} is in ${card.lane}; its contract is written in plan.` };
|
|
287
|
+
}
|
|
288
|
+
if (!holds(card, actor)) return { error: `${heldBy(card)} Only its owner records a plan.` };
|
|
289
|
+
if (op.artifact === undefined) {
|
|
290
|
+
return {
|
|
291
|
+
error: `contract needs the plan file ${cardRef(card.id)} is executed against: pass \`path\` as \`plan\` action \`write\` returned it.`,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
if ("error" in op.artifact) return { error: op.artifact.error };
|
|
295
|
+
const fields: Partial<Card> = {};
|
|
296
|
+
const parsedPlan = normalizePlanPath(op.artifact.path);
|
|
297
|
+
if ("error" in parsedPlan) return { error: parsedPlan.error };
|
|
298
|
+
const rePointed = parsedPlan.path !== card.planPath;
|
|
299
|
+
if (rePointed) fields.planPath = parsedPlan.path;
|
|
300
|
+
const changed: Partial<Record<(typeof CONTRACT_FIELDS)[number], string>> = {};
|
|
301
|
+
for (const field of CONTRACT_FIELDS) {
|
|
302
|
+
const raw = op[field];
|
|
303
|
+
if (raw === undefined) continue;
|
|
304
|
+
const parsed = normalizeContractField(field, raw);
|
|
305
|
+
if ("error" in parsed) return { error: parsed.error };
|
|
306
|
+
if (parsed.text !== card.contract?.[field]) changed[field] = parsed.text;
|
|
307
|
+
}
|
|
308
|
+
if (Object.keys(changed).length > 0) fields.contract = mergedContract(card.contract, changed);
|
|
309
|
+
// The sign names a promise *and* the file it will be read against, so pointing the card at
|
|
310
|
+
// a different plan is a change to what was approved even when the three lines are the same:
|
|
311
|
+
// keeping it would let a session swap the artifact after a peer read the old one.
|
|
312
|
+
else if (rePointed && card.contract?.approved !== undefined) fields.contract = mergedContract(card.contract, {});
|
|
313
|
+
if (Object.keys(fields).length === 0) return { changed: false, fields: null };
|
|
314
|
+
return { changed: true, fields };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Approve the contract: the lead, who does not hold the card, says the wording is right.
|
|
319
|
+
*
|
|
320
|
+
* Two refusals, and the order between them is the point: a holder that is also the lead is told
|
|
321
|
+
* to release the card first, because a session signing its own wording is the gate approving
|
|
322
|
+
* itself, and a session that is neither the lead nor the person is told whose signature the gate
|
|
323
|
+
* takes. The person's `/board approve` is the other way in.
|
|
324
|
+
*
|
|
325
|
+
* `text` is the note the signer leaves with the sign, in the same write: a signer is exactly
|
|
326
|
+
* the caller `note` refuses on a held card, so this is where a caveat has somewhere to go.
|
|
327
|
+
*/
|
|
328
|
+
export function approve(card: Card, actor: Actor, now: number, config: BoardConfig, text?: unknown): Prepared {
|
|
329
|
+
if (LANE_RULES[card.lane].terminal) return { error: `${cardRef(card.id)} is done; its contract is closed.` };
|
|
330
|
+
if (card.lane !== "plan") return { error: `${cardRef(card.id)} is in ${card.lane}; a contract is approved in plan.` };
|
|
331
|
+
if (holds(card, actor)) return { error: `${heldBy(card)} ${holderRefusal(actor, config.lead, "approve")}` };
|
|
332
|
+
const wrongSigner = signRefusal(card, actor, config.lead, "approve");
|
|
333
|
+
if (wrongSigner !== undefined) return { error: wrongSigner };
|
|
334
|
+
const contract = card.contract;
|
|
335
|
+
const missing = missingContractFields(contract);
|
|
336
|
+
// One refusal for both shapes: a card with no contract names every field, and one with a
|
|
337
|
+
// line still blank names the blank one. A complete contract is never undefined, so the
|
|
338
|
+
// check above is also what narrows it.
|
|
339
|
+
if (contract === undefined || missing.length > 0) {
|
|
340
|
+
const gaps = missing.length === 0 ? "" : `: ${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} missing`;
|
|
341
|
+
return { error: `${cardRef(card.id)} has no contract to approve yet${gaps}.` };
|
|
342
|
+
}
|
|
343
|
+
const noted = signNote(card, text, actor, now);
|
|
344
|
+
if ("error" in noted) return { error: noted.error };
|
|
345
|
+
// A second sign from the same session says nothing new, so the contract keeps its stamp;
|
|
346
|
+
// the note still lands, because a caller who wrote one recorded a fact about the card.
|
|
347
|
+
const signed = contract.approved?.by === actor.label;
|
|
348
|
+
if (signed && !noted.wrote) return { changed: false, fields: null };
|
|
349
|
+
const approval = signed ? {} : { contract: { ...contract, approved: { by: actor.label, at: now } } };
|
|
350
|
+
return { changed: true, fields: { ...approval, ...noted.fields } };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Record the spot-check of finished work: the lead's, and never the holder's.
|
|
355
|
+
*
|
|
356
|
+
* `text` rides the sign here too, so "checked, and this is still wrong" is one call.
|
|
357
|
+
*/
|
|
358
|
+
export function review(card: Card, actor: Actor, now: number, config: BoardConfig, text?: unknown): Prepared {
|
|
359
|
+
if (LANE_RULES[card.lane].terminal) return { error: `${cardRef(card.id)} is done; it cannot be reviewed.` };
|
|
360
|
+
if (card.lane !== "execute") {
|
|
361
|
+
return { error: `${cardRef(card.id)} is in ${card.lane}; finished work is spot-checked in execute.` };
|
|
362
|
+
}
|
|
363
|
+
if (holds(card, actor)) return { error: `${heldBy(card)} ${holderRefusal(actor, config.lead, "review")}` };
|
|
364
|
+
const wrongSigner = signRefusal(card, actor, config.lead, "review");
|
|
365
|
+
if (wrongSigner !== undefined) return { error: wrongSigner };
|
|
366
|
+
const noted = signNote(card, text, actor, now);
|
|
367
|
+
if ("error" in noted) return { error: noted.error };
|
|
368
|
+
const signed = card.review?.by === actor.label;
|
|
369
|
+
if (signed && !noted.wrote) return { changed: false, fields: null };
|
|
370
|
+
const spotCheck = signed ? {} : { review: { by: actor.label, at: now } };
|
|
371
|
+
return { changed: true, fields: { ...spotCheck, ...noted.fields } };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The note a signer leaves with the sign, in the same write.
|
|
376
|
+
*
|
|
377
|
+
* Validated after the gate, as `note` validates after the holder check: a card that cannot be
|
|
378
|
+
* signed at all is the answer worth reading first.
|
|
379
|
+
*/
|
|
380
|
+
function signNote(
|
|
381
|
+
card: Card,
|
|
382
|
+
raw: unknown,
|
|
383
|
+
actor: Actor,
|
|
384
|
+
now: number,
|
|
385
|
+
): { fields: Partial<Card>; wrote: boolean } | { error: string } {
|
|
386
|
+
if (raw === undefined) return { fields: {}, wrote: false };
|
|
387
|
+
const parsed = normalizeNote(raw);
|
|
388
|
+
if ("error" in parsed) return { error: parsed.error };
|
|
389
|
+
return { fields: appendNote(card, actor, now, parsed.text), wrote: true };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Fix what a card says: its title, its hints, its priority, and what it waits for.
|
|
394
|
+
*
|
|
395
|
+
* The holder decides for a held card and anyone decides for an unclaimed one — and
|
|
396
|
+
* that includes a `done` card, because a title is what a reader reads, not a step of
|
|
397
|
+
* the work. Every field is optional and an absent one is left alone; a field that already
|
|
398
|
+
* holds the value asked for is not a write, so re-editing the same card is a no-op.
|
|
399
|
+
*/
|
|
400
|
+
export function edit(card: Card, op: Extract<CardOp, { kind: "edit" }>, actor: Actor, board: BoardIndex): Prepared {
|
|
401
|
+
if (card.owner !== undefined && !holds(card, actor)) {
|
|
402
|
+
return { error: `${heldBy(card)} Only its owner can edit it.` };
|
|
403
|
+
}
|
|
404
|
+
const fields: Partial<Card> = {};
|
|
405
|
+
if (op.title !== undefined) {
|
|
406
|
+
const parsed = normalizeTitle(op.title);
|
|
407
|
+
if ("error" in parsed) return { error: parsed.error };
|
|
408
|
+
if (parsed.title !== card.title) fields.title = parsed.title;
|
|
409
|
+
}
|
|
410
|
+
if (op.paths !== undefined) {
|
|
411
|
+
const parsed = normalizePathHints(op.paths);
|
|
412
|
+
if ("error" in parsed) return { error: parsed.error };
|
|
413
|
+
if (!sameList(parsed.paths, card.paths)) fields.paths = parsed.paths;
|
|
414
|
+
}
|
|
415
|
+
if (op.priority !== undefined) {
|
|
416
|
+
const parsed = normalizePriority(op.priority);
|
|
417
|
+
if ("error" in parsed) return { error: parsed.error };
|
|
418
|
+
if (parsed.priority !== priorityOf(card)) fields.priority = parsed.priority;
|
|
419
|
+
}
|
|
420
|
+
if (op.dependsOn !== undefined) {
|
|
421
|
+
const parsed = normalizeDepends(op.dependsOn, card, board);
|
|
422
|
+
if ("error" in parsed) return { error: parsed.error };
|
|
423
|
+
if (!sameList(parsed.dependsOn, dependsOf(card))) fields.dependsOn = parsed.dependsOn;
|
|
424
|
+
}
|
|
425
|
+
if (Object.keys(fields).length === 0) return { changed: false, fields: null };
|
|
426
|
+
return { changed: true, fields };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Record that the card was offered to the team, and nobody has taken it.
|
|
431
|
+
*
|
|
432
|
+
* The same preconditions a claim would meet, because the offer asks a peer to claim — and
|
|
433
|
+
* they are this operation's own, not a claim run as a stand-in: a held card, a finished
|
|
434
|
+
* one, one waiting on unfinished work, and a `plan` lane already at its limit are all
|
|
435
|
+
* refused here, before anything is broadcast. The limit is the one a claim would meet when
|
|
436
|
+
* the peer answers, so an offer can never send a handoff for work nobody can pick up.
|
|
437
|
+
* Re-offering refreshes the age rather than refusing as a no-op.
|
|
438
|
+
*/
|
|
439
|
+
export function offer(card: Card, now: number, board: BoardIndex, limits: WipLimits): Prepared {
|
|
440
|
+
if (LANE_RULES[card.lane].terminal) return { error: `${cardRef(card.id)} is done; it cannot be offered.` };
|
|
441
|
+
if (card.owner !== undefined) return { error: heldBy(card) };
|
|
442
|
+
const waiting = waitingOn(card, board);
|
|
443
|
+
if (waiting !== undefined) return { error: waiting };
|
|
444
|
+
const full = wipRefusal("plan", [...board.values()], limits);
|
|
445
|
+
if (full !== undefined) return { error: `${cardRef(card.id)} cannot be offered: ${full}` };
|
|
446
|
+
return { changed: true, fields: { offeredAt: now } };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Set a finished card aside, out of the board and into the archive beside it.
|
|
451
|
+
*
|
|
452
|
+
* Only a finished card leaves this way. No field moves and no transition is appended — the
|
|
453
|
+
* record is unchanged, and what changed is which pile its file lies in, which is the
|
|
454
|
+
* store's write. `changed` is still true, because the board lost a card.
|
|
455
|
+
*/
|
|
456
|
+
export function archive(card: Card): Prepared {
|
|
457
|
+
if (!LANE_RULES[card.lane].terminal) {
|
|
458
|
+
return { error: `${cardRef(card.id)} is in ${card.lane}; only a finished card can be archived.` };
|
|
459
|
+
}
|
|
460
|
+
return { changed: true, fields: null };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export function drop(card: Card, actor: Actor, board: BoardIndex): Prepared {
|
|
464
|
+
// A drop takes its id with it, so a card another one waits on cannot leave: the
|
|
465
|
+
// remaining link would name nothing, and nothing on the board would say so.
|
|
466
|
+
const depended = dependedOn(card.id, board);
|
|
467
|
+
if (depended !== undefined) return { error: depended };
|
|
468
|
+
const rule = LANE_RULES[card.lane];
|
|
469
|
+
if (rule.ownerless || rule.terminal) return { changed: true, fields: null };
|
|
470
|
+
if (!holds(card, actor)) return { error: `${heldBy(card)} It cannot be dropped while someone holds it.` };
|
|
471
|
+
return { changed: true, fields: null };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Whether two string lists are the same list, in the same order. */
|
|
475
|
+
function sameList(left: readonly string[], right: readonly string[]): boolean {
|
|
476
|
+
return left.length === right.length && left.every((entry, index) => entry === right[index]);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** The owner recorded for an actor: the id only when this actor has one. */
|
|
480
|
+
function ownerOf(actor: Actor): CardOwner {
|
|
481
|
+
return actor.id === undefined ? { label: actor.label } : { id: actor.id, label: actor.label };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** The refusal for an operation on a card someone else holds. */
|
|
485
|
+
function heldBy(card: Card): string {
|
|
486
|
+
return `${cardRef(card.id)} is held by ${card.owner?.label ?? "another session"}.`;
|
|
487
|
+
}
|