@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/extensions/board/seat.ts
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The seat: the name the page serves as, and the actor
|
|
2
|
+
* The seat: the name the page serves as, and the actor it is shown as.
|
|
3
3
|
*
|
|
4
4
|
* A page outlives the session that opened it — a `/resume`, a rename, or a fresh
|
|
5
|
-
* session taking the page over would otherwise change who
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* same cards belong to it however many sessions have served it.
|
|
5
|
+
* session taking the page over would otherwise change who the page says it is. So the
|
|
6
|
+
* page is a *seat* rather than a session: one fixed name, one fixed id derived from it,
|
|
7
|
+
* and the same identity however many sessions have served it.
|
|
9
8
|
*
|
|
10
|
-
* The
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* The page writes nothing, so the id decides no ownership: it is what the page's own
|
|
10
|
+
* roster and the `you` chip are read against, and it keeps a page's reads the same
|
|
11
|
+
* across a handover. The board's tools write as the session — which is why the label is
|
|
12
|
+
* shared with the session serving the page and the ids are not.
|
|
14
13
|
*/
|
|
15
14
|
|
|
16
15
|
import type { Actor } from "../../lib/board/rules.ts";
|
|
@@ -22,7 +21,7 @@ export const DEFAULT_SEAT = "team-lead";
|
|
|
22
21
|
/** The prefix of a page's actor id, so one is never mistaken for a session id. */
|
|
23
22
|
export const SEAT_ID_PREFIX = "board:";
|
|
24
23
|
|
|
25
|
-
/** The actor a page
|
|
24
|
+
/** The actor a page reads as: the seat, and an id derived from it. */
|
|
26
25
|
export function pageActor(label: string = DEFAULT_SEAT): Actor {
|
|
27
26
|
const seat = normalizeLabel(label, DEFAULT_SEAT);
|
|
28
27
|
return { id: `${SEAT_ID_PREFIX}${seat.toLowerCase()}`, label: seat };
|
|
@@ -18,8 +18,6 @@ export interface SnapshotContext {
|
|
|
18
18
|
store: BoardStore;
|
|
19
19
|
/** The actor every request is made and reported as: the page's seat. */
|
|
20
20
|
actor: () => Actor;
|
|
21
|
-
/** The session serving the page, which is who is reading, not who it writes as. */
|
|
22
|
-
session?: () => Actor;
|
|
23
21
|
now: () => number;
|
|
24
22
|
/** Where this repository's bus files its records. */
|
|
25
23
|
busDir: () => Promise<string>;
|
|
@@ -28,13 +26,17 @@ export interface SnapshotContext {
|
|
|
28
26
|
/** The snapshot the page renders, and the body of `/api/board`. */
|
|
29
27
|
export async function viewOf(context: SnapshotContext): Promise<BoardView> {
|
|
30
28
|
const roster = await readRoster(await context.busDir());
|
|
31
|
-
|
|
29
|
+
// One read of the pile answers both: a frame that asked `list` and `skipped` separately
|
|
30
|
+
// would parse every card twice, once a second, for as long as a tab is open.
|
|
31
|
+
const { cards, skipped } = await context.store.scan();
|
|
32
32
|
return buildView({
|
|
33
33
|
me: context.actor(),
|
|
34
|
-
|
|
35
|
-
cards: await context.store.list(),
|
|
34
|
+
cards,
|
|
36
35
|
now: context.now(),
|
|
37
36
|
bytes: await context.store.bytes(),
|
|
37
|
+
skipped: skipped.length,
|
|
38
|
+
repo: context.store.name(),
|
|
39
|
+
root: context.store.root(),
|
|
38
40
|
where: context.store.where(),
|
|
39
41
|
...(roster === undefined ? {} : { roster }),
|
|
40
42
|
});
|
|
@@ -14,23 +14,48 @@
|
|
|
14
14
|
|
|
15
15
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import type { Static } from "typebox";
|
|
17
|
-
import { cardRef, matchesQuery, normalizeCardId } from "../../lib/board/card.ts";
|
|
17
|
+
import { type Card, cardRef, matchesQuery, normalizeCardId } from "../../lib/board/card.ts";
|
|
18
18
|
import { indexOf } from "../../lib/board/links.ts";
|
|
19
19
|
import { type CardOp, holds } from "../../lib/board/rules.ts";
|
|
20
20
|
import { buildRound } from "../../lib/board/round.ts";
|
|
21
21
|
import type { BoardStore } from "../../lib/board/store.ts";
|
|
22
22
|
import { messageOf } from "../../lib/errors.ts";
|
|
23
|
+
import { revParse } from "../../lib/git/read.ts";
|
|
24
|
+
import { strayField } from "../../lib/stray-fields.ts";
|
|
25
|
+
import { createExecRunner } from "../../lib/git/runner.ts";
|
|
23
26
|
import { failedResult, textResult } from "../../lib/tool-result.ts";
|
|
24
27
|
import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
|
|
25
|
-
import { callLine, detailsOrLine } from "../../lib/tui/tool.ts";
|
|
28
|
+
import { callLine, detailsOrLine, isTailResult, TailResult } from "../../lib/tui/tool.ts";
|
|
26
29
|
import { formatRound } from "../../lib/board/round-text.ts";
|
|
27
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
archiveNote,
|
|
32
|
+
formatArchive,
|
|
33
|
+
formatArchived,
|
|
34
|
+
formatBoard,
|
|
35
|
+
formatCard,
|
|
36
|
+
formatDropped,
|
|
37
|
+
formatSkipped,
|
|
38
|
+
} from "./format.ts";
|
|
28
39
|
import { createTeamJoin, type TeamJoin } from "../../lib/session-tool.ts";
|
|
29
|
-
import {
|
|
40
|
+
import { joinOnClaim } from "./join.ts";
|
|
41
|
+
import { collapsedLine, resultInput } from "./result.ts";
|
|
30
42
|
import { createInput } from "./input.ts";
|
|
31
43
|
import type { BoardSession } from "../../lib/board/session.ts";
|
|
32
44
|
import { BoardParams } from "./schema.ts";
|
|
33
|
-
import {
|
|
45
|
+
import {
|
|
46
|
+
ACTION_FIELDS,
|
|
47
|
+
BOARD_ACTION_DOCS,
|
|
48
|
+
BOARD_ACTIONS,
|
|
49
|
+
BOARD_TOOL,
|
|
50
|
+
type BoardAction,
|
|
51
|
+
type BoardDetails,
|
|
52
|
+
normalizeBoardAction,
|
|
53
|
+
} from "./types.ts";
|
|
54
|
+
|
|
55
|
+
/** The verbs as one sentence, in `BOARD_ACTIONS` order; the table owns the words. */
|
|
56
|
+
function verbHelp(): string {
|
|
57
|
+
return `${BOARD_ACTIONS.map((action) => `\`${action}\` ${BOARD_ACTION_DOCS[action]}`).join("; ")}.`;
|
|
58
|
+
}
|
|
34
59
|
|
|
35
60
|
/** The parameters as the schema validates them. */
|
|
36
61
|
type BoardArgs = Static<typeof BoardParams>;
|
|
@@ -71,8 +96,29 @@ function refusal(action: BoardAction, me: BoardDetails["me"], message: string, c
|
|
|
71
96
|
});
|
|
72
97
|
}
|
|
73
98
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Whether a result this tool built is a refusal.
|
|
101
|
+
*
|
|
102
|
+
* `refusal` marks one with `isError`, and the success shape (`ToolResult`) has no such
|
|
103
|
+
* field, so this is the one probe that tells the two apart — read here rather than
|
|
104
|
+
* widening the shared envelope with a flag that would then be optional everywhere.
|
|
105
|
+
*/
|
|
106
|
+
function refused(result: unknown): boolean {
|
|
107
|
+
return typeof result === "object" && result !== null && (result as { isError?: unknown }).isError === true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The commit at `cwd`'s `HEAD`, shortened, or nothing when there is none.
|
|
112
|
+
*
|
|
113
|
+
* A finished card records what closed it, and the commit is the one fact a repository can
|
|
114
|
+
* answer that the card cannot. A directory that is not a repository, or one with no
|
|
115
|
+
* commits, answers nothing — the move still happens, because finishing a card must not
|
|
116
|
+
* depend on git.
|
|
117
|
+
*/
|
|
118
|
+
async function headCommit(pi: ExtensionAPI, cwd: string): Promise<{ commit: string } | undefined> {
|
|
119
|
+
const id = await revParse(createExecRunner(pi), cwd, "HEAD");
|
|
120
|
+
return id === undefined ? undefined : { commit: id.slice(0, 7) };
|
|
121
|
+
}
|
|
76
122
|
|
|
77
123
|
export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { team?: TeamJoin } = {}): void {
|
|
78
124
|
const team = deps.team ?? createTeamJoin(pi);
|
|
@@ -82,16 +128,9 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
|
|
|
82
128
|
description:
|
|
83
129
|
"The shared feature list for this repository: one card per feature, which any session reads and writes. " +
|
|
84
130
|
"A card carries the feature's outcome, the session that holds it, path hints, the plan it is executing, " +
|
|
85
|
-
|
|
86
|
-
"
|
|
87
|
-
|
|
88
|
-
"creates a `todo` card; `claim` takes one for this session; `release` gives it back; `reclaim` takes it " +
|
|
89
|
-
"from whoever holds it; `move` changes lane; `note` appends to its history; `plan` records the plan file " +
|
|
90
|
-
"being executed; `edit` fixes what the card says (title, paths, priority, and the cards it waits for); " +
|
|
91
|
-
"`drop` deletes a `todo`, a `done`, or one you hold. A card's steps do not belong on the board - they " +
|
|
92
|
-
"belong in the plan and in `todo`.",
|
|
93
|
-
promptSnippet:
|
|
94
|
-
"The shared feature list for this repository: list, add, claim, release, reclaim, move, note, plan, edit, drop.",
|
|
131
|
+
`its priority, what it waits for, and its notes. ${verbHelp()} A card's steps do not belong on the ` +
|
|
132
|
+
"board - they belong in the plan and in `todo`.",
|
|
133
|
+
promptSnippet: `The shared feature list for this repository: ${BOARD_ACTIONS.join(", ")}.`,
|
|
95
134
|
promptGuidelines: [
|
|
96
135
|
"Claim a card before working on it; release or finish it when you stop, and reclaim a card whose owner is gone.",
|
|
97
136
|
"Record the plan file with `board` action `plan` once the plan is approved, then execute it.",
|
|
@@ -117,13 +156,10 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
|
|
|
117
156
|
}
|
|
118
157
|
const me = runtime.who(ctx, ctx.cwd);
|
|
119
158
|
try {
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (MUTATING_ACTIONS.has(action) && !runtime.joined()) {
|
|
125
|
-
if ((await team.join(ctx)).ok) runtime.markJoined();
|
|
126
|
-
}
|
|
159
|
+
// Before the join and the store: a refused call has done nothing, so it cannot
|
|
160
|
+
// have enlisted this session as a teammate on its way to the refusal.
|
|
161
|
+
const stray = strayField(action, args, ACTION_FIELDS);
|
|
162
|
+
if (stray !== undefined) return refusal(action, me, stray);
|
|
127
163
|
const store = await runtime.store(ctx.cwd);
|
|
128
164
|
const now = runtime.now();
|
|
129
165
|
switch (action) {
|
|
@@ -131,8 +167,16 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
|
|
|
131
167
|
return await read(store, action, me, args, now);
|
|
132
168
|
case "add":
|
|
133
169
|
return await add(store, me, args, now);
|
|
134
|
-
case "claim":
|
|
135
|
-
|
|
170
|
+
case "claim": {
|
|
171
|
+
const claimed = await mutate(store, action, me, now, args, { kind: "claim" });
|
|
172
|
+
// Taking a card is what makes this session a teammate. It is the one action here
|
|
173
|
+
// worth being addressed about, and the board is the user's surface for the team, so
|
|
174
|
+
// nobody has to know the bus exists. A refused claim wrote nothing and enlists
|
|
175
|
+
// nobody; a refused join is not a failed claim, so the card stands and the next
|
|
176
|
+
// claim asks again.
|
|
177
|
+
if (!refused(claimed)) await joinOnClaim(runtime.team, team, ctx, me.label);
|
|
178
|
+
return claimed;
|
|
179
|
+
}
|
|
136
180
|
case "release":
|
|
137
181
|
return await mutate(store, action, me, now, args, { kind: "release" });
|
|
138
182
|
case "reclaim":
|
|
@@ -147,7 +191,16 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
|
|
|
147
191
|
'move needs a target lane: "doing", "blocked", or "done". Use release to return a card to todo.',
|
|
148
192
|
);
|
|
149
193
|
}
|
|
150
|
-
|
|
194
|
+
// Only the lane that has something to say reads for it: a move to `done`
|
|
195
|
+
// records the commit that closed the card, and a board outside a
|
|
196
|
+
// repository simply has none to record.
|
|
197
|
+
const landed = status === "done" ? await headCommit(pi, ctx.cwd) : undefined;
|
|
198
|
+
return await mutate(store, action, me, now, args, {
|
|
199
|
+
kind: "move",
|
|
200
|
+
status,
|
|
201
|
+
...(args.blockedBy === undefined ? {} : { blockedBy: args.blockedBy }),
|
|
202
|
+
...(landed === undefined ? {} : { landed }),
|
|
203
|
+
});
|
|
151
204
|
}
|
|
152
205
|
case "note":
|
|
153
206
|
return await mutate(store, action, me, now, args, { kind: "note", text: args.text });
|
|
@@ -172,6 +225,8 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
|
|
|
172
225
|
}
|
|
173
226
|
case "drop":
|
|
174
227
|
return await dropCard(store, me, args, now);
|
|
228
|
+
case "archive":
|
|
229
|
+
return await archiveCards(store, me, args, now);
|
|
175
230
|
}
|
|
176
231
|
} catch (error) {
|
|
177
232
|
return refusal(action, me, messageOf(error));
|
|
@@ -196,15 +251,15 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
|
|
|
196
251
|
text.setText(collapsedLine(theme, outcome.details));
|
|
197
252
|
return text;
|
|
198
253
|
}
|
|
199
|
-
const
|
|
200
|
-
|
|
254
|
+
const previous = context?.lastComponent;
|
|
255
|
+
const view = isTailResult<Card>(previous) ? previous : new TailResult<Card>(input, theme);
|
|
201
256
|
view.setInput(input, theme);
|
|
202
257
|
return view;
|
|
203
258
|
},
|
|
204
259
|
});
|
|
205
260
|
}
|
|
206
261
|
|
|
207
|
-
/** `list`: the board, one card,
|
|
262
|
+
/** `list`: the board, one card, the lead's rollup, or the archive. */
|
|
208
263
|
async function read(
|
|
209
264
|
store: BoardStore,
|
|
210
265
|
action: BoardAction,
|
|
@@ -212,13 +267,32 @@ async function read(
|
|
|
212
267
|
args: BoardArgs,
|
|
213
268
|
now: number,
|
|
214
269
|
): Promise<ReturnType<typeof textResult<BoardDetails>>> {
|
|
270
|
+
if (args.archived === true) return readArchive(store, action, me, args, now);
|
|
215
271
|
const cards = await store.list();
|
|
272
|
+
// Read once: a card file the reader refused is the difference between "not there"
|
|
273
|
+
// and "there, and this build cannot read it".
|
|
274
|
+
const skipped = await store.skipped();
|
|
275
|
+
// A directory listing, not a read: a listing says what it left aside without opening one.
|
|
276
|
+
const archived = await store.archivedCount();
|
|
216
277
|
if (args.card !== undefined) {
|
|
217
278
|
const id = normalizeCardId(args.card);
|
|
218
279
|
if (id === undefined) return refusal(action, me, `"${args.card}" is not a card id.`);
|
|
219
280
|
const card = cards.find((entry) => entry.id === id);
|
|
220
|
-
if (!card)
|
|
221
|
-
|
|
281
|
+
if (!card) {
|
|
282
|
+
// Gone and set aside are different answers, and one read by id tells them apart.
|
|
283
|
+
if ((await store.read(id)) !== undefined) {
|
|
284
|
+
return refusal(action, me, `${cardRef(id)} is archived; pass archived: true to read it.`);
|
|
285
|
+
}
|
|
286
|
+
const unreadable = skipped.find((entry) => entry.id === id);
|
|
287
|
+
return refusal(
|
|
288
|
+
action,
|
|
289
|
+
me,
|
|
290
|
+
unreadable === undefined
|
|
291
|
+
? `${cardRef(id)} does not exist.`
|
|
292
|
+
: `${cardRef(id)} is there and this build cannot read it (${unreadable.file}: ${unreadable.why}).`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
return textResult(formatCard(card, indexOf(cards), now), { action, me, card });
|
|
222
296
|
}
|
|
223
297
|
const listed = cards.filter(
|
|
224
298
|
(card) =>
|
|
@@ -228,9 +302,65 @@ async function read(
|
|
|
228
302
|
// session could not release.
|
|
229
303
|
(args.mine !== true || holds(card, me)),
|
|
230
304
|
);
|
|
231
|
-
|
|
305
|
+
// A finished card is history rather than work, and nothing prunes it — this
|
|
306
|
+
// repository's own board is mostly finished cards — so a listing that names no lane
|
|
307
|
+
// leaves them out and says how many. A named lane, `card`, and `round` are unchanged;
|
|
308
|
+
// the rollup counts the whole board, which is what makes it a rollup.
|
|
309
|
+
const hiddenDone = args.status === undefined ? listed.filter((card) => card.status === "done").length : 0;
|
|
310
|
+
const shown = hiddenDone === 0 ? listed : listed.filter((card) => card.status !== "done");
|
|
311
|
+
if (args.round !== true) {
|
|
312
|
+
return textResult(formatBoard(shown, { hiddenDone, archived, skipped }), { action, me, cards: shown });
|
|
313
|
+
}
|
|
232
314
|
const round = buildRound(cards, now);
|
|
233
|
-
|
|
315
|
+
// One line under the rollup, because a reader who cannot see a card file should know why,
|
|
316
|
+
// and one for the archive, because a rollup that silently left it out would be a board
|
|
317
|
+
// smaller than the one on disk.
|
|
318
|
+
const notes = [formatSkipped(skipped), archiveNote(archived)].filter((line) => line !== "");
|
|
319
|
+
const text = [formatRound(cards, round), ...notes].join("\n");
|
|
320
|
+
return textResult(text, { action, me, cards: listed, round });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* `list archived: true`: the cards set aside, which no board read opens.
|
|
325
|
+
*
|
|
326
|
+
* A lane and a rollup are answers about the board, so asking for the archive with either
|
|
327
|
+
* is refused rather than quietly ignored: the pile is one thing, and every card in it is
|
|
328
|
+
* finished. A query and `mine` narrow it, because both are questions about a card.
|
|
329
|
+
*/
|
|
330
|
+
async function readArchive(
|
|
331
|
+
store: BoardStore,
|
|
332
|
+
action: BoardAction,
|
|
333
|
+
me: BoardDetails["me"],
|
|
334
|
+
args: BoardArgs,
|
|
335
|
+
now: number,
|
|
336
|
+
): Promise<ReturnType<typeof textResult<BoardDetails>>> {
|
|
337
|
+
const wrong = (["status", "round"] as const).filter((field) => args[field] !== undefined);
|
|
338
|
+
if (wrong.length > 0) {
|
|
339
|
+
return refusal(
|
|
340
|
+
action,
|
|
341
|
+
me,
|
|
342
|
+
`archived does not read ${wrong.join(", ")}: the archive is one pile of finished cards, out of the board.`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
const pile = await store.archived();
|
|
346
|
+
if (args.card !== undefined) {
|
|
347
|
+
const id = normalizeCardId(args.card);
|
|
348
|
+
if (id === undefined) return refusal(action, me, `"${args.card}" is not a card id.`);
|
|
349
|
+
const card = pile.cards.find((entry) => entry.id === id);
|
|
350
|
+
if (!card) {
|
|
351
|
+
const onBoard = (await store.list()).some((entry) => entry.id === id);
|
|
352
|
+
return refusal(
|
|
353
|
+
action,
|
|
354
|
+
me,
|
|
355
|
+
onBoard ? `${cardRef(id)} is on the board, not in the archive.` : `${cardRef(id)} is not in the archive.`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
return textResult(formatCard(card, indexOf(pile.cards), now), { action, me, card });
|
|
359
|
+
}
|
|
360
|
+
const shown = pile.cards.filter(
|
|
361
|
+
(card) => (args.query === undefined || matchesQuery(card, args.query)) && (args.mine !== true || holds(card, me)),
|
|
362
|
+
);
|
|
363
|
+
return textResult(formatArchive(shown, pile.skipped), { action, me, cards: shown });
|
|
234
364
|
}
|
|
235
365
|
|
|
236
366
|
/** `add`: the only action that creates a card. */
|
|
@@ -263,6 +393,53 @@ async function mutate(
|
|
|
263
393
|
return textResult(formatCard(result.card), { action, me, card: result.card });
|
|
264
394
|
}
|
|
265
395
|
|
|
396
|
+
/**
|
|
397
|
+
* `archive`: a finished card leaves the board without being destroyed.
|
|
398
|
+
*
|
|
399
|
+
* One card, or — with no card named — every finished card, because clearing a board is
|
|
400
|
+
* the errand and a lead should not have to run sixty calls to do it. Each move is its own
|
|
401
|
+
* compare-and-set, so a sweep is best-effort: it reports what it actually set aside.
|
|
402
|
+
*/
|
|
403
|
+
async function archiveCards(
|
|
404
|
+
store: BoardStore,
|
|
405
|
+
me: BoardDetails["me"],
|
|
406
|
+
args: BoardArgs,
|
|
407
|
+
now: number,
|
|
408
|
+
): Promise<ReturnType<typeof textResult<BoardDetails>>> {
|
|
409
|
+
if (args.card !== undefined) {
|
|
410
|
+
const id = normalizeCardId(args.card);
|
|
411
|
+
if (id === undefined) return refusal("archive", me, `"${args.card}" is not a card id.`);
|
|
412
|
+
const result = await store.mutate(id, { kind: "archive" }, me, now);
|
|
413
|
+
if (!result.ok) return refusal("archive", me, result.error, result.card);
|
|
414
|
+
// Asked twice is one state, not an error: the card is where the caller wanted it.
|
|
415
|
+
if (!result.changed) return textResult(`${cardRef(id)} is already archived.`, { action: "archive", me });
|
|
416
|
+
return textResult(formatArchived([id]), { action: "archive", me, card: result.card, archived: [id] });
|
|
417
|
+
}
|
|
418
|
+
const finished = (await store.list()).filter((card) => card.status === "done");
|
|
419
|
+
const moved: string[] = [];
|
|
420
|
+
const failed: Array<{ card: string; error: string }> = [];
|
|
421
|
+
for (const card of finished) {
|
|
422
|
+
const result = await store.mutate(card.id, { kind: "archive" }, me, now);
|
|
423
|
+
if (result.ok && result.changed) moved.push(card.id);
|
|
424
|
+
// A sweep is best-effort, and a card that changed underneath it is the one thing it
|
|
425
|
+
// cannot report by silence: "every finished card" must not read as fewer, unexplained.
|
|
426
|
+
else if (!result.ok) failed.push({ card: card.id, error: result.error });
|
|
427
|
+
}
|
|
428
|
+
if (moved.length === 0 && failed.length === 0) {
|
|
429
|
+
return textResult("No finished cards to archive.", { action: "archive", me });
|
|
430
|
+
}
|
|
431
|
+
const text = [
|
|
432
|
+
...(moved.length === 0 ? [] : [formatArchived(moved)]),
|
|
433
|
+
...failed.map((entry) => `${cardRef(entry.card)} was not archived (${entry.error})`),
|
|
434
|
+
].join(" · ");
|
|
435
|
+
return textResult(text, {
|
|
436
|
+
action: "archive",
|
|
437
|
+
me,
|
|
438
|
+
...(moved.length === 0 ? {} : { archived: moved }),
|
|
439
|
+
...(failed.length === 0 ? {} : { failed }),
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
266
443
|
/** `drop`: the card and its notes leave together, so the report is the id. */
|
|
267
444
|
async function dropCard(
|
|
268
445
|
store: BoardStore,
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
* The board page's router: one request in, one response out, and every policy the
|
|
3
3
|
* served surface obeys decided here.
|
|
4
4
|
*
|
|
5
|
-
* The page
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* this module only decides the order and the sentences.
|
|
5
|
+
* The page reads. It cannot change a card, so what is left to decide is the `Host`
|
|
6
|
+
* allowlist, the token in the URL, and which reads exist — a method that would write is
|
|
7
|
+
* refused by the method list before a handler is reached. The guards themselves are
|
|
8
|
+
* `lib/http`'s — this module only decides the order and the sentences.
|
|
9
9
|
*
|
|
10
|
-
* Nothing here knows what a board is. The snapshot arrives as `view()
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Nothing here knows what a board is. The snapshot arrives as `view()` and the document
|
|
11
|
+
* as `page()`, both supplied by `page.ts`, so this file can be tested with a fake store
|
|
12
|
+
* and no server.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import {
|
|
@@ -18,72 +18,46 @@ import {
|
|
|
18
18
|
jsonError,
|
|
19
19
|
jsonResponse,
|
|
20
20
|
pageHeaders,
|
|
21
|
-
PAGE_METHODS,
|
|
22
21
|
parseRequestUrl,
|
|
23
|
-
postOnly,
|
|
24
|
-
requireJsonBody,
|
|
25
22
|
scriptHeaders,
|
|
26
23
|
type ServeResponse,
|
|
27
24
|
} from "../../../lib/http/index.ts";
|
|
28
25
|
import type { BoardView } from "../../../lib/board/view.ts";
|
|
29
|
-
import
|
|
26
|
+
import { PING_PATH, SNAPSHOT_PATH } from "../../../lib/board/page-pointer.ts";
|
|
30
27
|
import { THEME_JS, THEME_JS_PATH } from "../../../lib/web/index.ts";
|
|
31
28
|
import type { PageView } from "../view/page.ts";
|
|
32
29
|
|
|
33
|
-
/** The methods this surface serves: reads, writes, and the one deletion. */
|
|
34
|
-
const METHODS: readonly string[] = [...PAGE_METHODS, "DELETE"];
|
|
35
|
-
|
|
36
30
|
/**
|
|
37
|
-
*
|
|
31
|
+
* The methods this surface serves: the two reads and the stream.
|
|
38
32
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* function instead of remembering which token it was given.
|
|
33
|
+
* `PAGE_METHODS` also carries `POST`, because a page that writes needs it. This one does
|
|
34
|
+
* not, so a write is refused by the envelope — before the token is even read — and the
|
|
35
|
+
* `Allow` header says the truth about what a browser can do here.
|
|
43
36
|
*/
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
/** The verbs a card route accepts, in the order the page uses them. */
|
|
47
|
-
const CARD_VERBS = ["claim", "release", "reclaim", "assign", "move", "note", "plan", "edit"] as const;
|
|
48
|
-
|
|
49
|
-
/** One card verb. */
|
|
50
|
-
export type CardVerb = (typeof CARD_VERBS)[number];
|
|
51
|
-
|
|
52
|
-
/** One write the page asks for, already routed but not yet applied. */
|
|
53
|
-
export type PageWrite =
|
|
54
|
-
| { kind: "add"; body: Record<string, unknown> }
|
|
55
|
-
| { kind: "drop"; id: string }
|
|
56
|
-
| { kind: CardVerb; id: string; body: Record<string, unknown> };
|
|
57
|
-
|
|
58
|
-
/** What a write did, or why it was refused with the status the page shows. */
|
|
59
|
-
export type PageResult = { ok: true; card: Card } | { ok: false; status: number; error: string; card?: Card };
|
|
37
|
+
const METHODS: readonly string[] = ["GET", "HEAD"];
|
|
60
38
|
|
|
61
39
|
/** Everything the router needs from the running page. */
|
|
62
40
|
export interface BoardRouterContext {
|
|
63
|
-
/**
|
|
64
|
-
|
|
41
|
+
/** Whether a token is this page's. */
|
|
42
|
+
read: (given: string | null) => boolean;
|
|
65
43
|
/** The names this server answers to. */
|
|
66
44
|
hosts: () => readonly string[];
|
|
67
|
-
/** The document for one tab
|
|
68
|
-
page: (view: PageView
|
|
45
|
+
/** The document for one tab. */
|
|
46
|
+
page: (view: PageView) => Promise<string>;
|
|
69
47
|
/** The client script. */
|
|
70
48
|
script: string;
|
|
71
|
-
/** The snapshot the page renders, and the body of
|
|
49
|
+
/** The snapshot the page renders, and the body of the snapshot route. */
|
|
72
50
|
view: () => Promise<BoardView>;
|
|
73
|
-
/** Apply one write as the serving session. */
|
|
74
|
-
write: (request: PageWrite) => Promise<PageResult>;
|
|
75
51
|
}
|
|
76
52
|
|
|
77
53
|
/**
|
|
78
|
-
* Handle one request. `method`, `rawUrl`, and `headers` are the raw HTTP values
|
|
79
|
-
* `body` is the request body, which is only non-empty when the server caps it.
|
|
54
|
+
* Handle one request. `method`, `rawUrl`, and `headers` are the raw HTTP values.
|
|
80
55
|
*/
|
|
81
56
|
export async function handleBoardRequest(
|
|
82
57
|
ctx: BoardRouterContext,
|
|
83
58
|
method: string,
|
|
84
59
|
rawUrl: string,
|
|
85
60
|
headers: Record<string, string | string[] | undefined> = {},
|
|
86
|
-
body = "",
|
|
87
61
|
): Promise<ServeResponse> {
|
|
88
62
|
const envelope = envelopeRefusal({ method, headers, hosts: ctx.hosts(), allowed: METHODS });
|
|
89
63
|
if (envelope) return envelope;
|
|
@@ -92,81 +66,24 @@ export async function handleBoardRequest(
|
|
|
92
66
|
if (!parsed) return jsonError(400, "Malformed request URL.");
|
|
93
67
|
const { pathname, search } = parsed;
|
|
94
68
|
|
|
95
|
-
if (pathname === "/favicon.ico") return { status: 204, headers: { "Cache-Control": "no-store" } };
|
|
96
69
|
if (pathname === THEME_JS_PATH) return { status: 200, headers: scriptHeaders(), body: THEME_JS };
|
|
97
70
|
if (pathname === "/app.js") return { status: 200, headers: scriptHeaders(), body: ctx.script };
|
|
98
71
|
|
|
99
|
-
|
|
100
|
-
if (authority === undefined) return jsonError(403, "Invalid or missing token.");
|
|
72
|
+
if (!ctx.read(search.get("token"))) return jsonError(403, "Invalid or missing token.");
|
|
101
73
|
|
|
102
|
-
|
|
74
|
+
// The liveness answer, and nothing else: a session deciding whether this page is alive
|
|
75
|
+
// must not pay for the snapshot, which is a store read and a roster read on the same
|
|
76
|
+
// event loop the probe is racing.
|
|
77
|
+
if (pathname === PING_PATH) return jsonResponse(200, { ok: true });
|
|
78
|
+
if (pathname === SNAPSHOT_PATH) return jsonResponse(200, await ctx.view());
|
|
103
79
|
if (pathname === "/" || pathname === "/index.html") {
|
|
104
|
-
|
|
105
|
-
return { status: 200, headers: pageHeaders(), body: await ctx.page(view, authority) };
|
|
80
|
+
return { status: 200, headers: pageHeaders(), body: await ctx.page(viewOf(search.get("view"))) };
|
|
106
81
|
}
|
|
107
82
|
if (pathname === "/events") return { status: 200, headers: eventHeaders(), stream: true };
|
|
108
|
-
|
|
109
|
-
// Everything below changes a card, so a credential that may only read stops here.
|
|
110
|
-
const readOnly = writeGuard(authority);
|
|
111
|
-
if (readOnly) return readOnly;
|
|
112
|
-
|
|
113
|
-
if (pathname === "/api/cards") {
|
|
114
|
-
const methodRefusal = postOnly(method, "add a card");
|
|
115
|
-
if (methodRefusal) return methodRefusal;
|
|
116
|
-
const jsonRefusal = requireJsonBody(headers["content-type"]);
|
|
117
|
-
if (jsonRefusal) return jsonRefusal;
|
|
118
|
-
const parsedBody = parseBody(body);
|
|
119
|
-
if (!parsedBody) return jsonError(400, "The request body must be a JSON object.");
|
|
120
|
-
return writeAnswer(await ctx.write({ kind: "add", body: parsedBody }));
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const route = /^\/api\/cards\/([^/]+)(?:\/([^/]+))?$/.exec(pathname);
|
|
124
|
-
if (!route) return jsonError(404, "That page does not exist.");
|
|
125
|
-
const id = decodeURIComponent(route[1] ?? "");
|
|
126
|
-
const verb = route[2];
|
|
127
|
-
|
|
128
|
-
if (verb === undefined) {
|
|
129
|
-
if (method.toUpperCase() !== "DELETE") return jsonError(405, "Use DELETE to drop a card.");
|
|
130
|
-
return writeAnswer(await ctx.write({ kind: "drop", id }));
|
|
131
|
-
}
|
|
132
|
-
if (!CARD_VERBS.includes(verb as CardVerb)) return jsonError(404, "That card action does not exist.");
|
|
133
|
-
const methodRefusal = postOnly(method, verb);
|
|
134
|
-
if (methodRefusal) return methodRefusal;
|
|
135
|
-
const jsonRefusal = requireJsonBody(headers["content-type"]);
|
|
136
|
-
if (jsonRefusal) return jsonRefusal;
|
|
137
|
-
const parsedBody = parseBody(body);
|
|
138
|
-
if (!parsedBody) return jsonError(400, "The request body must be a JSON object.");
|
|
139
|
-
return writeAnswer(await ctx.write({ kind: verb as CardVerb, id, body: parsedBody }));
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** The 403 for a credential that may read this page but not change it. */
|
|
143
|
-
function writeGuard(authority: Authority): ServeResponse | undefined {
|
|
144
|
-
if (authority === "write") return undefined;
|
|
145
|
-
return jsonError(403, "This page is read-only for a viewer.");
|
|
83
|
+
return jsonError(404, "That page does not exist.");
|
|
146
84
|
}
|
|
147
85
|
|
|
148
86
|
/** The tab a `?view=` names; anything else is the board. */
|
|
149
87
|
function viewOf(raw: string | null): PageView {
|
|
150
88
|
return raw === "team" ? "team" : "board";
|
|
151
89
|
}
|
|
152
|
-
|
|
153
|
-
/** A request body as an object, or undefined when it is not one. */
|
|
154
|
-
function parseBody(body: string): Record<string, unknown> | undefined {
|
|
155
|
-
let parsed: unknown;
|
|
156
|
-
try {
|
|
157
|
-
parsed = JSON.parse(body);
|
|
158
|
-
} catch {
|
|
159
|
-
return undefined;
|
|
160
|
-
}
|
|
161
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
|
|
162
|
-
return parsed as Record<string, unknown>;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/** A write's answer: the card, or the refusal and the card it left alone. */
|
|
166
|
-
function writeAnswer(result: PageResult): ServeResponse {
|
|
167
|
-
if (result.ok) return jsonResponse(200, { card: result.card });
|
|
168
|
-
return jsonResponse(
|
|
169
|
-
result.status,
|
|
170
|
-
result.card === undefined ? { error: result.error } : { error: result.error, card: result.card },
|
|
171
|
-
);
|
|
172
|
-
}
|