@gavin-hu/my-pi-agent 0.17.0 → 0.19.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 +61 -45
- package/README.zh-CN.md +54 -42
- package/docs/extensions/README.md +34 -0
- package/docs/extensions/board.md +344 -0
- package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +465 -416
- package/{extensions/check/README.md → docs/extensions/check.md} +83 -68
- package/{extensions/context/README.md → docs/extensions/context.md} +40 -23
- package/docs/extensions/file-browse.md +554 -0
- package/{extensions/file-open/README.md → docs/extensions/file-open.md} +31 -23
- package/{extensions/file-read/README.md → docs/extensions/file-read.md} +47 -55
- package/{extensions/goal/README.md → docs/extensions/goal.md} +115 -131
- package/{extensions/job/README.md → docs/extensions/job.md} +66 -53
- package/{extensions/memory/README.md → docs/extensions/memory.md} +74 -63
- package/docs/extensions/plan.md +976 -0
- package/{extensions/question/README.md → docs/extensions/question.md} +54 -28
- package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
- package/docs/extensions/session-bus.md +285 -0
- package/{extensions/subagent/README.md → docs/extensions/subagent.md} +166 -119
- package/docs/extensions/team.md +226 -0
- package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
- package/{extensions/watch/README.md → docs/extensions/watch.md} +60 -38
- package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
- package/{extensions/wechat/README.md → docs/extensions/wechat.md} +275 -192
- package/{extensions/worktree/README.md → docs/extensions/worktree.md} +115 -72
- package/extensions/board/commands.ts +87 -0
- package/extensions/board/config.ts +36 -0
- package/extensions/board/format.ts +72 -0
- package/extensions/board/index.ts +40 -0
- package/extensions/board/input.ts +54 -0
- package/extensions/board/join.ts +42 -0
- package/extensions/board/page.ts +312 -0
- package/extensions/board/result.ts +99 -0
- package/extensions/board/rows.ts +48 -0
- package/extensions/board/schema.ts +75 -0
- package/extensions/board/seat.ts +29 -0
- package/extensions/board/snapshot.ts +41 -0
- package/extensions/board/tools.ts +279 -0
- package/extensions/board/transport/router.ts +172 -0
- package/extensions/board/transport/server.ts +149 -0
- package/extensions/board/types.ts +54 -0
- package/extensions/board/view/actions.ts +306 -0
- package/extensions/board/view/client.ts +41 -0
- package/extensions/board/view/keys.ts +121 -0
- package/extensions/board/view/live.ts +397 -0
- package/extensions/board/view/page.ts +158 -0
- package/extensions/board/view/render.ts +496 -0
- package/extensions/board/view/styles.ts +211 -0
- package/extensions/board/view/team.ts +305 -0
- package/extensions/board/writes.ts +99 -0
- package/extensions/bytefly/notify/types.ts +1 -1
- package/extensions/bytefly/panel/commands.ts +40 -7
- package/extensions/bytefly/panel/settings-runtime.ts +0 -6
- package/extensions/bytefly/panel/settings.ts +11 -21
- package/extensions/bytefly/panel/tui/settings-view.ts +1 -1
- package/extensions/bytefly/picker/discovery.ts +0 -2
- package/extensions/bytefly/picker/types.ts +0 -2
- package/extensions/bytefly/update/check.ts +39 -0
- package/extensions/bytefly/update/format.ts +15 -0
- package/extensions/bytefly/update/register.ts +73 -34
- package/extensions/bytefly/update/state.ts +6 -3
- package/extensions/context/tui.ts +1 -1
- package/extensions/file-browse/{config.ts → config/config.ts} +13 -4
- package/extensions/file-browse/{files.ts → files/files.ts} +1 -14
- package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
- package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
- package/extensions/file-browse/index.ts +4 -4
- package/extensions/file-browse/{live.ts → live/live.ts} +54 -82
- package/extensions/file-browse/live/rev.ts +42 -0
- package/extensions/file-browse/{router.ts → transport/router.ts} +44 -17
- package/extensions/file-browse/transport/server.ts +110 -0
- package/extensions/file-browse/view/chrome.ts +83 -0
- package/extensions/file-browse/{client.ts → view/client.ts} +63 -1
- package/extensions/file-browse/view/diff-html.ts +94 -0
- package/extensions/file-browse/{html.ts → view/html.ts} +32 -158
- package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
- package/extensions/file-browse/{live-client.ts → view/live-client.ts} +47 -1
- package/extensions/file-browse/{markdown.ts → view/markdown.ts} +7 -7
- package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +1 -1
- package/extensions/file-browse/{styles.ts → view/styles.ts} +31 -5
- package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
- package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
- package/extensions/file-read/schema.ts +2 -1
- package/extensions/goal/plan-route.ts +8 -2
- package/extensions/job/index.ts +15 -7
- package/extensions/memory/store.ts +12 -5
- package/extensions/plan/blocks.ts +23 -2
- package/extensions/plan/commands.ts +77 -21
- package/extensions/plan/feedback.ts +409 -63
- package/extensions/plan/index.ts +7 -5
- package/extensions/plan/plan-index.ts +44 -10
- package/extensions/plan/plan-text.ts +51 -24
- package/extensions/plan/plans.ts +162 -57
- package/extensions/plan/policy.ts +20 -12
- package/extensions/plan/review-prompt.ts +93 -0
- package/extensions/plan/review.ts +51 -95
- package/extensions/plan/runtime.ts +22 -10
- package/extensions/plan/sections.ts +112 -0
- package/extensions/plan/steps.ts +63 -95
- package/extensions/plan/tools/enter.ts +8 -3
- package/extensions/plan/tools/exit.ts +13 -19
- package/extensions/plan/tools/write.ts +8 -3
- package/extensions/plan/tools.ts +1 -1
- package/extensions/plan/tui.ts +29 -15
- package/extensions/plan/types.ts +29 -3
- package/extensions/plan/web-chrome.ts +2 -1
- package/extensions/plan/web-client.ts +122 -178
- package/extensions/plan/web-collab.ts +207 -0
- package/extensions/plan/web-comments.ts +269 -0
- package/extensions/plan/web-page.ts +96 -17
- package/extensions/plan/web-router.ts +109 -47
- package/extensions/plan/web-share-session.ts +191 -0
- package/extensions/plan/web-share.ts +169 -0
- package/extensions/plan/web-streams.ts +124 -0
- package/extensions/plan/web-tokens.ts +63 -0
- package/extensions/plan/web.ts +245 -151
- package/extensions/question/dialogs.ts +91 -1
- package/extensions/question/tools.ts +2 -1
- package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
- package/extensions/rewind/{review.ts → commands/review.ts} +13 -13
- package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
- package/extensions/rewind/{config.ts → config/config.ts} +1 -1
- package/extensions/rewind/index.ts +3 -3
- package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
- package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
- package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
- package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
- package/extensions/rewind/{format.ts → text/format.ts} +3 -3
- package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
- package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
- package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
- package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
- package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
- package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
- package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
- package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
- package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
- package/extensions/rewind/{status.ts → view/status.ts} +4 -4
- package/extensions/session-bus/commands.ts +113 -0
- package/extensions/session-bus/format.ts +101 -0
- package/extensions/session-bus/index.ts +75 -0
- package/extensions/session-bus/join-request.ts +40 -0
- package/extensions/session-bus/mail-watch.ts +134 -0
- package/extensions/session-bus/notice.ts +86 -0
- package/extensions/session-bus/overlap.ts +36 -0
- package/extensions/session-bus/protocol.ts +104 -0
- package/extensions/session-bus/resolve.ts +67 -0
- package/extensions/session-bus/result.ts +208 -0
- package/extensions/session-bus/rows.ts +171 -0
- package/extensions/session-bus/runtime.ts +466 -0
- package/extensions/session-bus/store.ts +365 -0
- package/extensions/session-bus/tools.ts +192 -0
- package/extensions/session-bus/tui.ts +394 -0
- package/extensions/session-bus/types.ts +98 -0
- package/extensions/subagent/agents.ts +9 -8
- package/extensions/subagent/invocation.ts +4 -2
- package/extensions/team/commands.ts +112 -0
- package/extensions/team/dispatch.ts +133 -0
- package/extensions/team/format.ts +105 -0
- package/extensions/team/index.ts +33 -0
- package/extensions/team/schema.ts +29 -0
- package/extensions/team/tools.ts +246 -0
- package/extensions/team/types.ts +48 -0
- package/extensions/watch/index.ts +15 -9
- package/extensions/web-access/fetch/page.ts +4 -1
- package/extensions/web-access/fetch/structured.ts +447 -0
- package/extensions/web-access/fetch/tool.ts +2 -1
- package/extensions/web-access/search/tool.ts +2 -1
- package/extensions/wechat/bridge.ts +76 -92
- package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
- package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
- package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
- package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
- package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
- package/extensions/wechat/dialog/channels.ts +136 -0
- package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +66 -51
- package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +40 -25
- package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
- package/extensions/wechat/index.ts +10 -10
- package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
- package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
- package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +119 -56
- package/extensions/wechat/{presence.ts → peer/presence.ts} +11 -11
- package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +13 -5
- package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
- package/extensions/wechat/{state.ts → state/state.ts} +14 -2
- package/extensions/wechat/{format.ts → text/format.ts} +3 -3
- package/extensions/wechat/{notices.ts → text/notices.ts} +15 -7
- package/extensions/wechat/tools.ts +29 -12
- package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
- package/extensions/wechat/{deliver.ts → transport/deliver.ts} +104 -40
- package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
- package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
- package/extensions/wechat/transport/outbound.ts +142 -0
- package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
- package/extensions/wechat/types.ts +10 -1
- package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
- package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
- package/extensions/worktree/git.ts +5 -0
- package/extensions/worktree/guard.ts +3 -5
- package/extensions/worktree/registry.ts +11 -5
- package/extensions/worktree/status.ts +1 -3
- package/lib/README.md +12 -6
- package/lib/board/card.ts +290 -0
- package/lib/board/links.ts +117 -0
- package/lib/board/page-pointer.ts +256 -0
- package/lib/board/round-text.ts +72 -0
- package/lib/board/round.ts +89 -0
- package/lib/board/rules.ts +304 -0
- package/lib/board/session.ts +105 -0
- package/lib/board/store.ts +310 -0
- package/lib/board/view.ts +150 -0
- package/lib/errors.ts +12 -4
- package/lib/format.ts +13 -0
- package/lib/http/guard.ts +112 -0
- package/lib/http/index.ts +16 -5
- package/lib/http/router.ts +40 -13
- package/lib/http/serve.ts +234 -0
- package/lib/http/server.ts +29 -13
- package/lib/http/sink.ts +110 -0
- package/lib/http/token.ts +38 -0
- package/lib/interaction.ts +10 -0
- package/lib/notice.ts +29 -2
- package/lib/plan-entry.ts +7 -2
- package/lib/policy.ts +12 -2
- package/lib/qr.ts +64 -0
- package/lib/repo-key.ts +74 -0
- package/lib/report.ts +52 -33
- package/lib/requirements.ts +7 -9
- package/lib/session-label.ts +64 -0
- package/lib/session-roster.ts +130 -0
- package/lib/session-tool.ts +184 -0
- package/lib/tool-names.ts +25 -2
- package/lib/tui/README.md +16 -2
- package/lib/tui/dock-screen.ts +85 -17
- package/lib/tui/glyphs.ts +2 -0
- package/lib/tui/line-pane.ts +15 -4
- package/lib/tui/tool.ts +23 -4
- package/lib/web/README.md +5 -2
- package/lib/web/components.ts +6 -1
- package/lib/web/runtime.ts +7 -1
- package/lib/web/tokens.ts +25 -2
- package/package.json +27 -14
- package/skills/init/SKILL.md +80 -45
- package/skills/init/references/agents-md-quality.md +26 -2
- package/skills/init/references/interview-questions.md +36 -14
- package/skills/next/SKILL.md +77 -51
- package/skills/project-docs/SKILL.md +133 -0
- package/skills/project-docs/references/audit.md +131 -0
- package/skills/project-docs/references/changelog.md +105 -0
- package/skills/project-docs/references/discovery.md +127 -0
- package/skills/project-docs/references/readme.md +80 -0
- package/skills/project-docs/references/release.md +84 -0
- package/skills/project-docs/references/roadmap.md +93 -0
- package/skills/project-docs/scripts/state.ts +316 -0
- package/skills/refactor/SKILL.md +100 -125
- package/skills/refactor/references/handoff.md +35 -0
- package/skills/refactor/references/signals.md +54 -0
- package/skills/refactor/scripts/inventory.ts +587 -0
- package/extensions/file-browse/README.md +0 -467
- package/extensions/file-browse/server.ts +0 -134
- package/extensions/plan/README.md +0 -709
- package/extensions/plan/config.ts +0 -27
- package/extensions/wechat/outbound.ts +0 -100
- package/extensions/wechat/qrcode.ts +0 -46
- package/skills/changelog/SKILL.md +0 -148
- /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
- /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
- /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
- /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
- /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
- /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
- /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
- /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
- /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/team` — the lead's round, printed.
|
|
3
|
+
*
|
|
4
|
+
* The Team tab is the same round with a mouse; a lead reading a TUI should not have to
|
|
5
|
+
* open a browser to see what needs a decision, so the command prints the rollup the
|
|
6
|
+
* tool returns and one line saying who is live. It reads the cards and the bus's records
|
|
7
|
+
* and writes nothing: acting on the round is `team`'s tool (`assign`, `reclaim`) or
|
|
8
|
+
* `board`'s.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { Card } from "../../lib/board/card.ts";
|
|
13
|
+
import { buildRound } from "../../lib/board/round.ts";
|
|
14
|
+
import { formatRound, plural } from "../../lib/board/round-text.ts";
|
|
15
|
+
import type { BoardSession } from "../../lib/board/session.ts";
|
|
16
|
+
import { createTeamJoin, type JoinResult, type TeamJoin } from "../../lib/session-tool.ts";
|
|
17
|
+
import { readRoster, type RosterSession, rosterStateOf } from "../../lib/session-roster.ts";
|
|
18
|
+
import { formatJoined, formatNotJoined } from "./format.ts";
|
|
19
|
+
|
|
20
|
+
/** The command's name, which is also the word a user types. */
|
|
21
|
+
export const TEAM_COMMAND = "team";
|
|
22
|
+
|
|
23
|
+
/** The words the command accepts besides the round itself. */
|
|
24
|
+
export const TEAM_WORDS = ["join"] as const;
|
|
25
|
+
|
|
26
|
+
/** How many lines a round may print before it says how many it left out. */
|
|
27
|
+
export const MAX_ROUND_LINES = 10;
|
|
28
|
+
|
|
29
|
+
export function registerCommands(pi: ExtensionAPI, session: BoardSession, deps: { team?: TeamJoin } = {}): void {
|
|
30
|
+
const team = deps.team ?? createTeamJoin(pi);
|
|
31
|
+
pi.registerCommand(TEAM_COMMAND, {
|
|
32
|
+
description: "The lead's round: who holds what, and what needs a decision; `/team join [<label>]` joins the team",
|
|
33
|
+
getArgumentCompletions: (prefix: string) => {
|
|
34
|
+
const text = prefix.trimStart();
|
|
35
|
+
if (text.includes(" ")) return null;
|
|
36
|
+
const items = TEAM_WORDS.filter((word) => word.startsWith(text));
|
|
37
|
+
return items.length > 0 ? items.map((value) => ({ value, label: value })) : null;
|
|
38
|
+
},
|
|
39
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
40
|
+
const [word = "", ...rest] = args.trim().split(/\s+/).filter(Boolean);
|
|
41
|
+
if (word === "join") {
|
|
42
|
+
const label = rest.join(" ").trim();
|
|
43
|
+
const joined = await team.join(ctx, label === "" ? undefined : label);
|
|
44
|
+
ctx.ui.notify(joinedLine(joined), joined.ok ? "info" : "warning");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (word !== "") {
|
|
48
|
+
ctx.ui.notify(
|
|
49
|
+
`team: "${word}" is not a word this command knows — try /team, or /team join [<label>]`,
|
|
50
|
+
"warning",
|
|
51
|
+
);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const store = await session.store(ctx.cwd);
|
|
55
|
+
const cards = await store.list();
|
|
56
|
+
// The roster is read as data, so a repository with no bus prints the round and
|
|
57
|
+
// says nothing about who is here rather than failing.
|
|
58
|
+
const roster = await readRoster(await session.busDir(ctx.cwd));
|
|
59
|
+
ctx.ui.notify(roundText(cards, session.now(), roster), "info");
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `/team join`'s line: who this session is on the team, or why it is on none. */
|
|
65
|
+
export function joinedLine(result: JoinResult): string {
|
|
66
|
+
return result.ok ? formatJoined(result.label, result.watching) : formatNotJoined(result.error);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The round as the lines a lead reads: the rollup, then who is live.
|
|
71
|
+
*
|
|
72
|
+
* Capped, because a notify is a message and not a screen: past its limit the round says
|
|
73
|
+
* how many lines it left out instead of pushing the rest of the session up the screen.
|
|
74
|
+
*/
|
|
75
|
+
export function roundText(cards: readonly Card[], now: number, roster?: readonly RosterSession[]): string {
|
|
76
|
+
if (cards.length === 0) return "team: nothing to report — the board is empty";
|
|
77
|
+
const round = formatRound(cards, buildRound(cards, now)).split("\n");
|
|
78
|
+
const shown = round.slice(0, MAX_ROUND_LINES);
|
|
79
|
+
if (round.length > MAX_ROUND_LINES) shown.push(`… ${plural(round.length - MAX_ROUND_LINES, "line")} more`);
|
|
80
|
+
const who = presenceLine(roster, now);
|
|
81
|
+
if (who !== undefined) shown.push(who);
|
|
82
|
+
return shown.join("\n");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `live: a, b · not listening: b · quiet: c`, or nothing when nobody is here.
|
|
87
|
+
*
|
|
88
|
+
* Three facts, and the middle one is the one a lead can act on: a live session that is
|
|
89
|
+
* not listening reads mail at its next turn rather than being woken for it. It is only
|
|
90
|
+
* named when its record says so — a session that never wrote the field is not accused of
|
|
91
|
+
* ignoring anything.
|
|
92
|
+
*/
|
|
93
|
+
function presenceLine(roster: readonly RosterSession[] | undefined, now: number): string | undefined {
|
|
94
|
+
if (roster === undefined || roster.length === 0) return undefined;
|
|
95
|
+
const live: string[] = [];
|
|
96
|
+
const quiet: string[] = [];
|
|
97
|
+
const silent: string[] = [];
|
|
98
|
+
for (const session of roster) {
|
|
99
|
+
if (rosterStateOf(session, now) !== "live") {
|
|
100
|
+
quiet.push(session.label);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
live.push(session.label);
|
|
104
|
+
if (session.watching?.armed === false) silent.push(session.label);
|
|
105
|
+
}
|
|
106
|
+
const parts = [
|
|
107
|
+
live.length > 0 ? `live: ${live.join(", ")}` : "",
|
|
108
|
+
silent.length > 0 ? `not listening: ${silent.join(", ")}` : "",
|
|
109
|
+
quiet.length > 0 ? `quiet: ${quiet.join(", ")}` : "",
|
|
110
|
+
];
|
|
111
|
+
return parts.filter((part) => part !== "").join(" · ");
|
|
112
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The handoff: what `team assign` tells the peer, and how it tells it.
|
|
3
|
+
*
|
|
4
|
+
* The peer is a session that is already running, so this is a message and not a
|
|
5
|
+
* process: `session send` writes it into the recipient's inbox, and its armed watch is
|
|
6
|
+
* what raises the turn. Nothing here starts anything, and a send that fails leaves the
|
|
7
|
+
* card exactly as it was — the order is deliberate, because a card assigned without a
|
|
8
|
+
* message is a card nobody knows about.
|
|
9
|
+
*
|
|
10
|
+
* The transport is a seam (`Dispatch`), so the tool's ordering is testable without a
|
|
11
|
+
* bus, and the one production implementation is `lib/session-tool.ts`'s.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ExtensionToolContext } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import type { Card } from "../../lib/board/card.ts";
|
|
16
|
+
import { cardRef, type CardOwner } from "../../lib/board/card.ts";
|
|
17
|
+
import { listPeers, MAX_MESSAGE_TEXT, type Peer, send as sendTo } from "../../lib/session-tool.ts";
|
|
18
|
+
|
|
19
|
+
/** How a handoff reaches a peer, and what the bus can say about the peers there are. */
|
|
20
|
+
export interface Dispatch {
|
|
21
|
+
/** Send the text as `kind`, and report the labels a message file was written for. Throws on failure. */
|
|
22
|
+
send(input: { kind: "handoff" | "event"; to: string; text: string }): Promise<{ delivered: string[] }>;
|
|
23
|
+
/**
|
|
24
|
+
* Every session the bus lists, when it can say.
|
|
25
|
+
*
|
|
26
|
+
* Its record is what makes an assignment survive a rename — `holds` compares ids
|
|
27
|
+
* before labels — and what makes the report able to name a peer that cannot be
|
|
28
|
+
* woken. It never throws: a resolve that fails must not undo a handoff that landed.
|
|
29
|
+
*/
|
|
30
|
+
peers(): Promise<Peer[] | undefined>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What the peer reads.
|
|
35
|
+
*
|
|
36
|
+
* It names the card, its outcome, and the paths, and says what to do next: record the
|
|
37
|
+
* plan, work, note, finish. It does **not** ask the peer to claim the card — `assign`
|
|
38
|
+
* has already recorded the owner, and a claim by the holder is a no-op. The claim is
|
|
39
|
+
* only for the failure path where the send landed and the assignment did not.
|
|
40
|
+
*/
|
|
41
|
+
export function handoffText(card: Card, to: string, note?: string): string {
|
|
42
|
+
const lines = [
|
|
43
|
+
`Handoff: ${cardRef(card.id)} "${card.title}" is yours, ${to}.`,
|
|
44
|
+
card.paths.length > 0 ? `Paths: ${card.paths.join(", ")}` : undefined,
|
|
45
|
+
card.plan === undefined ? undefined : `Plan: ${card.plan}`,
|
|
46
|
+
note === undefined ? undefined : `Note: ${note}`,
|
|
47
|
+
`It is already assigned to you — record the plan with board action "plan" if it has none, then note each phase and move it to done or blocked.`,
|
|
48
|
+
].filter((line): line is string => line !== undefined);
|
|
49
|
+
return withinLimit(lines.join("\n"));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What every live session reads when the lead offers a card instead of assigning it.
|
|
54
|
+
*
|
|
55
|
+
* An offer names no owner: the first peer to claim the card takes it, so the text says
|
|
56
|
+
* what the card is and that claiming is the next step, and it never implies the work is
|
|
57
|
+
* already somebody's.
|
|
58
|
+
*/
|
|
59
|
+
export function offerText(card: Card, note?: string): string {
|
|
60
|
+
const lines = [
|
|
61
|
+
`Offer: ${cardRef(card.id)} "${card.title}" is unclaimed.`,
|
|
62
|
+
card.paths.length > 0 ? `Paths: ${card.paths.join(", ")}` : undefined,
|
|
63
|
+
card.plan === undefined ? undefined : `Plan: ${card.plan}`,
|
|
64
|
+
note === undefined ? undefined : `Note: ${note}`,
|
|
65
|
+
`First to claim it takes it: run board action "claim" with card "${card.id}", then note each phase and move it to done or blocked.`,
|
|
66
|
+
].filter((line): line is string => line !== undefined);
|
|
67
|
+
return withinLimit(lines.join("\n"));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Trim to the bus's text limit, which is also this message's limit.
|
|
72
|
+
*
|
|
73
|
+
* Not `lib/tui/columns.ts`'s `clip`: that one measures terminal columns, and this one
|
|
74
|
+
* counts characters, because the bus's limit is characters. The number arrives from
|
|
75
|
+
* `lib/session-tool.ts` — one copy for the two extensions that send through it.
|
|
76
|
+
*/
|
|
77
|
+
function withinLimit(text: string): string {
|
|
78
|
+
return text.length > MAX_MESSAGE_TEXT ? `${text.slice(0, MAX_MESSAGE_TEXT - 1)}…` : text;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The production dispatch: `lib/session-tool.ts`, with a failure turned into a throw. */
|
|
82
|
+
export function sessionDispatch(ctx: ExtensionToolContext): Dispatch {
|
|
83
|
+
return {
|
|
84
|
+
async send(input) {
|
|
85
|
+
const result = await sendTo(ctx, input);
|
|
86
|
+
// `assign` and `offer` branch on a failed send, so this seam reports it the way
|
|
87
|
+
// they read it: the bus's sentence, as the reason the card was not handed over.
|
|
88
|
+
if (!result.ok) throw new Error(result.error);
|
|
89
|
+
return { delivered: result.delivered };
|
|
90
|
+
},
|
|
91
|
+
peers: () => listPeers(ctx),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The peer `to` names: an id exactly, else a label ignoring case.
|
|
97
|
+
*
|
|
98
|
+
* The send has already proved exactly one session answers to it, so the first match is
|
|
99
|
+
* the recipient; a miss means the bus could not say, which is why the caller falls back
|
|
100
|
+
* to the label alone.
|
|
101
|
+
*/
|
|
102
|
+
export function peerFor(peers: readonly Peer[] | undefined, to: string): Peer | undefined {
|
|
103
|
+
if (peers === undefined) return undefined;
|
|
104
|
+
const target = to.trim();
|
|
105
|
+
const byId = peers.find((peer) => peer.id === target);
|
|
106
|
+
if (byId) return byId;
|
|
107
|
+
const wanted = target.toLowerCase();
|
|
108
|
+
return peers.find((peer) => peer.label.toLowerCase() === wanted);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The labels a message reached that cannot be woken: their record says they are not
|
|
113
|
+
* listening.
|
|
114
|
+
*
|
|
115
|
+
* A label the bus could not answer for is left out — "the record does not say" is not
|
|
116
|
+
* "nobody will read it" — and so is a peer the bus lists as listening.
|
|
117
|
+
*/
|
|
118
|
+
export function notListening(peers: readonly Peer[] | undefined, labels: readonly string[]): string[] {
|
|
119
|
+
if (peers === undefined) return [];
|
|
120
|
+
return labels.filter((label) => {
|
|
121
|
+
const peer = peerFor(peers, label);
|
|
122
|
+
return peer !== undefined && peer.listening === false;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The peer a handoff names, as the card records it when the bus cannot say who that
|
|
128
|
+
* is: the label a message file was written for, else the one that was asked for.
|
|
129
|
+
*/
|
|
130
|
+
export function ownerFor(to: string, delivered: readonly string[]): CardOwner {
|
|
131
|
+
const label = delivered[0] ?? to;
|
|
132
|
+
return { label };
|
|
133
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model-facing text for one `team` call.
|
|
3
|
+
*
|
|
4
|
+
* A handoff is stated as what happened and what did not: `handed off` names the peer
|
|
5
|
+
* and the card, a failed send says the card is untouched, and an assignment that lost
|
|
6
|
+
* a race after the send says the peer should claim it. Nothing here claims the peer
|
|
7
|
+
* read the message — `delivered` means a message file was written for it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { type Card, cardRef } from "../../lib/board/card.ts";
|
|
11
|
+
|
|
12
|
+
/** `/team join` landed: the label peers address, and whether mail will reach this session. */
|
|
13
|
+
export function formatJoined(label: string, watching: { armed: boolean }): string {
|
|
14
|
+
const state = watching.armed ? "listening for handoffs" : "not listening";
|
|
15
|
+
return `team: on the team as ${label}, ${state}.`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** `/team join` did not land, so nothing about this session changed. */
|
|
19
|
+
export function formatNotJoined(reason: string): string {
|
|
20
|
+
return `team: not on the team — ${reason}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A card that was handed over, and the peer that got the message.
|
|
25
|
+
*
|
|
26
|
+
* `notListening` is the one fact the send cannot tell you and the record can: a peer
|
|
27
|
+
* that is not listening reads the handoff at its next turn rather than being woken for
|
|
28
|
+
* it. Saying so is the difference between "delivered" and "delivered and read".
|
|
29
|
+
*/
|
|
30
|
+
export function formatHandoff(
|
|
31
|
+
card: Card,
|
|
32
|
+
to: string,
|
|
33
|
+
delivered: readonly string[],
|
|
34
|
+
notListening: readonly string[] = [],
|
|
35
|
+
): string {
|
|
36
|
+
const who = delivered.length > 0 ? delivered.join(", ") : to;
|
|
37
|
+
return [
|
|
38
|
+
`${cardRef(card.id)} handed off to ${who}.`,
|
|
39
|
+
` ${card.title}`,
|
|
40
|
+
card.paths.length > 0 ? ` paths: ${card.paths.join(", ")}` : undefined,
|
|
41
|
+
" The card is already owned by the peer; the handoff is in its inbox.",
|
|
42
|
+
notListeningLine(notListening),
|
|
43
|
+
]
|
|
44
|
+
.filter((line): line is string => line !== undefined)
|
|
45
|
+
.join("\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The line that names a peer which will not be woken, or nothing when every peer is
|
|
50
|
+
* listening.
|
|
51
|
+
*
|
|
52
|
+
* One sentence for both verbs: what the mail does instead, and no pronoun, because the
|
|
53
|
+
* list can hold one peer or three.
|
|
54
|
+
*/
|
|
55
|
+
export function notListeningLine(labels: readonly string[]): string | undefined {
|
|
56
|
+
if (labels.length === 0) return undefined;
|
|
57
|
+
return ` Not listening: ${labels.join(", ")} — it reads this at its next turn.`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The send that never landed: the card was not touched. */
|
|
61
|
+
export function formatHandoffFailure(card: Card, to: string, reason: string): string {
|
|
62
|
+
return `${cardRef(card.id)} was not handed off to ${to}: ${reason}\n The card is unchanged.`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A card offered to the team: who heard, and that nobody holds it yet.
|
|
67
|
+
*
|
|
68
|
+
* `offer` writes nothing, so the sentence has to say the ownership question is still
|
|
69
|
+
* open — a reader who took "offered" for "assigned" would stop looking at the card.
|
|
70
|
+
*/
|
|
71
|
+
export function formatOffer(card: Card, delivered: readonly string[], notListening: readonly string[] = []): string {
|
|
72
|
+
const who = delivered.length > 0 ? delivered.join(", ") : "nobody";
|
|
73
|
+
return [
|
|
74
|
+
`${cardRef(card.id)} offered to ${who}.`,
|
|
75
|
+
` ${card.title}`,
|
|
76
|
+
card.paths.length > 0 ? ` paths: ${card.paths.join(", ")}` : undefined,
|
|
77
|
+
" The card is unowned until one of them claims it.",
|
|
78
|
+
notListeningLine(notListening),
|
|
79
|
+
]
|
|
80
|
+
.filter((line): line is string => line !== undefined)
|
|
81
|
+
.join("\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The broadcast that never landed: no card was touched, and nobody heard. */
|
|
85
|
+
export function formatOfferFailure(card: Card, reason: string): string {
|
|
86
|
+
return `${cardRef(card.id)} was not offered: ${reason}\n The card is unchanged.`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The send landed and the assignment then lost a race.
|
|
91
|
+
*
|
|
92
|
+
* The peer has a message telling it the card is its, so the correction names the
|
|
93
|
+
* claim rather than pretending nothing happened.
|
|
94
|
+
*/
|
|
95
|
+
export function formatRacedAssignment(card: Card, to: string, reason: string): string {
|
|
96
|
+
return (
|
|
97
|
+
`${cardRef(card.id)} was handed off to ${to}, but the card changed before it could be assigned: ${reason}\n` +
|
|
98
|
+
` ${to} should run \`board\` action \`claim\` with card "${card.id}".`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A card taken back to To do. */
|
|
103
|
+
export function formatReclaimed(card: Card, previous: string): string {
|
|
104
|
+
return `${cardRef(card.id)} reclaimed from ${previous} and back in To do; its notes are kept.\n ${card.title}`;
|
|
105
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* team — the lead's round over the shared board.
|
|
3
|
+
*
|
|
4
|
+
* `board` owns the cards and never touches the session bus; `team` owns the two
|
|
5
|
+
* actions that need it. `assign` hands a card to a session that is already running
|
|
6
|
+
* and sends it a handoff — dispatch cannot start a peer's turn, and the page and the
|
|
7
|
+
* docs say so — and `reclaim` takes a card back when its owner has gone quiet. Its one
|
|
8
|
+
* command, `/team`, prints the round the board's Team tab draws, without a browser.
|
|
9
|
+
*
|
|
10
|
+
* Either extension works without the other: `board` is the work list any session
|
|
11
|
+
* uses, and `team` is the lead's. The factory only registers; the store is opened on
|
|
12
|
+
* first use.
|
|
13
|
+
*
|
|
14
|
+
* Load with: pi --extension ./extensions/team --extension ./extensions/session-bus
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { type BoardSession, openBoardSession } from "../../lib/board/session.ts";
|
|
19
|
+
import { isExtensionEnabled } from "../../lib/env.ts";
|
|
20
|
+
import { registerCommands } from "./commands.ts";
|
|
21
|
+
import { registerTools } from "./tools.ts";
|
|
22
|
+
|
|
23
|
+
export interface TeamDeps {
|
|
24
|
+
/** Test seam: a session with a temp agent directory and a fixed clock. */
|
|
25
|
+
session?: BoardSession;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default function team(pi: ExtensionAPI, deps: TeamDeps = {}): void {
|
|
29
|
+
if (!isExtensionEnabled("team")) return;
|
|
30
|
+
const session = deps.session ?? openBoardSession(pi);
|
|
31
|
+
registerTools(pi, session);
|
|
32
|
+
registerCommands(pi, session);
|
|
33
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `team` tool's parameters: two actions over one card.
|
|
3
|
+
*
|
|
4
|
+
* `assign` names the peer a handoff goes to, and `reclaim` takes a card back. Both
|
|
5
|
+
* take the card the way `board` does — `4` or `#4` — because a lead reads ids off
|
|
6
|
+
* the same board.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Type } from "typebox";
|
|
10
|
+
import { TEAM_ACTIONS } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
export const TeamParams = Type.Object({
|
|
13
|
+
action: Type.Union(
|
|
14
|
+
TEAM_ACTIONS.map((action) => Type.Literal(action)),
|
|
15
|
+
{
|
|
16
|
+
description:
|
|
17
|
+
"`assign` hands a card to a running session and sends it a handoff; `offer` broadcasts an unclaimed card to " +
|
|
18
|
+
"every other live session and writes nothing, so the first to claim it takes it; `reclaim` takes a card back " +
|
|
19
|
+
"to To do. A lead's three actions — use `board` to create, claim, move, note, and finish the cards themselves.",
|
|
20
|
+
},
|
|
21
|
+
),
|
|
22
|
+
card: Type.String({ description: 'The card to act on, e.g. "4" or "#4".' }),
|
|
23
|
+
to: Type.Optional(
|
|
24
|
+
Type.String({
|
|
25
|
+
description: "`assign`: the peer to hand the card to, as `/team` lists them — a teammate's label or id.",
|
|
26
|
+
}),
|
|
27
|
+
),
|
|
28
|
+
note: Type.Optional(Type.String({ description: "`assign` or `offer`: a line to include, e.g. what to do first." })),
|
|
29
|
+
});
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `team` tool: the lead's round over the same cards `board` owns.
|
|
3
|
+
*
|
|
4
|
+
* Two actions, because those are the two that need the bus. `assign` sends the
|
|
5
|
+
* handoff **first** and sets the owner **second**: a card assigned without a message
|
|
6
|
+
* is a card nobody knows about, while a message for a card that then changed is one
|
|
7
|
+
* the peer can still claim. Either failure is reported with the card as it actually
|
|
8
|
+
* is, so the lead's next call starts from the truth.
|
|
9
|
+
*
|
|
10
|
+
* `reclaim` needs no message — it is the compensation for a dispatch that did not
|
|
11
|
+
* land — and it belongs here rather than in `board` because it only makes sense in
|
|
12
|
+
* the lead's context.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import type { Static } from "typebox";
|
|
17
|
+
import { type Card, cardRef, normalizeCardId } from "../../lib/board/card.ts";
|
|
18
|
+
import { indexOf } from "../../lib/board/links.ts";
|
|
19
|
+
import { type Actor, applyOp } from "../../lib/board/rules.ts";
|
|
20
|
+
import type { BoardSession } from "../../lib/board/session.ts";
|
|
21
|
+
import type { BoardStore } from "../../lib/board/store.ts";
|
|
22
|
+
import { messageOf } from "../../lib/errors.ts";
|
|
23
|
+
import { failedResult, type ToolFailure, textResult } from "../../lib/tool-result.ts";
|
|
24
|
+
import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
|
|
25
|
+
import { callLine, detailsOrLine } from "../../lib/tui/tool.ts";
|
|
26
|
+
import { plural } from "../../lib/board/round-text.ts";
|
|
27
|
+
import { type Dispatch, handoffText, notListening, offerText, ownerFor, peerFor, sessionDispatch } from "./dispatch.ts";
|
|
28
|
+
import {
|
|
29
|
+
formatHandoff,
|
|
30
|
+
formatHandoffFailure,
|
|
31
|
+
formatOffer,
|
|
32
|
+
formatOfferFailure,
|
|
33
|
+
formatRacedAssignment,
|
|
34
|
+
formatReclaimed,
|
|
35
|
+
} from "./format.ts";
|
|
36
|
+
import { TeamParams } from "./schema.ts";
|
|
37
|
+
import { normalizeTeamAction, TEAM_ACTIONS, TEAM_TOOL, type TeamAction, type TeamDetails } from "./types.ts";
|
|
38
|
+
|
|
39
|
+
/** The parameters as the schema validates them. */
|
|
40
|
+
type TeamArgs = Static<typeof TeamParams>;
|
|
41
|
+
|
|
42
|
+
/** One refusal: the reason for the model, the state for the renderer. */
|
|
43
|
+
function refusal(action: TeamAction, message: string, card?: Card): ToolFailure<TeamDetails> {
|
|
44
|
+
return failedResult(`Error: ${message}`, { action, error: message, ...(card === undefined ? {} : { card }) });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The call line's subject: the verb, the card, and the peer it names. */
|
|
48
|
+
function callSubject(action: TeamAction, args: Record<string, unknown>): string {
|
|
49
|
+
const card = typeof args.card === "string" && args.card.trim() !== "" ? cardRef(args.card.trim()) : "";
|
|
50
|
+
const to = typeof args.to === "string" && args.to.trim() !== "" ? ` → ${args.to.trim()}` : "";
|
|
51
|
+
return card === "" ? action : `${action} ${card}${action === "assign" ? to : ""}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What the tool needs from the outside, so a suite drives it without a bus. */
|
|
55
|
+
export interface TeamToolsDeps {
|
|
56
|
+
/** Test seam: the handoff transport. */
|
|
57
|
+
dispatch?: (ctx: Parameters<typeof sessionDispatch>[0]) => Dispatch;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function registerTools(pi: ExtensionAPI, session: BoardSession, deps: TeamToolsDeps = {}): void {
|
|
61
|
+
const dispatchFor = deps.dispatch ?? sessionDispatch;
|
|
62
|
+
|
|
63
|
+
pi.registerTool({
|
|
64
|
+
name: TEAM_TOOL,
|
|
65
|
+
label: "Team",
|
|
66
|
+
description:
|
|
67
|
+
"Run a round of work over the shared board: `assign` hands a card to a running session and sends it a handoff " +
|
|
68
|
+
"(`to` is a teammate's label or id, as `/team` lists them); `offer` broadcasts an unclaimed card to every other " +
|
|
69
|
+
"live session and writes nothing, so the first to claim it takes it; `reclaim` takes a card back to To do and " +
|
|
70
|
+
"sends nothing, so it needs no team. Use `board` for everything else. Nothing here starts a peer's turn: a " +
|
|
71
|
+
"message waits in its inbox, and a peer that is listening reports it at its next turn.",
|
|
72
|
+
promptSnippet: "Run a round over the board: assign a card to a running session, offer one, or reclaim one.",
|
|
73
|
+
promptGuidelines: [
|
|
74
|
+
"Open the board with `/board` or work a card first, so this session and its peers are on the team.",
|
|
75
|
+
"Offer a card nobody has taken; assign one you mean a particular peer to hold.",
|
|
76
|
+
"When a result says `Not listening`, say so: the message waits for that peer's next turn.",
|
|
77
|
+
"Reclaim a card whose owner has gone quiet rather than editing someone else's work.",
|
|
78
|
+
],
|
|
79
|
+
parameters: TeamParams,
|
|
80
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
|
|
81
|
+
executionMode: "sequential",
|
|
82
|
+
|
|
83
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
84
|
+
const args = params as TeamArgs;
|
|
85
|
+
const action = normalizeTeamAction(args.action);
|
|
86
|
+
if (!action) {
|
|
87
|
+
return refusal("assign", `unknown action "${String(args.action)}"; use ${TEAM_ACTIONS.join(" or ")}.`);
|
|
88
|
+
}
|
|
89
|
+
const id = normalizeCardId(args.card);
|
|
90
|
+
if (id === undefined) return refusal(action, `"${String(args.card)}" is not a card id.`);
|
|
91
|
+
const me = session.who(ctx, ctx.cwd);
|
|
92
|
+
try {
|
|
93
|
+
const store = await session.store(ctx.cwd);
|
|
94
|
+
const now = session.now();
|
|
95
|
+
if (action === "reclaim") return await reclaimCard(store, me, id, now);
|
|
96
|
+
if (action === "offer") return await offerCard(store, id, args.note, now, dispatchFor(ctx));
|
|
97
|
+
const to = typeof args.to === "string" ? args.to.trim() : "";
|
|
98
|
+
if (to === "") return refusal("assign", 'assign needs a peer: pass `to`, e.g. "billing-core".');
|
|
99
|
+
return await assignCard(store, me, id, to, args.note, now, dispatchFor(ctx));
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return refusal(action, messageOf(error));
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
renderCall(args, theme, context) {
|
|
106
|
+
const action = normalizeTeamAction(args.action) ?? "assign";
|
|
107
|
+
return callLine(theme, context.lastComponent, TEAM_TOOL, callSubject(action, args as Record<string, unknown>));
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
renderResult(result, _options, theme, context) {
|
|
111
|
+
const text = reuseText(context?.lastComponent);
|
|
112
|
+
const details = toolDetails<TeamDetails>(result.details);
|
|
113
|
+
const outcome = detailsOrLine(theme, result, false, details);
|
|
114
|
+
text.setText("line" in outcome ? outcome.line : summaryOf(outcome.details));
|
|
115
|
+
return text;
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* `assign`: check the preconditions, send the handoff, then set the owner.
|
|
122
|
+
*
|
|
123
|
+
* The preconditions are checked with the same `applyOp` the store will use, so an
|
|
124
|
+
* assignment the rules would refuse never sends a message at all.
|
|
125
|
+
*/
|
|
126
|
+
async function assignCard(
|
|
127
|
+
store: BoardStore,
|
|
128
|
+
me: Actor,
|
|
129
|
+
id: string,
|
|
130
|
+
to: string,
|
|
131
|
+
note: string | undefined,
|
|
132
|
+
now: number,
|
|
133
|
+
dispatch: Dispatch,
|
|
134
|
+
): Promise<ReturnType<typeof textResult<TeamDetails>> | ToolFailure<TeamDetails>> {
|
|
135
|
+
const card = await store.read(id);
|
|
136
|
+
if (!card) return refusal("assign", `${cardRef(id)} does not exist.`);
|
|
137
|
+
const owner: Actor = { label: to };
|
|
138
|
+
// The board, not just the card: a card that waits on unfinished work is refused here,
|
|
139
|
+
// so a handoff for it is never sent.
|
|
140
|
+
const dry = applyOp(card, { kind: "assign", to: owner }, me, now, indexOf(await store.list()));
|
|
141
|
+
if (!dry.ok) return refusal("assign", dry.error, card);
|
|
142
|
+
|
|
143
|
+
let delivered: string[];
|
|
144
|
+
try {
|
|
145
|
+
delivered = (await dispatch.send({ kind: "handoff", to, text: handoffText(card, to, note) })).delivered;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
return refusal("assign", formatHandoffFailure(card, to, messageOf(error)), card);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Ask the bus which session answers to the label: an owner recorded with its id
|
|
151
|
+
// survives the peer renaming itself, which a label alone does not — and the same read
|
|
152
|
+
// says whether that peer will be woken by the message it just got.
|
|
153
|
+
const peers = await dispatch.peers().catch(() => undefined);
|
|
154
|
+
const peer = peerFor(peers, to);
|
|
155
|
+
const applied = await store.mutate(
|
|
156
|
+
id,
|
|
157
|
+
{ kind: "assign", to: peer === undefined ? ownerFor(to, delivered) : { id: peer.id, label: peer.label } },
|
|
158
|
+
me,
|
|
159
|
+
now,
|
|
160
|
+
);
|
|
161
|
+
if (!applied.ok)
|
|
162
|
+
return refusal("assign", formatRacedAssignment(applied.card ?? card, to, applied.error), applied.card ?? card);
|
|
163
|
+
const silent = notListening(peers, delivered);
|
|
164
|
+
return textResult(formatHandoff(applied.card, to, delivered, silent), {
|
|
165
|
+
action: "assign",
|
|
166
|
+
card: applied.card,
|
|
167
|
+
assigned: applied.card.owner,
|
|
168
|
+
delivered,
|
|
169
|
+
...(silent.length === 0 ? {} : { notListening: silent }),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The actor an offer's preconditions are judged as: nobody in particular.
|
|
175
|
+
*
|
|
176
|
+
* `claim` is what the offer asks a peer to do, so the dry run is a claim by someone who
|
|
177
|
+
* holds nothing — the rules' own refusals decide whether a card may be offered at all,
|
|
178
|
+
* without a second copy of the sentences.
|
|
179
|
+
*/
|
|
180
|
+
const OFFER_CLAIMANT: Actor = { label: "*" };
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* `offer`: check the claim's preconditions, broadcast the card, and write nothing.
|
|
184
|
+
*
|
|
185
|
+
* The order matters less here than in `assign`, because nothing is written: a failed
|
|
186
|
+
* broadcast leaves the card exactly as it was, and the first peer to claim is what makes
|
|
187
|
+
* an offer into work. Two peers racing are settled by the store's compare-and-set.
|
|
188
|
+
*/
|
|
189
|
+
async function offerCard(
|
|
190
|
+
store: BoardStore,
|
|
191
|
+
id: string,
|
|
192
|
+
note: string | undefined,
|
|
193
|
+
now: number,
|
|
194
|
+
dispatch: Dispatch,
|
|
195
|
+
): Promise<ReturnType<typeof textResult<TeamDetails>> | ToolFailure<TeamDetails>> {
|
|
196
|
+
const card = await store.read(id);
|
|
197
|
+
if (!card) return refusal("offer", `${cardRef(id)} does not exist.`);
|
|
198
|
+
// The board, not just the card: a card that waits on unfinished work, or one somebody
|
|
199
|
+
// holds, is refused here, so a broadcast for it is never sent.
|
|
200
|
+
const dry = applyOp(card, { kind: "claim" }, OFFER_CLAIMANT, now, indexOf(await store.list()));
|
|
201
|
+
if (!dry.ok) return refusal("offer", dry.error, card);
|
|
202
|
+
|
|
203
|
+
let delivered: string[];
|
|
204
|
+
try {
|
|
205
|
+
delivered = (await dispatch.send({ kind: "event", to: "*", text: offerText(card, note) })).delivered;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
return refusal("offer", formatOfferFailure(card, messageOf(error)), card);
|
|
208
|
+
}
|
|
209
|
+
const peers = await dispatch.peers().catch(() => undefined);
|
|
210
|
+
const silent = notListening(peers, delivered);
|
|
211
|
+
return textResult(formatOffer(card, delivered, silent), {
|
|
212
|
+
action: "offer",
|
|
213
|
+
card,
|
|
214
|
+
delivered,
|
|
215
|
+
...(silent.length === 0 ? {} : { notListening: silent }),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** `reclaim`: the owner clears and the card returns to To do, notes and all. */
|
|
220
|
+
async function reclaimCard(
|
|
221
|
+
store: BoardStore,
|
|
222
|
+
me: Actor,
|
|
223
|
+
id: string,
|
|
224
|
+
now: number,
|
|
225
|
+
): Promise<ReturnType<typeof textResult<TeamDetails>> | ToolFailure<TeamDetails>> {
|
|
226
|
+
const before = await store.read(id);
|
|
227
|
+
if (!before) return refusal("reclaim", `${cardRef(id)} does not exist.`);
|
|
228
|
+
const previous = before.owner?.label;
|
|
229
|
+
if (previous === undefined) return refusal("reclaim", `${cardRef(id)} has no owner to reclaim.`, before);
|
|
230
|
+
const result = await store.mutate(id, { kind: "reclaim" }, me, now);
|
|
231
|
+
if (!result.ok) return refusal("reclaim", result.error, result.card ?? before);
|
|
232
|
+
return textResult(formatReclaimed(result.card, previous), { action: "reclaim", card: result.card });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The collapsed line for a successful call, from its details. */
|
|
236
|
+
function summaryOf(details: TeamDetails): string {
|
|
237
|
+
const card = details.card;
|
|
238
|
+
if (!card) return details.action === "reclaim" ? "reclaimed" : "handed off";
|
|
239
|
+
if (details.action === "offer") {
|
|
240
|
+
const count = details.delivered?.length ?? 0;
|
|
241
|
+
return `${cardRef(card.id)} offered to ${plural(count, "session")}`;
|
|
242
|
+
}
|
|
243
|
+
const who = details.assigned?.label ?? card.owner?.label;
|
|
244
|
+
const target = details.action === "assign" && who !== undefined ? ` → ${who}` : "";
|
|
245
|
+
return `${cardRef(card.id)} ${card.status}${target}`;
|
|
246
|
+
}
|