@gavin-hu/my-pi-agent 0.19.0 → 0.21.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 +19 -12
- package/README.zh-CN.md +15 -9
- package/docs/extensions/README.md +2 -1
- package/docs/extensions/board.md +329 -151
- package/docs/extensions/bytefly.md +427 -273
- package/docs/extensions/file-browse.md +46 -44
- package/docs/extensions/job.md +1 -2
- package/docs/extensions/plan.md +86 -24
- package/docs/extensions/serve.md +129 -0
- package/docs/extensions/session-bus.md +23 -10
- package/docs/extensions/team.md +56 -28
- package/docs/extensions/wechat.md +2 -1
- package/docs/extensions/worktree.md +30 -15
- package/extensions/board/commands.ts +48 -17
- package/extensions/board/format.ts +107 -9
- package/extensions/board/index.ts +14 -2
- package/extensions/board/join.ts +31 -10
- package/extensions/board/page.ts +202 -156
- package/extensions/board/result.ts +10 -24
- package/extensions/board/rows.ts +1 -1
- package/extensions/board/schema.ts +31 -10
- package/extensions/board/seat.ts +9 -10
- package/extensions/board/snapshot.ts +7 -5
- package/extensions/board/tools.ts +211 -34
- package/extensions/board/transport/router.ts +27 -110
- package/extensions/board/transport/server.ts +76 -56
- package/extensions/board/types.ts +57 -0
- package/extensions/board/view/client.ts +24 -13
- package/extensions/board/view/dialog.ts +43 -0
- package/extensions/board/view/keys.ts +28 -24
- package/extensions/board/view/live.ts +6 -178
- package/extensions/board/view/names.ts +91 -0
- package/extensions/board/view/page.ts +60 -89
- package/extensions/board/view/render.ts +228 -200
- package/extensions/board/view/styles.ts +72 -49
- package/extensions/board/view/team.ts +63 -41
- package/extensions/bytefly/footer/budget.ts +6 -5
- package/extensions/bytefly/footer/collect/entries.ts +12 -4
- package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
- package/extensions/bytefly/footer/config.ts +19 -11
- package/extensions/bytefly/footer/price/price.ts +30 -8
- package/extensions/bytefly/footer/register.ts +5 -5
- package/extensions/bytefly/footer/render/footer.ts +18 -4
- package/extensions/bytefly/footer/render/format.ts +18 -0
- 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 +23 -7
- package/extensions/bytefly/footer/render/segments.ts +93 -31
- package/extensions/bytefly/footer/types.ts +29 -5
- package/extensions/bytefly/footer/unpriced.ts +42 -17
- package/extensions/bytefly/header/component.ts +4 -20
- package/extensions/bytefly/panel/commands.ts +28 -1
- package/extensions/bytefly/panel/filter.ts +68 -0
- package/extensions/bytefly/panel/format.ts +16 -15
- package/extensions/bytefly/panel/join.ts +56 -13
- package/extensions/bytefly/panel/prices.ts +43 -0
- package/extensions/bytefly/panel/settings.ts +72 -23
- package/extensions/bytefly/panel/tui/entries.ts +26 -8
- package/extensions/bytefly/panel/tui/frame.ts +130 -93
- package/extensions/bytefly/panel/tui/keys.ts +352 -0
- package/extensions/bytefly/panel/tui/list-view.ts +25 -46
- package/extensions/bytefly/panel/tui/panes.ts +76 -0
- package/extensions/bytefly/panel/tui/section-view.ts +126 -0
- package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
- package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
- package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
- package/extensions/bytefly/panel/tui.ts +306 -259
- package/extensions/check/commands.ts +2 -1
- package/extensions/check/render.ts +10 -1
- package/extensions/file-browse/config/config.ts +4 -19
- package/extensions/file-browse/files/classify.ts +136 -0
- package/extensions/file-browse/files/files.ts +8 -547
- package/extensions/file-browse/files/index.ts +90 -0
- package/extensions/file-browse/files/list.ts +261 -0
- package/extensions/file-browse/files/tree.ts +101 -0
- package/extensions/file-browse/index.ts +27 -48
- package/extensions/file-browse/live/live.ts +1 -1
- package/extensions/file-browse/transport/router.ts +12 -6
- 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/emit.ts +51 -0
- package/extensions/file-browse/view/html.ts +78 -46
- package/extensions/file-browse/view/live-client.ts +2 -2
- package/extensions/file-browse/view/markdown.ts +23 -26
- package/extensions/file-browse/view/palette.ts +2 -2
- package/extensions/file-browse/view/raw-html.ts +27 -22
- package/extensions/file-browse/view/tree.ts +1 -1
- package/extensions/file-browse/view/urls.ts +20 -6
- package/extensions/goal/tui.ts +12 -2
- package/extensions/job/runtime.ts +4 -4
- package/extensions/job/tools.ts +19 -7
- package/extensions/job/tui.ts +2 -28
- package/extensions/plan/commands.ts +2 -2
- package/extensions/plan/feedback.ts +88 -18
- package/extensions/plan/format.ts +10 -3
- package/extensions/plan/index.ts +24 -2
- package/extensions/plan/plan-files.ts +88 -0
- package/extensions/plan/plan-text.ts +114 -0
- package/extensions/plan/plans.ts +19 -23
- package/extensions/plan/policy.ts +10 -5
- package/extensions/plan/runtime.ts +43 -4
- package/extensions/plan/todos.ts +51 -3
- package/extensions/plan/tools/exit.ts +10 -1
- package/extensions/plan/tools/todo-seed.ts +4 -2
- package/extensions/plan/tools/write.ts +12 -1
- package/extensions/plan/tools.ts +2 -2
- package/extensions/plan/types.ts +15 -0
- package/extensions/plan/web-blocks.ts +160 -0
- package/extensions/plan/web-chrome.ts +7 -5
- package/extensions/plan/web-client.ts +271 -325
- package/extensions/plan/web-collab.ts +7 -7
- package/extensions/plan/web-comments.ts +57 -16
- package/extensions/plan/web-composer.ts +91 -0
- package/extensions/plan/web-draft.ts +40 -0
- package/extensions/plan/web-keys.ts +258 -0
- package/extensions/plan/web-markdown.ts +1 -1
- package/extensions/plan/web-outline.ts +212 -0
- package/extensions/plan/web-page.ts +59 -86
- package/extensions/plan/web-payload.ts +188 -0
- package/extensions/plan/web-plan.ts +160 -0
- package/extensions/plan/web-router.ts +0 -1
- package/extensions/plan/web-settle.ts +201 -0
- 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 +77 -130
- package/extensions/rewind/commands/review.ts +2 -1
- package/extensions/serve/config.ts +37 -0
- package/extensions/serve/index.ts +71 -0
- package/extensions/serve/registry.ts +251 -0
- package/extensions/session-bus/join-request.ts +16 -9
- package/extensions/session-bus/result.ts +1 -27
- package/extensions/session-bus/rows.ts +7 -3
- package/extensions/session-bus/runtime.ts +100 -38
- package/extensions/session-bus/store.ts +14 -2
- package/extensions/session-bus/tools.ts +5 -4
- package/extensions/session-bus/tui.ts +1 -1
- package/extensions/session-bus/types.ts +10 -1
- package/extensions/subagent/commands.ts +2 -1
- package/extensions/team/commands.ts +44 -9
- package/extensions/team/format.ts +14 -0
- package/extensions/team/index.ts +2 -1
- package/extensions/team/tools.ts +30 -9
- package/extensions/team/types.ts +17 -0
- package/extensions/todo/index.ts +4 -1
- package/extensions/todo/nudge.ts +8 -2
- package/extensions/todo/tui.ts +21 -3
- package/extensions/wechat/bridge.ts +43 -172
- package/extensions/wechat/intake.ts +245 -0
- package/extensions/worktree/commands.ts +24 -17
- package/extensions/worktree/exit.ts +12 -6
- package/extensions/worktree/git.ts +22 -14
- package/extensions/worktree/guard.ts +15 -285
- package/extensions/worktree/handoff.ts +10 -2
- package/extensions/worktree/index.ts +2 -1
- package/extensions/worktree/lifecycle.ts +2 -0
- package/extensions/worktree/registry.ts +20 -11
- package/extensions/worktree/root-tools.ts +13 -3
- package/extensions/worktree/runtime.ts +8 -2
- package/extensions/worktree/screen.ts +15 -8
- package/extensions/worktree/shell.ts +303 -0
- package/extensions/worktree/state.ts +1 -1
- package/extensions/worktree/status.ts +42 -9
- package/extensions/worktree/tools.ts +19 -13
- package/lib/README.md +9 -7
- package/lib/board/card.ts +192 -3
- package/lib/board/links.ts +26 -2
- package/lib/board/page-pointer.ts +111 -32
- package/lib/board/round-text.ts +11 -2
- package/lib/board/round.ts +25 -20
- package/lib/board/rules.ts +144 -11
- package/lib/board/session.ts +84 -18
- package/lib/board/store.ts +268 -55
- package/lib/board/view.ts +114 -24
- package/lib/bus-request.ts +83 -0
- package/lib/format.ts +22 -0
- package/lib/http/index.ts +11 -7
- package/lib/http/mount.ts +142 -0
- package/lib/http/port.ts +25 -0
- package/lib/http/serve.ts +170 -43
- package/lib/http/server.ts +3 -3
- package/lib/http/sink.ts +34 -9
- package/lib/plan-entry.ts +20 -13
- package/lib/policy.ts +14 -1
- package/lib/requirements.ts +2 -2
- package/lib/session-label.ts +23 -7
- package/lib/session-roster.ts +74 -3
- package/lib/session-tool.ts +37 -21
- package/lib/spawn.ts +37 -3
- package/lib/stray-fields.ts +45 -0
- package/lib/tool-names.ts +13 -0
- package/lib/tui/README.md +6 -3
- package/lib/tui/glyphs.ts +3 -6
- package/lib/tui/render-cache.ts +29 -0
- package/lib/tui/tool.ts +30 -1
- package/lib/web/README.md +52 -2
- package/lib/web/client-runtime.ts +149 -0
- package/lib/web/index.ts +4 -2
- package/lib/web/markup.ts +106 -2
- package/lib/web/names.ts +42 -0
- package/package.json +3 -2
- package/extensions/board/config.ts +0 -36
- package/extensions/board/view/actions.ts +0 -306
- package/extensions/board/writes.ts +0 -99
- package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
- package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
- package/extensions/job/process.ts +0 -35
package/lib/board/round.ts
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* know — nobody has touched it in half an hour — not that its owner's process is
|
|
9
9
|
* gone.
|
|
10
10
|
*
|
|
11
|
-
* The groups overlap on purpose. A `doing` card with no plan and no touch for
|
|
12
|
-
* hour is both `unplanned` and `idle`, and hiding it in one of the two would lose
|
|
11
|
+
* The groups overlap on purpose. A `doing` card with no plan and no touch for half
|
|
12
|
+
* an hour is both `unplanned` and `idle`, and hiding it in one of the two would lose
|
|
13
13
|
* half the reason a lead should look at it.
|
|
14
14
|
*
|
|
15
15
|
* Pure: no I/O and no clock of its own.
|
|
@@ -20,28 +20,33 @@ import type { Card } from "./card.ts";
|
|
|
20
20
|
/** How long a `doing` card may go untouched before the rollup calls it idle. */
|
|
21
21
|
export const IDLE_MS = 30 * 60 * 1_000;
|
|
22
22
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* One owner, the live cards it holds, and the ones it finished.
|
|
25
|
+
*
|
|
26
|
+
* `T` is what the caller handed in: the rollup never builds a card, so a snapshot whose
|
|
27
|
+
* cards carry derived facts hands them back on the same terms it received them.
|
|
28
|
+
*/
|
|
29
|
+
export interface RoundGroup<T extends Card = Card> {
|
|
25
30
|
label: string;
|
|
26
|
-
cards:
|
|
31
|
+
cards: T[];
|
|
27
32
|
/** This owner's finished cards: history, kept so a teammate who is done is not erased. */
|
|
28
|
-
done:
|
|
33
|
+
done: T[];
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
/** The lead's rollup of one board. */
|
|
32
|
-
export interface RoundSummary {
|
|
37
|
+
export interface RoundSummary<T extends Card = Card> {
|
|
33
38
|
/** Every owner with at least one unfinished card, ordered by label. */
|
|
34
|
-
owners: RoundGroup[];
|
|
39
|
+
owners: RoundGroup<T>[];
|
|
35
40
|
/** `todo` cards: nobody has taken them yet. */
|
|
36
|
-
unassigned:
|
|
41
|
+
unassigned: T[];
|
|
37
42
|
/** `doing` cards with no plan recorded: work started before it was planned. */
|
|
38
|
-
unplanned:
|
|
43
|
+
unplanned: T[];
|
|
39
44
|
/** `blocked` cards, and the note that says why. */
|
|
40
|
-
blocked:
|
|
45
|
+
blocked: T[];
|
|
41
46
|
/** `doing` cards untouched for {@link IDLE_MS}: possibly a dead owner. */
|
|
42
|
-
idle:
|
|
47
|
+
idle: T[];
|
|
43
48
|
/** Finished cards, oldest first, kept as history. */
|
|
44
|
-
done:
|
|
49
|
+
done: T[];
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
/**
|
|
@@ -55,13 +60,13 @@ export interface RoundSummary {
|
|
|
55
60
|
* whose labels differ only by case are one owner — a name is case-insensitive when
|
|
56
61
|
* it is a send target, so it is here too — and the first spelling seen is kept.
|
|
57
62
|
*/
|
|
58
|
-
export function buildRound(cards: readonly
|
|
59
|
-
const groups = new Map<string, RoundGroup
|
|
60
|
-
const unassigned:
|
|
61
|
-
const unplanned:
|
|
62
|
-
const blocked:
|
|
63
|
-
const idle:
|
|
64
|
-
const done:
|
|
63
|
+
export function buildRound<T extends Card = Card>(cards: readonly T[], now: number): RoundSummary<T> {
|
|
64
|
+
const groups = new Map<string, RoundGroup<T>>();
|
|
65
|
+
const unassigned: T[] = [];
|
|
66
|
+
const unplanned: T[] = [];
|
|
67
|
+
const blocked: T[] = [];
|
|
68
|
+
const idle: T[] = [];
|
|
69
|
+
const done: T[] = [];
|
|
65
70
|
|
|
66
71
|
for (const card of cards) {
|
|
67
72
|
if (card.status === "todo") unassigned.push(card);
|
package/lib/board/rules.ts
CHANGED
|
@@ -31,7 +31,9 @@ import {
|
|
|
31
31
|
cardRef,
|
|
32
32
|
DEFAULT_PRIORITY,
|
|
33
33
|
dependsOf,
|
|
34
|
+
MAX_LOG,
|
|
34
35
|
MAX_NOTES,
|
|
36
|
+
normalizeBlockedBy,
|
|
35
37
|
normalizeNote,
|
|
36
38
|
normalizePathHints,
|
|
37
39
|
normalizePlanPath,
|
|
@@ -57,7 +59,13 @@ export type CardOp =
|
|
|
57
59
|
*/
|
|
58
60
|
| { kind: "assign"; to: Actor; note?: unknown }
|
|
59
61
|
| { kind: "release" }
|
|
60
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Change lane. `blockedBy` says what a block waits on, and `landed` says which commit
|
|
64
|
+
* closed a card: both are read only by the lane they belong to, and both are optional,
|
|
65
|
+
* because a card may be blocked with nothing recorded and a `done` card may predate the
|
|
66
|
+
* field or live outside a repository.
|
|
67
|
+
*/
|
|
68
|
+
| { kind: "move"; status: Exclude<CardStatus, "todo">; blockedBy?: unknown; landed?: { commit: string } }
|
|
61
69
|
// `text` and `path` are whatever the model sent: `card.ts` validates them, and the
|
|
62
70
|
// refusal it produces is the one the caller shows.
|
|
63
71
|
| { kind: "note"; text: unknown }
|
|
@@ -66,6 +74,16 @@ export type CardOp =
|
|
|
66
74
|
// sends the three it can show, and omitting a link must not clear it.
|
|
67
75
|
| { kind: "edit"; title: unknown; paths: unknown; priority: unknown; dependsOn: unknown }
|
|
68
76
|
| { kind: "reclaim" }
|
|
77
|
+
/**
|
|
78
|
+
* Record that the card was offered to the team: `team offer` broadcasts it on the bus
|
|
79
|
+
* and then writes this, because an event nobody answers otherwise leaves no trace.
|
|
80
|
+
*/
|
|
81
|
+
| { kind: "offer" }
|
|
82
|
+
/**
|
|
83
|
+
* Set a finished card aside: the store moves its file out of the board, and the record
|
|
84
|
+
* itself is unchanged, because the pile its file lies in is the whole of the state.
|
|
85
|
+
*/
|
|
86
|
+
| { kind: "archive" }
|
|
69
87
|
| { kind: "drop" };
|
|
70
88
|
|
|
71
89
|
/** What an operation did: the new card, or the reason it was refused. */
|
|
@@ -89,6 +107,10 @@ export function createCard(
|
|
|
89
107
|
priority: input.priority ?? DEFAULT_PRIORITY,
|
|
90
108
|
notes: input.note === undefined ? [] : [{ by: actor.label, at: now, text: input.note }],
|
|
91
109
|
notesDropped: 0,
|
|
110
|
+
// A card is born in `todo`, and that is a transition like any other: a reader asking
|
|
111
|
+
// "how long has this been waiting" gets an answer rather than an absence.
|
|
112
|
+
log: [{ status: "todo", at: now, by: actor.label }],
|
|
113
|
+
logDropped: 0,
|
|
92
114
|
rev: 1,
|
|
93
115
|
createdAt: now,
|
|
94
116
|
updatedAt: now,
|
|
@@ -125,7 +147,7 @@ export function applyOp(card: Card, op: CardOp, actor: Actor, now: number, board
|
|
|
125
147
|
case "release":
|
|
126
148
|
return release(card, actor, now);
|
|
127
149
|
case "move":
|
|
128
|
-
return move(card, op
|
|
150
|
+
return move(card, op, actor, now, board);
|
|
129
151
|
case "note":
|
|
130
152
|
return note(card, op.text, actor, now);
|
|
131
153
|
case "plan":
|
|
@@ -134,6 +156,10 @@ export function applyOp(card: Card, op: CardOp, actor: Actor, now: number, board
|
|
|
134
156
|
return edit(card, op, actor, now, board);
|
|
135
157
|
case "reclaim":
|
|
136
158
|
return reclaim(card, actor, now);
|
|
159
|
+
case "offer":
|
|
160
|
+
return offer(card, actor, now, board);
|
|
161
|
+
case "archive":
|
|
162
|
+
return archive(card);
|
|
137
163
|
case "drop":
|
|
138
164
|
return drop(card, actor, board);
|
|
139
165
|
}
|
|
@@ -145,7 +171,11 @@ function claim(card: Card, actor: Actor, now: number, board: BoardIndex): OpResu
|
|
|
145
171
|
if (card.owner !== undefined) return { ok: false, error: heldBy(card) };
|
|
146
172
|
const waiting = waitingOn(card, board);
|
|
147
173
|
if (waiting !== undefined) return { ok: false, error: waiting };
|
|
148
|
-
return {
|
|
174
|
+
return {
|
|
175
|
+
ok: true,
|
|
176
|
+
card: write(card, actor, now, { status: "doing", owner: ownerOf(actor), offeredAt: undefined }),
|
|
177
|
+
changed: true,
|
|
178
|
+
};
|
|
149
179
|
}
|
|
150
180
|
|
|
151
181
|
function assign(card: Card, to: Actor, rawNote: unknown, actor: Actor, now: number, board: BoardIndex): OpResult {
|
|
@@ -154,7 +184,7 @@ function assign(card: Card, to: Actor, rawNote: unknown, actor: Actor, now: numb
|
|
|
154
184
|
if (holds(card, to) && card.status === "doing") return { ok: true, card, changed: false };
|
|
155
185
|
const waiting = waitingOn(card, board);
|
|
156
186
|
if (waiting !== undefined) return { ok: false, error: waiting };
|
|
157
|
-
const fields: Partial<Card> = { status: "doing", owner: ownerOf(to) };
|
|
187
|
+
const fields: Partial<Card> = { status: "doing", owner: ownerOf(to), offeredAt: undefined };
|
|
158
188
|
if (rawNote !== undefined) {
|
|
159
189
|
const parsed = normalizeNote(rawNote);
|
|
160
190
|
if ("error" in parsed) return { ok: false, error: parsed.error };
|
|
@@ -172,23 +202,66 @@ function release(card: Card, actor: Actor, now: number): OpResult {
|
|
|
172
202
|
if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; it cannot be released.` };
|
|
173
203
|
if (card.owner === undefined) return { ok: false, error: `${cardRef(card.id)} is not claimed.` };
|
|
174
204
|
if (!holds(card, actor)) return { ok: false, error: `${heldBy(card)} Release is the owner's decision.` };
|
|
175
|
-
return {
|
|
205
|
+
return {
|
|
206
|
+
ok: true,
|
|
207
|
+
card: write(card, actor, now, { status: "todo", owner: undefined, offeredAt: undefined }),
|
|
208
|
+
changed: true,
|
|
209
|
+
};
|
|
176
210
|
}
|
|
177
211
|
|
|
178
|
-
function move(
|
|
212
|
+
function move(
|
|
213
|
+
card: Card,
|
|
214
|
+
op: Extract<CardOp, { kind: "move" }>,
|
|
215
|
+
actor: Actor,
|
|
216
|
+
now: number,
|
|
217
|
+
board: BoardIndex,
|
|
218
|
+
): OpResult {
|
|
219
|
+
const status = op.status;
|
|
179
220
|
if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; it cannot be moved.` };
|
|
180
221
|
if (card.owner === undefined) {
|
|
181
222
|
return { ok: false, error: `${cardRef(card.id)} is unclaimed; claim it before moving it to ${status}.` };
|
|
182
223
|
}
|
|
183
224
|
if (!holds(card, actor)) return { ok: false, error: `${heldBy(card)} Only its owner can move it.` };
|
|
184
|
-
|
|
225
|
+
// A block's reason is checked before the no-op: a caller that repeats a move with a new
|
|
226
|
+
// sentence meant to change it, and a refusal is clearer than a silent nothing.
|
|
227
|
+
let blockedBy: string | undefined;
|
|
228
|
+
if (status === "blocked" && op.blockedBy !== undefined) {
|
|
229
|
+
const parsed = normalizeBlockedBy(op.blockedBy);
|
|
230
|
+
if ("error" in parsed) return { ok: false, error: parsed.error };
|
|
231
|
+
blockedBy = parsed.blockedBy;
|
|
232
|
+
}
|
|
233
|
+
if (card.status === status && blockedBy === undefined) return { ok: true, card, changed: false };
|
|
185
234
|
// Parking is what a link is for, so only the two lanes that put a card back in flight
|
|
186
235
|
// are gated: a card that waits may be blocked, it may not be taken or finished.
|
|
187
236
|
if (status !== "blocked") {
|
|
188
237
|
const waiting = waitingOn(card, board);
|
|
189
238
|
if (waiting !== undefined) return { ok: false, error: waiting };
|
|
190
239
|
}
|
|
191
|
-
return { ok: true, card: write(card, actor, now,
|
|
240
|
+
return { ok: true, card: write(card, actor, now, laneFields(status, blockedBy, op, now)), changed: true };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The fields one lane change writes.
|
|
245
|
+
*
|
|
246
|
+
* `blockedBy` belongs to `blocked` alone: a card that leaves for `doing` or `done` has
|
|
247
|
+
* had its decision or stopped needing one, and a stale sentence would be read as a
|
|
248
|
+
* current one. `landed` belongs to `done` alone, and only when the caller could name a
|
|
249
|
+
* commit — a board outside git still finishes cards, it just cannot say with what.
|
|
250
|
+
*/
|
|
251
|
+
function laneFields(
|
|
252
|
+
status: Exclude<CardStatus, "todo">,
|
|
253
|
+
blockedBy: string | undefined,
|
|
254
|
+
op: Extract<CardOp, { kind: "move" }>,
|
|
255
|
+
now: number,
|
|
256
|
+
): Partial<Card> {
|
|
257
|
+
const fields: Partial<Card> = { status };
|
|
258
|
+
if (status === "blocked") {
|
|
259
|
+
if (blockedBy !== undefined) fields.blockedBy = blockedBy;
|
|
260
|
+
} else {
|
|
261
|
+
fields.blockedBy = undefined;
|
|
262
|
+
}
|
|
263
|
+
if (status === "done" && op.landed !== undefined) fields.landed = { commit: op.landed.commit, at: now };
|
|
264
|
+
return fields;
|
|
192
265
|
}
|
|
193
266
|
|
|
194
267
|
function note(card: Card, raw: unknown, actor: Actor, now: number): OpResult {
|
|
@@ -207,6 +280,16 @@ function appendNote(card: Card, actor: Actor, now: number, text: string): Pick<C
|
|
|
207
280
|
};
|
|
208
281
|
}
|
|
209
282
|
|
|
283
|
+
/** A card's log after one more transition, with the oldest dropped and counted. */
|
|
284
|
+
function appendTransition(card: Card, status: CardStatus, actor: Actor, now: number): Pick<Card, "log" | "logDropped"> {
|
|
285
|
+
const log = [...(card.log ?? []), { status, at: now, by: actor.label }];
|
|
286
|
+
const overflow = Math.max(0, log.length - MAX_LOG);
|
|
287
|
+
return {
|
|
288
|
+
log: overflow === 0 ? log : log.slice(overflow),
|
|
289
|
+
logDropped: (card.logDropped ?? 0) + overflow,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
210
293
|
function plan(card: Card, raw: unknown, actor: Actor, now: number): OpResult {
|
|
211
294
|
if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; its plan cannot change.` };
|
|
212
295
|
if (!holds(card, actor)) return { ok: false, error: `${heldBy(card)} Only its owner records a plan.` };
|
|
@@ -262,10 +345,45 @@ function edit(
|
|
|
262
345
|
return { ok: true, card: write(card, actor, now, fields), changed: true };
|
|
263
346
|
}
|
|
264
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Record that the card was offered to the team, and nobody has taken it.
|
|
350
|
+
*
|
|
351
|
+
* The same preconditions a claim would meet, because the offer asks a peer to claim: a
|
|
352
|
+
* held card, a finished one, or one waiting on unfinished work is refused before anything
|
|
353
|
+
* is broadcast. Re-offering refreshes the age rather than refusing as a no-op — a lead
|
|
354
|
+
* who offers again is asking again, and the age is the record of when they last did.
|
|
355
|
+
*/
|
|
356
|
+
function offer(card: Card, actor: Actor, now: number, board: BoardIndex): OpResult {
|
|
357
|
+
if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; it cannot be offered.` };
|
|
358
|
+
if (card.owner !== undefined) return { ok: false, error: heldBy(card) };
|
|
359
|
+
const waiting = waitingOn(card, board);
|
|
360
|
+
if (waiting !== undefined) return { ok: false, error: waiting };
|
|
361
|
+
return { ok: true, card: write(card, actor, now, { offeredAt: now }), changed: true };
|
|
362
|
+
}
|
|
363
|
+
|
|
265
364
|
function reclaim(card: Card, actor: Actor, now: number): OpResult {
|
|
266
365
|
if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; there is nothing to reclaim.` };
|
|
267
366
|
if (card.owner === undefined) return { ok: false, error: `${cardRef(card.id)} has no owner to reclaim.` };
|
|
268
|
-
return {
|
|
367
|
+
return {
|
|
368
|
+
ok: true,
|
|
369
|
+
card: write(card, actor, now, { status: "todo", owner: undefined, offeredAt: undefined }),
|
|
370
|
+
changed: true,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Set a finished card aside, out of the board and into the archive beside it.
|
|
376
|
+
*
|
|
377
|
+
* Only a finished card leaves this way: `done` is terminal, and a card still in flight
|
|
378
|
+
* has an owner, a plan, and a decision in it. No field moves and no transition is
|
|
379
|
+
* appended — the record is unchanged, and what changed is which pile its file lies in,
|
|
380
|
+
* which is the store's write. `changed` is still true, because the board lost a card.
|
|
381
|
+
*/
|
|
382
|
+
function archive(card: Card): OpResult {
|
|
383
|
+
if (card.status !== "done") {
|
|
384
|
+
return { ok: false, error: `${cardRef(card.id)} is in ${card.status}; only a finished card can be archived.` };
|
|
385
|
+
}
|
|
386
|
+
return { ok: true, card, changed: true };
|
|
269
387
|
}
|
|
270
388
|
|
|
271
389
|
function drop(card: Card, actor: Actor, board: BoardIndex): OpResult {
|
|
@@ -283,13 +401,28 @@ function sameList(left: readonly string[], right: readonly string[]): boolean {
|
|
|
283
401
|
return left.length === right.length && left.every((entry, index) => entry === right[index]);
|
|
284
402
|
}
|
|
285
403
|
|
|
286
|
-
/**
|
|
404
|
+
/**
|
|
405
|
+
* A card with `fields` applied and its write marks moved.
|
|
406
|
+
*
|
|
407
|
+
* This is the one place a lane change is observed, because it is the one place every
|
|
408
|
+
* transition ends: a note, a plan, or an edit writes fields and leaves the lane alone,
|
|
409
|
+
* while a claim, an assign, a release, a reclaim, and a move all name the new one. So
|
|
410
|
+
* the log is appended from here rather than from each operation, and an operation added
|
|
411
|
+
* later cannot forget it.
|
|
412
|
+
*/
|
|
287
413
|
function write(card: Card, actor: Actor, now: number, fields: Partial<Card>): Card {
|
|
288
|
-
const
|
|
414
|
+
const status = fields.status;
|
|
415
|
+
const transition =
|
|
416
|
+
status !== undefined && status !== card.status ? appendTransition(card, status, actor, now) : undefined;
|
|
417
|
+
const next: Card = { ...card, ...fields, ...transition, rev: card.rev + 1, updatedAt: now, by: actor.label };
|
|
289
418
|
// `owner: undefined` must clear the field rather than store a key holding it, so a
|
|
290
419
|
// released card reads back exactly like one that was never claimed. Only an
|
|
291
420
|
// explicit `owner` in `fields` may delete it: every other write keeps the holder.
|
|
292
421
|
if ("owner" in fields && fields.owner === undefined) delete next.owner;
|
|
422
|
+
// The same for the block: a card that left `blocked` has no reason, and a key holding
|
|
423
|
+
// undefined would be one more shape for a reader to know about.
|
|
424
|
+
if ("blockedBy" in fields && fields.blockedBy === undefined) delete next.blockedBy;
|
|
425
|
+
if ("offeredAt" in fields && fields.offeredAt === undefined) delete next.offeredAt;
|
|
293
426
|
return next;
|
|
294
427
|
}
|
|
295
428
|
|
package/lib/board/session.ts
CHANGED
|
@@ -3,9 +3,14 @@
|
|
|
3
3
|
*
|
|
4
4
|
* One store per working directory, opened on first use and kept for the session:
|
|
5
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
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* answer is remembered per `cwd`. The identity is the same pair a card records — the
|
|
7
|
+
* session id Pi knows, and the team name derived from it — which is why the board can
|
|
8
|
+
* name its owner without reading the bus, and why the name survives a rename.
|
|
9
|
+
*
|
|
10
|
+
* One session per *host*, not one per extension: Pi loads `board` and `team` with
|
|
11
|
+
* separate module caches, so two sessions would mean the seat `/board` sets is
|
|
12
|
+
* invisible to every write `team` makes, and one session would name its own cards
|
|
13
|
+
* two ways.
|
|
9
14
|
*
|
|
10
15
|
* The store and its cache live in this closure, not in a module variable: Pi loads
|
|
11
16
|
* each extension with an isolated module cache, so module state would be one copy
|
|
@@ -15,16 +20,39 @@
|
|
|
15
20
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
21
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
17
22
|
import { basename, join } from "node:path";
|
|
18
|
-
import { defaultLabel, sessionNameOf } from "../session-label.ts";
|
|
23
|
+
import { agentLabel, defaultLabel, sessionNameOf } from "../session-label.ts";
|
|
19
24
|
import { BUS_DIR_NAME } from "../session-roster.ts";
|
|
25
|
+
import type { JoinResult } from "../session-tool.ts";
|
|
20
26
|
import type { Actor } from "./rules.ts";
|
|
21
27
|
import { type BoardStore, openBoard } from "./store.ts";
|
|
22
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Where this session stands on the team, as the one place that keeps it.
|
|
31
|
+
*
|
|
32
|
+
* Two surfaces ask: `board` when it joins on the page or on a claim, and `team` for
|
|
33
|
+
* `/team join`. It is a standing rather than a flag because both need to *say* what
|
|
34
|
+
* happened — `/board status` names the label and whether mail is listened for — and
|
|
35
|
+
* because a failure has to be asked again while a success must not be. A failure is
|
|
36
|
+
* kept so a line can say why, and `ok` is what a caller reads to decide.
|
|
37
|
+
*/
|
|
38
|
+
export interface TeamStanding {
|
|
39
|
+
/** What the last join did, or undefined when this session has not asked. */
|
|
40
|
+
last(): JoinResult | undefined;
|
|
41
|
+
/** Record what an attempt did; a failed one is kept, and asked again. */
|
|
42
|
+
record(result: JoinResult): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
23
45
|
/** What a tool, a page, and a round need from the session, without any of them owning it. */
|
|
24
46
|
export interface BoardSession {
|
|
25
47
|
/** The board for `cwd`'s repository. */
|
|
26
48
|
store(cwd: string): Promise<BoardStore>;
|
|
27
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* The session a write is made as: its team name, and the id a card keeps.
|
|
51
|
+
*
|
|
52
|
+
* The label is the seat while this session serves the page, else `team-agent-<id>`,
|
|
53
|
+
* else — on a host that reports no id — the readable chain. One name on every
|
|
54
|
+
* surface is the point: the card, the bus record, and the roster say the same word.
|
|
55
|
+
*/
|
|
28
56
|
who(ctx: ExtensionContext, cwd: string): Actor;
|
|
29
57
|
/** The clock, so a test drives a deadline instead of waiting on one. */
|
|
30
58
|
now(): number;
|
|
@@ -36,12 +64,23 @@ export interface BoardSession {
|
|
|
36
64
|
* with `session-bus` unloaded.
|
|
37
65
|
*/
|
|
38
66
|
busDir(cwd: string): Promise<string>;
|
|
39
|
-
/**
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
|
|
67
|
+
/** The seat this session serves the page as, or undefined when it is not serving. */
|
|
68
|
+
serving(): string | undefined;
|
|
69
|
+
/** Serve the page under `seat`, or stand it down when the page stops. */
|
|
70
|
+
serve(seat: string | undefined): void;
|
|
71
|
+
/** Where this session stands on the team, so a claim joins once and status can say. */
|
|
72
|
+
team: TeamStanding;
|
|
43
73
|
}
|
|
44
74
|
|
|
75
|
+
/**
|
|
76
|
+
* The part of a session a command needs: what it is called, and where it stands.
|
|
77
|
+
*
|
|
78
|
+
* `/board` joins, sets the seat, and reports the standing; it never opens a store or
|
|
79
|
+
* reads the bus, so a command takes this much and no more. A test drives it with four
|
|
80
|
+
* stand-ins instead of a whole session with a temp agent directory.
|
|
81
|
+
*/
|
|
82
|
+
export type BoardTeam = Pick<BoardSession, "who" | "serving" | "serve" | "team">;
|
|
83
|
+
|
|
45
84
|
/** Test seams and the one place a caller may redirect the store. */
|
|
46
85
|
export interface BoardSessionOptions {
|
|
47
86
|
now?: () => number;
|
|
@@ -49,16 +88,32 @@ export interface BoardSessionOptions {
|
|
|
49
88
|
}
|
|
50
89
|
|
|
51
90
|
/**
|
|
52
|
-
*
|
|
91
|
+
* The key one host's session is kept under.
|
|
53
92
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
93
|
+
* `Symbol.for` is the global registry, so the copy of this module in `board`'s cache
|
|
94
|
+
* and the copy in `team`'s agree on the key — a plain `Symbol()` would be two keys on
|
|
95
|
+
* one host, which is the drift this reuse exists to stop.
|
|
96
|
+
*/
|
|
97
|
+
const SESSION_KEY = Symbol.for("@gavin-hu/my-pi-agent/board-session");
|
|
98
|
+
|
|
99
|
+
/** A host with this module's key on it. */
|
|
100
|
+
type BoardHost = ExtensionAPI & { [SESSION_KEY]?: BoardSession };
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The session around one host, built on the first call and shared after it.
|
|
104
|
+
*
|
|
105
|
+
* `board` and `team` both work on the same store and must name a write as the same
|
|
106
|
+
* session, so the first caller's options are the ones built: a second extension joins
|
|
107
|
+
* the session that exists rather than opening a second one beside it.
|
|
57
108
|
*/
|
|
58
109
|
export function openBoardSession(pi: ExtensionAPI, options: BoardSessionOptions = {}): BoardSession {
|
|
110
|
+
const host = pi as BoardHost;
|
|
111
|
+
const shared = host[SESSION_KEY];
|
|
112
|
+
if (shared !== undefined) return shared;
|
|
59
113
|
const stores = new Map<string, Promise<BoardStore>>();
|
|
60
114
|
const busDirs = new Map<string, Promise<string>>();
|
|
61
|
-
let
|
|
115
|
+
let seat: string | undefined;
|
|
116
|
+
let standing: JoinResult | undefined;
|
|
62
117
|
const agentDir = options.agentDir;
|
|
63
118
|
const session: BoardSession = {
|
|
64
119
|
store(cwd) {
|
|
@@ -70,7 +125,11 @@ export function openBoardSession(pi: ExtensionAPI, options: BoardSessionOptions
|
|
|
70
125
|
},
|
|
71
126
|
who(ctx, cwd) {
|
|
72
127
|
const id = sessionIdOf(ctx);
|
|
73
|
-
|
|
128
|
+
// The seat is a role and the agent name is derived from the id, so neither moves
|
|
129
|
+
// when a user renames the session — which is what keeps a card's owner readable
|
|
130
|
+
// against the roster a year later. A host that reports no id has nothing to derive
|
|
131
|
+
// from, so it keeps the bus's own readable chain.
|
|
132
|
+
const label = seat ?? (id === undefined ? defaultLabel(sessionNameOf(ctx), cwd) : agentLabel(id));
|
|
74
133
|
return id === undefined ? { label } : { id, label };
|
|
75
134
|
},
|
|
76
135
|
now: options.now ?? Date.now,
|
|
@@ -86,11 +145,18 @@ export function openBoardSession(pi: ExtensionAPI, options: BoardSessionOptions
|
|
|
86
145
|
busDirs.set(cwd, resolved);
|
|
87
146
|
return resolved;
|
|
88
147
|
},
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
148
|
+
serving: () => seat,
|
|
149
|
+
serve: (label) => {
|
|
150
|
+
seat = label;
|
|
151
|
+
},
|
|
152
|
+
team: {
|
|
153
|
+
last: () => standing,
|
|
154
|
+
record: (result) => {
|
|
155
|
+
standing = result;
|
|
156
|
+
},
|
|
92
157
|
},
|
|
93
158
|
};
|
|
159
|
+
host[SESSION_KEY] = session;
|
|
94
160
|
return session;
|
|
95
161
|
}
|
|
96
162
|
|