@gavin-hu/my-pi-agent 0.18.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 +52 -44
- package/README.zh-CN.md +46 -41
- package/docs/extensions/README.md +34 -0
- package/docs/extensions/board.md +344 -0
- package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +446 -404
- 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} +61 -50
- package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
- package/{extensions/plan/README.md → docs/extensions/plan.md} +390 -317
- package/{extensions/question/README.md → docs/extensions/question.md} +54 -31
- 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} +165 -118
- 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} +54 -36
- package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
- package/{extensions/wechat/README.md → docs/extensions/wechat.md} +258 -162
- 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/panel/commands.ts +40 -7
- package/extensions/bytefly/panel/settings-runtime.ts +0 -6
- package/extensions/bytefly/panel/settings.ts +3 -20
- 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} +1 -1
- 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} +20 -69
- package/extensions/file-browse/{router.ts → transport/router.ts} +21 -15
- package/extensions/file-browse/transport/server.ts +110 -0
- package/extensions/file-browse/{chrome.ts → view/chrome.ts} +4 -4
- package/extensions/file-browse/{client.ts → view/client.ts} +1 -1
- package/extensions/file-browse/{diff-html.ts → view/diff-html.ts} +3 -3
- package/extensions/file-browse/{html.ts → view/html.ts} +14 -12
- package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -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} +29 -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/memory/store.ts +12 -5
- package/extensions/plan/blocks.ts +23 -2
- package/extensions/plan/commands.ts +21 -16
- package/extensions/plan/feedback.ts +147 -38
- package/extensions/plan/index.ts +6 -4
- package/extensions/plan/plan-index.ts +43 -9
- package/extensions/plan/plan-text.ts +33 -23
- package/extensions/plan/plans.ts +8 -11
- package/extensions/plan/policy.ts +20 -12
- package/extensions/plan/review.ts +3 -27
- package/extensions/plan/runtime.ts +21 -9
- package/extensions/plan/sections.ts +112 -0
- package/extensions/plan/steps.ts +3 -22
- package/extensions/plan/tools/exit.ts +11 -95
- package/extensions/plan/tui.ts +5 -34
- package/extensions/plan/types.ts +0 -8
- package/extensions/plan/web-client.ts +1 -1
- package/extensions/plan/web-page.ts +0 -7
- package/extensions/plan/web-router.ts +3 -57
- package/extensions/plan/web-share-session.ts +24 -30
- package/extensions/plan/web-streams.ts +14 -14
- package/extensions/plan/web-tokens.ts +2 -2
- package/extensions/plan/web.ts +66 -120
- 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 +17 -69
- package/extensions/session-bus/format.ts +22 -45
- package/extensions/session-bus/index.ts +13 -7
- package/extensions/session-bus/join-request.ts +40 -0
- package/extensions/session-bus/mail-watch.ts +134 -0
- package/extensions/session-bus/notice.ts +32 -4
- package/{lib/claims → extensions/session-bus}/overlap.ts +11 -18
- package/extensions/session-bus/protocol.ts +19 -49
- package/extensions/session-bus/resolve.ts +67 -0
- package/extensions/session-bus/result.ts +16 -51
- package/extensions/session-bus/rows.ts +4 -2
- package/extensions/session-bus/runtime.ts +129 -238
- package/extensions/session-bus/store.ts +42 -6
- package/extensions/session-bus/tools.ts +31 -66
- package/extensions/session-bus/tui.ts +4 -4
- package/extensions/session-bus/types.ts +13 -13
- 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/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} +37 -37
- package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +2 -2
- 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} +100 -22
- package/extensions/wechat/{presence.ts → peer/presence.ts} +8 -7
- package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +1 -1
- 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} +8 -0
- 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} +72 -28
- 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/guard.ts +3 -5
- package/lib/README.md +8 -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/serve.ts +234 -0
- package/lib/http/server.ts +12 -10
- package/lib/http/sink.ts +110 -0
- package/lib/http/token.ts +38 -0
- package/lib/policy.ts +12 -2
- package/lib/requirements.ts +6 -7
- 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/glyphs.ts +2 -0
- package/lib/web/README.md +5 -2
- package/lib/web/components.ts +5 -0
- package/lib/web/runtime.ts +7 -1
- package/lib/web/tokens.ts +25 -2
- package/package.json +26 -14
- package/skills/next/SKILL.md +4 -3
- 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 -506
- package/extensions/file-browse/server.ts +0 -141
- package/extensions/plan/split.ts +0 -87
- package/extensions/session-bus/README.md +0 -247
- package/extensions/session-bus/team.ts +0 -102
- package/extensions/wechat/outbound.ts +0 -100
- package/lib/plans/sections.ts +0 -172
- package/lib/plans/setting.ts +0 -52
- package/lib/plans/split.ts +0 -215
- package/lib/plans/store.ts +0 -19
- package/lib/plans/team.ts +0 -151
- package/skills/changelog/SKILL.md +0 -148
- package/skills/parallel-dev/SKILL.md +0 -150
- /package/extensions/file-browse/{rev.ts → live/rev.ts} +0 -0
- /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/{live-client.ts → view/live-client.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,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The card lifecycle: every transition, as a pure function of the card, the
|
|
3
|
+
* operation, who asked, the clock, and the board's other cards.
|
|
4
|
+
*
|
|
5
|
+
* One table decides who may do what — the tool, the router, the page, and `team` all
|
|
6
|
+
* route through {@link applyOp} — so a lane change cannot be reachable from one
|
|
7
|
+
* surface and refused from another. Nothing here touches the filesystem or the bus:
|
|
8
|
+
* ownership is advisory (the bus's path claims are the lock), and the store owns the
|
|
9
|
+
* compare-and-set that makes a concurrent writer lose.
|
|
10
|
+
*
|
|
11
|
+
* The board index is a parameter rather than a lookup because three operations need a
|
|
12
|
+
* *different* card: a claim or a move is refused while a dependency is unfinished, and
|
|
13
|
+
* a drop is refused while somebody waits on the card. `lib/board/links.ts` answers
|
|
14
|
+
* those questions; this module decides what to do with the answer.
|
|
15
|
+
*
|
|
16
|
+
* Two shapes of failure, and the difference matters. A refusal is `{ ok: false }`
|
|
17
|
+
* with the reason, and the caller returns the card untouched. A *no-op* is
|
|
18
|
+
* `{ ok: true, changed: false }`: the operation's preconditions already hold
|
|
19
|
+
* (claiming a card you already hold), so nothing is written and the revision does
|
|
20
|
+
* not move.
|
|
21
|
+
*
|
|
22
|
+
* `add` is {@link createCard} rather than an operation, because it has no card to
|
|
23
|
+
* transition — and because a card is born `todo` with no owner, which is the one
|
|
24
|
+
* state the other operations never produce.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
type Card,
|
|
29
|
+
type CardOwner,
|
|
30
|
+
type CardStatus,
|
|
31
|
+
cardRef,
|
|
32
|
+
DEFAULT_PRIORITY,
|
|
33
|
+
dependsOf,
|
|
34
|
+
MAX_NOTES,
|
|
35
|
+
normalizeNote,
|
|
36
|
+
normalizePathHints,
|
|
37
|
+
normalizePlanPath,
|
|
38
|
+
normalizePriority,
|
|
39
|
+
normalizeTitle,
|
|
40
|
+
type Priority,
|
|
41
|
+
priorityOf,
|
|
42
|
+
} from "./card.ts";
|
|
43
|
+
import { type BoardIndex, dependedOn, normalizeDepends, waitingOn } from "./links.ts";
|
|
44
|
+
|
|
45
|
+
/** Who is attempting an operation: a session when it has one, and the label it is addressed by. */
|
|
46
|
+
export interface Actor {
|
|
47
|
+
id?: string;
|
|
48
|
+
label: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One named mutation of one card. */
|
|
52
|
+
export type CardOp =
|
|
53
|
+
| { kind: "claim" }
|
|
54
|
+
/**
|
|
55
|
+
* Hand a card to `to`, and — from the page, which cannot message a peer — a note that
|
|
56
|
+
* says so. `team assign` sends a handoff instead and passes none.
|
|
57
|
+
*/
|
|
58
|
+
| { kind: "assign"; to: Actor; note?: unknown }
|
|
59
|
+
| { kind: "release" }
|
|
60
|
+
| { kind: "move"; status: Exclude<CardStatus, "todo"> }
|
|
61
|
+
// `text` and `path` are whatever the model sent: `card.ts` validates them, and the
|
|
62
|
+
// refusal it produces is the one the caller shows.
|
|
63
|
+
| { kind: "note"; text: unknown }
|
|
64
|
+
| { kind: "plan"; path: unknown }
|
|
65
|
+
// Every field but `kind` is optional, and an absent one is left as it was: the page
|
|
66
|
+
// sends the three it can show, and omitting a link must not clear it.
|
|
67
|
+
| { kind: "edit"; title: unknown; paths: unknown; priority: unknown; dependsOn: unknown }
|
|
68
|
+
| { kind: "reclaim" }
|
|
69
|
+
| { kind: "drop" };
|
|
70
|
+
|
|
71
|
+
/** What an operation did: the new card, or the reason it was refused. */
|
|
72
|
+
export type OpResult = { ok: true; card: Card; changed: boolean } | { ok: false; error: string };
|
|
73
|
+
|
|
74
|
+
/** A card's first state: `todo`, no owner, one note at most. */
|
|
75
|
+
export function createCard(
|
|
76
|
+
id: string,
|
|
77
|
+
input: { title: string; paths: string[]; note?: string; priority?: Priority },
|
|
78
|
+
actor: Actor,
|
|
79
|
+
now: number,
|
|
80
|
+
): Card {
|
|
81
|
+
return {
|
|
82
|
+
v: 1,
|
|
83
|
+
id,
|
|
84
|
+
title: input.title,
|
|
85
|
+
status: "todo",
|
|
86
|
+
paths: [...input.paths],
|
|
87
|
+
// A new card says its priority out loud, even when it is the ordinary one, so a
|
|
88
|
+
// file on disk tells a reader the whole story without a default to know.
|
|
89
|
+
priority: input.priority ?? DEFAULT_PRIORITY,
|
|
90
|
+
notes: input.note === undefined ? [] : [{ by: actor.label, at: now, text: input.note }],
|
|
91
|
+
notesDropped: 0,
|
|
92
|
+
rev: 1,
|
|
93
|
+
createdAt: now,
|
|
94
|
+
updatedAt: now,
|
|
95
|
+
by: actor.label,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Whether `actor` is the owner of `card`.
|
|
101
|
+
*
|
|
102
|
+
* The id decides when both sides have one — a session that renamed itself is still
|
|
103
|
+
* the same holder — and the label decides otherwise, case-insensitively, the way
|
|
104
|
+
* the bus resolves a target.
|
|
105
|
+
*/
|
|
106
|
+
export function holds(card: Card, actor: Actor): boolean {
|
|
107
|
+
const owner = card.owner;
|
|
108
|
+
if (owner === undefined) return false;
|
|
109
|
+
if (owner.id !== undefined && actor.id !== undefined) return owner.id === actor.id;
|
|
110
|
+
return owner.label.toLowerCase() === actor.label.toLowerCase();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Apply one operation.
|
|
115
|
+
*
|
|
116
|
+
* A refused operation returns the reason and no card: the caller already holds the
|
|
117
|
+
* card it read, and a "refused" copy of it would be a second version of the truth.
|
|
118
|
+
*/
|
|
119
|
+
export function applyOp(card: Card, op: CardOp, actor: Actor, now: number, board: BoardIndex): OpResult {
|
|
120
|
+
switch (op.kind) {
|
|
121
|
+
case "claim":
|
|
122
|
+
return claim(card, actor, now, board);
|
|
123
|
+
case "assign":
|
|
124
|
+
return assign(card, op.to, op.note, actor, now, board);
|
|
125
|
+
case "release":
|
|
126
|
+
return release(card, actor, now);
|
|
127
|
+
case "move":
|
|
128
|
+
return move(card, op.status, actor, now, board);
|
|
129
|
+
case "note":
|
|
130
|
+
return note(card, op.text, actor, now);
|
|
131
|
+
case "plan":
|
|
132
|
+
return plan(card, op.path, actor, now);
|
|
133
|
+
case "edit":
|
|
134
|
+
return edit(card, op, actor, now, board);
|
|
135
|
+
case "reclaim":
|
|
136
|
+
return reclaim(card, actor, now);
|
|
137
|
+
case "drop":
|
|
138
|
+
return drop(card, actor, board);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function claim(card: Card, actor: Actor, now: number, board: BoardIndex): OpResult {
|
|
143
|
+
if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; it cannot be claimed.` };
|
|
144
|
+
if (holds(card, actor)) return { ok: true, card, changed: false };
|
|
145
|
+
if (card.owner !== undefined) return { ok: false, error: heldBy(card) };
|
|
146
|
+
const waiting = waitingOn(card, board);
|
|
147
|
+
if (waiting !== undefined) return { ok: false, error: waiting };
|
|
148
|
+
return { ok: true, card: write(card, actor, now, { status: "doing", owner: ownerOf(actor) }), changed: true };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function assign(card: Card, to: Actor, rawNote: unknown, actor: Actor, now: number, board: BoardIndex): OpResult {
|
|
152
|
+
if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; it cannot be assigned.` };
|
|
153
|
+
if (card.owner !== undefined && !holds(card, to)) return { ok: false, error: heldBy(card) };
|
|
154
|
+
if (holds(card, to) && card.status === "doing") return { ok: true, card, changed: false };
|
|
155
|
+
const waiting = waitingOn(card, board);
|
|
156
|
+
if (waiting !== undefined) return { ok: false, error: waiting };
|
|
157
|
+
const fields: Partial<Card> = { status: "doing", owner: ownerOf(to) };
|
|
158
|
+
if (rawNote !== undefined) {
|
|
159
|
+
const parsed = normalizeNote(rawNote);
|
|
160
|
+
if ("error" in parsed) return { ok: false, error: parsed.error };
|
|
161
|
+
// One revision, not two: the history says who it went to in the same write that
|
|
162
|
+
// recorded the owner, so a reader never sees the assignment without the sentence.
|
|
163
|
+
Object.assign(fields, appendNote(card, actor, now, parsed.text));
|
|
164
|
+
}
|
|
165
|
+
return { ok: true, card: write(card, actor, now, fields), changed: true };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function release(card: Card, actor: Actor, now: number): OpResult {
|
|
169
|
+
// Finished work is history: every other transition refuses it, and a release that did
|
|
170
|
+
// not would be the one way a `done` card could go backwards — reopening the order a
|
|
171
|
+
// dependency was recorded against.
|
|
172
|
+
if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; it cannot be released.` };
|
|
173
|
+
if (card.owner === undefined) return { ok: false, error: `${cardRef(card.id)} is not claimed.` };
|
|
174
|
+
if (!holds(card, actor)) return { ok: false, error: `${heldBy(card)} Release is the owner's decision.` };
|
|
175
|
+
return { ok: true, card: write(card, actor, now, { status: "todo", owner: undefined }), changed: true };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function move(card: Card, status: Exclude<CardStatus, "todo">, actor: Actor, now: number, board: BoardIndex): OpResult {
|
|
179
|
+
if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; it cannot be moved.` };
|
|
180
|
+
if (card.owner === undefined) {
|
|
181
|
+
return { ok: false, error: `${cardRef(card.id)} is unclaimed; claim it before moving it to ${status}.` };
|
|
182
|
+
}
|
|
183
|
+
if (!holds(card, actor)) return { ok: false, error: `${heldBy(card)} Only its owner can move it.` };
|
|
184
|
+
if (card.status === status) return { ok: true, card, changed: false };
|
|
185
|
+
// Parking is what a link is for, so only the two lanes that put a card back in flight
|
|
186
|
+
// are gated: a card that waits may be blocked, it may not be taken or finished.
|
|
187
|
+
if (status !== "blocked") {
|
|
188
|
+
const waiting = waitingOn(card, board);
|
|
189
|
+
if (waiting !== undefined) return { ok: false, error: waiting };
|
|
190
|
+
}
|
|
191
|
+
return { ok: true, card: write(card, actor, now, { status }), changed: true };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function note(card: Card, raw: unknown, actor: Actor, now: number): OpResult {
|
|
195
|
+
const parsed = normalizeNote(raw);
|
|
196
|
+
if ("error" in parsed) return { ok: false, error: parsed.error };
|
|
197
|
+
return { ok: true, card: write(card, actor, now, appendNote(card, actor, now, parsed.text)), changed: true };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** A card's notes after one more, with the oldest dropped and counted. */
|
|
201
|
+
function appendNote(card: Card, actor: Actor, now: number, text: string): Pick<Card, "notes" | "notesDropped"> {
|
|
202
|
+
const notes = [...card.notes, { by: actor.label, at: now, text }];
|
|
203
|
+
const overflow = Math.max(0, notes.length - MAX_NOTES);
|
|
204
|
+
return {
|
|
205
|
+
notes: overflow === 0 ? notes : notes.slice(overflow),
|
|
206
|
+
notesDropped: card.notesDropped + overflow,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function plan(card: Card, raw: unknown, actor: Actor, now: number): OpResult {
|
|
211
|
+
if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; its plan cannot change.` };
|
|
212
|
+
if (!holds(card, actor)) return { ok: false, error: `${heldBy(card)} Only its owner records a plan.` };
|
|
213
|
+
const parsed = normalizePlanPath(raw);
|
|
214
|
+
if ("error" in parsed) return { ok: false, error: parsed.error };
|
|
215
|
+
if (card.plan === parsed.path) return { ok: true, card, changed: false };
|
|
216
|
+
return { ok: true, card: write(card, actor, now, { plan: parsed.path }), changed: true };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Fix what a card says: its title, its hints, its priority, and what it waits for.
|
|
221
|
+
*
|
|
222
|
+
* The holder decides for a held card and anyone decides for an unclaimed one — and
|
|
223
|
+
* that includes a `done` card, because a title is what a reader reads, not a step of
|
|
224
|
+
* the work: a finished feature whose title says the wrong thing would stay wrong for
|
|
225
|
+
* as long as the board keeps history.
|
|
226
|
+
*
|
|
227
|
+
* Every field is optional and an absent one is left alone; a field that already holds
|
|
228
|
+
* the value asked for is not a write, so re-editing the same card is a no-op.
|
|
229
|
+
*/
|
|
230
|
+
function edit(
|
|
231
|
+
card: Card,
|
|
232
|
+
op: Extract<CardOp, { kind: "edit" }>,
|
|
233
|
+
actor: Actor,
|
|
234
|
+
now: number,
|
|
235
|
+
board: BoardIndex,
|
|
236
|
+
): OpResult {
|
|
237
|
+
if (card.owner !== undefined && !holds(card, actor)) {
|
|
238
|
+
return { ok: false, error: `${heldBy(card)} Only its owner can edit it.` };
|
|
239
|
+
}
|
|
240
|
+
const fields: Partial<Card> = {};
|
|
241
|
+
if (op.title !== undefined) {
|
|
242
|
+
const parsed = normalizeTitle(op.title);
|
|
243
|
+
if ("error" in parsed) return { ok: false, error: parsed.error };
|
|
244
|
+
if (parsed.title !== card.title) fields.title = parsed.title;
|
|
245
|
+
}
|
|
246
|
+
if (op.paths !== undefined) {
|
|
247
|
+
const parsed = normalizePathHints(op.paths);
|
|
248
|
+
if ("error" in parsed) return { ok: false, error: parsed.error };
|
|
249
|
+
if (!sameList(parsed.paths, card.paths)) fields.paths = parsed.paths;
|
|
250
|
+
}
|
|
251
|
+
if (op.priority !== undefined) {
|
|
252
|
+
const parsed = normalizePriority(op.priority);
|
|
253
|
+
if ("error" in parsed) return { ok: false, error: parsed.error };
|
|
254
|
+
if (parsed.priority !== priorityOf(card)) fields.priority = parsed.priority;
|
|
255
|
+
}
|
|
256
|
+
if (op.dependsOn !== undefined) {
|
|
257
|
+
const parsed = normalizeDepends(op.dependsOn, card.id, board);
|
|
258
|
+
if ("error" in parsed) return { ok: false, error: parsed.error };
|
|
259
|
+
if (!sameList(parsed.dependsOn, dependsOf(card))) fields.dependsOn = parsed.dependsOn;
|
|
260
|
+
}
|
|
261
|
+
if (Object.keys(fields).length === 0) return { ok: true, card, changed: false };
|
|
262
|
+
return { ok: true, card: write(card, actor, now, fields), changed: true };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function reclaim(card: Card, actor: Actor, now: number): OpResult {
|
|
266
|
+
if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; there is nothing to reclaim.` };
|
|
267
|
+
if (card.owner === undefined) return { ok: false, error: `${cardRef(card.id)} has no owner to reclaim.` };
|
|
268
|
+
return { ok: true, card: write(card, actor, now, { status: "todo", owner: undefined }), changed: true };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function drop(card: Card, actor: Actor, board: BoardIndex): OpResult {
|
|
272
|
+
// A drop takes its id with it, so a card another one waits on cannot leave: the
|
|
273
|
+
// remaining link would name nothing, and nothing on the board would say so.
|
|
274
|
+
const depended = dependedOn(card.id, board);
|
|
275
|
+
if (depended !== undefined) return { ok: false, error: depended };
|
|
276
|
+
if (card.status === "todo" || card.status === "done") return { ok: true, card, changed: true };
|
|
277
|
+
if (!holds(card, actor)) return { ok: false, error: `${heldBy(card)} It cannot be dropped while someone holds it.` };
|
|
278
|
+
return { ok: true, card, changed: true };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Whether two string lists are the same list, in the same order. */
|
|
282
|
+
function sameList(left: readonly string[], right: readonly string[]): boolean {
|
|
283
|
+
return left.length === right.length && left.every((entry, index) => entry === right[index]);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** A card with `fields` applied and its write marks moved. */
|
|
287
|
+
function write(card: Card, actor: Actor, now: number, fields: Partial<Card>): Card {
|
|
288
|
+
const next: Card = { ...card, ...fields, rev: card.rev + 1, updatedAt: now, by: actor.label };
|
|
289
|
+
// `owner: undefined` must clear the field rather than store a key holding it, so a
|
|
290
|
+
// released card reads back exactly like one that was never claimed. Only an
|
|
291
|
+
// explicit `owner` in `fields` may delete it: every other write keeps the holder.
|
|
292
|
+
if ("owner" in fields && fields.owner === undefined) delete next.owner;
|
|
293
|
+
return next;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The owner recorded for an actor: the id only when this actor has one. */
|
|
297
|
+
function ownerOf(actor: Actor): CardOwner {
|
|
298
|
+
return actor.id === undefined ? { label: actor.label } : { id: actor.id, label: actor.label };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** The refusal for an operation on a card someone else holds. */
|
|
302
|
+
function heldBy(card: Card): string {
|
|
303
|
+
return `${cardRef(card.id)} is held by ${card.owner?.label ?? "another session"}.`;
|
|
304
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a board call works on, and who it writes as.
|
|
3
|
+
*
|
|
4
|
+
* One store per working directory, opened on first use and kept for the session:
|
|
5
|
+
* a tool call is frequent and the repository lookup shells out to git, so the
|
|
6
|
+
* answer is remembered per `cwd`. The identity is the same pair a card records —
|
|
7
|
+
* the session id Pi knows, and the label `session-bus` would settle by default —
|
|
8
|
+
* which is why the board can name its owner without reading the bus.
|
|
9
|
+
*
|
|
10
|
+
* The store and its cache live in this closure, not in a module variable: Pi loads
|
|
11
|
+
* each extension with an isolated module cache, so module state would be one copy
|
|
12
|
+
* per extension and none of them the same one.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { basename, join } from "node:path";
|
|
18
|
+
import { defaultLabel, sessionNameOf } from "../session-label.ts";
|
|
19
|
+
import { BUS_DIR_NAME } from "../session-roster.ts";
|
|
20
|
+
import type { Actor } from "./rules.ts";
|
|
21
|
+
import { type BoardStore, openBoard } from "./store.ts";
|
|
22
|
+
|
|
23
|
+
/** What a tool, a page, and a round need from the session, without any of them owning it. */
|
|
24
|
+
export interface BoardSession {
|
|
25
|
+
/** The board for `cwd`'s repository. */
|
|
26
|
+
store(cwd: string): Promise<BoardStore>;
|
|
27
|
+
/** The session a write is made as. */
|
|
28
|
+
who(ctx: ExtensionContext, cwd: string): Actor;
|
|
29
|
+
/** The clock, so a test drives a deadline instead of waiting on one. */
|
|
30
|
+
now(): number;
|
|
31
|
+
/**
|
|
32
|
+
* Where this repository's session bus files its records, for the page's roster.
|
|
33
|
+
*
|
|
34
|
+
* A path, not a dependency: the board reads those files when they are there and
|
|
35
|
+
* behaves exactly as before when they are not, which is what keeps the page working
|
|
36
|
+
* with `session-bus` unloaded.
|
|
37
|
+
*/
|
|
38
|
+
busDir(cwd: string): Promise<string>;
|
|
39
|
+
/** Whether this session has already joined the team, so a card write joins once. */
|
|
40
|
+
joined(): boolean;
|
|
41
|
+
/** Record that the join landed; a failed one leaves this false so the next write asks. */
|
|
42
|
+
markJoined(): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Test seams and the one place a caller may redirect the store. */
|
|
46
|
+
export interface BoardSessionOptions {
|
|
47
|
+
now?: () => number;
|
|
48
|
+
agentDir?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build the session around one extension's `pi`.
|
|
53
|
+
*
|
|
54
|
+
* Shared by `board` and `team`: both work on the same store and must name a write
|
|
55
|
+
* as the same session, and two copies are how a card's owner label drifts from the
|
|
56
|
+
* label the board writes.
|
|
57
|
+
*/
|
|
58
|
+
export function openBoardSession(pi: ExtensionAPI, options: BoardSessionOptions = {}): BoardSession {
|
|
59
|
+
const stores = new Map<string, Promise<BoardStore>>();
|
|
60
|
+
const busDirs = new Map<string, Promise<string>>();
|
|
61
|
+
let teamJoined = false;
|
|
62
|
+
const agentDir = options.agentDir;
|
|
63
|
+
const session: BoardSession = {
|
|
64
|
+
store(cwd) {
|
|
65
|
+
const cached = stores.get(cwd);
|
|
66
|
+
if (cached) return cached;
|
|
67
|
+
const opened = openBoard(pi, cwd, agentDir === undefined ? {} : { agentDir });
|
|
68
|
+
stores.set(cwd, opened);
|
|
69
|
+
return opened;
|
|
70
|
+
},
|
|
71
|
+
who(ctx, cwd) {
|
|
72
|
+
const id = sessionIdOf(ctx);
|
|
73
|
+
const label = defaultLabel(sessionNameOf(ctx), cwd, id ?? "session");
|
|
74
|
+
return id === undefined ? { label } : { id, label };
|
|
75
|
+
},
|
|
76
|
+
now: options.now ?? Date.now,
|
|
77
|
+
// The board's own directory is `<agentDir>/board/<key>` and the bus's is
|
|
78
|
+
// `<agentDir>/session-bus/<key>`, both keyed by the same repository, so the key is
|
|
79
|
+
// read back off the board rather than resolved a second time through git.
|
|
80
|
+
busDir(cwd) {
|
|
81
|
+
const cached = busDirs.get(cwd);
|
|
82
|
+
if (cached) return cached;
|
|
83
|
+
const resolved = session
|
|
84
|
+
.store(cwd)
|
|
85
|
+
.then((store) => join(agentDir ?? getAgentDir(), BUS_DIR_NAME, basename(store.dir)));
|
|
86
|
+
busDirs.set(cwd, resolved);
|
|
87
|
+
return resolved;
|
|
88
|
+
},
|
|
89
|
+
joined: () => teamJoined,
|
|
90
|
+
markJoined: () => {
|
|
91
|
+
teamJoined = true;
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
return session;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The session id, or undefined when the host does not report one. */
|
|
98
|
+
function sessionIdOf(ctx: ExtensionContext): string | undefined {
|
|
99
|
+
try {
|
|
100
|
+
const id = ctx.sessionManager.getSessionId();
|
|
101
|
+
return typeof id === "string" && id !== "" ? id : undefined;
|
|
102
|
+
} catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|