@huseynsnmz/pi-suite 0.1.0 → 0.2.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/CHANGELOG.md +14 -0
- package/README.md +2 -1
- package/package.json +6 -7
- package/packages/board/package.json +0 -2
- package/packages/board/src/core/rows.mjs +21 -5
- package/packages/board/src/ui/dashboard.ts +36 -20
- package/packages/commands/LICENSE +21 -0
- package/packages/commands/README.md +19 -0
- package/packages/commands/package.json +22 -0
- package/packages/commands/src/index.ts +43 -0
- package/packages/commands/src/security-review.ts +109 -0
- package/packages/frame/README.md +1 -1
- package/packages/frame/package.json +1 -4
- package/packages/frame/src/data/github.ts +1 -1
- package/packages/frame/src/data/mode.ts +28 -2
- package/packages/frame/src/data/snapshot.ts +1 -1
- package/packages/frame/src/data/start-notes.ts +1 -1
- package/packages/frame/src/data/totals.ts +2 -2
- package/packages/frame/src/data/visits.ts +1 -1
- package/packages/frame/src/index.ts +22 -2
- package/packages/frame/src/kit/LICENSE +21 -0
- package/packages/frame/src/kit/app/attribution.ts +30 -0
- package/packages/frame/src/kit/app/builder.ts +186 -0
- package/packages/frame/src/kit/app/claim.ts +27 -0
- package/packages/frame/src/kit/app/feature.ts +91 -0
- package/packages/frame/src/kit/app/host.ts +214 -0
- package/packages/frame/src/kit/app/renderers.ts +62 -0
- package/packages/frame/src/kit/app/scope.ts +125 -0
- package/packages/frame/src/kit/board/model.ts +337 -0
- package/packages/frame/src/kit/contract.ts +79 -0
- package/packages/frame/src/kit/contracts/accounts.ts +33 -0
- package/packages/frame/src/kit/contracts/ask.ts +120 -0
- package/packages/frame/src/kit/contracts/board.ts +6 -0
- package/packages/frame/src/kit/contracts/by.ts +22 -0
- package/packages/frame/src/kit/contracts/calls.ts +46 -0
- package/packages/frame/src/kit/contracts/events.ts +89 -0
- package/packages/frame/src/kit/contracts/mode.ts +26 -0
- package/packages/frame/src/kit/contracts/names.ts +12 -0
- package/packages/frame/src/kit/contracts/peers.ts +77 -0
- package/packages/frame/src/kit/contracts/prompts.ts +8 -0
- package/packages/frame/src/kit/contracts/screen.ts +75 -0
- package/packages/frame/src/kit/contracts/skills.ts +81 -0
- package/packages/frame/src/kit/contracts/sources.ts +81 -0
- package/packages/frame/src/kit/contracts/views.ts +42 -0
- package/packages/frame/src/kit/control.ts +59 -0
- package/packages/frame/src/kit/dark-theme.ts +41 -0
- package/packages/frame/src/kit/decode.ts +210 -0
- package/packages/frame/src/kit/events/log.ts +313 -0
- package/packages/frame/src/kit/format.ts +25 -0
- package/packages/frame/src/kit/host.ts +4 -0
- package/packages/frame/src/kit/index.ts +25 -0
- package/packages/frame/src/kit/legacy-tasks.ts +39 -0
- package/packages/frame/src/kit/overlay-images.ts +36 -0
- package/packages/frame/src/kit/paths.ts +16 -0
- package/packages/frame/src/kit/screen/client.ts +74 -0
- package/packages/frame/src/kit/screen/feature.ts +64 -0
- package/packages/frame/src/kit/screen/model.ts +182 -0
- package/packages/frame/src/kit/screen/view.ts +838 -0
- package/packages/frame/src/kit/settings/files.ts +90 -0
- package/packages/frame/src/kit/settings/setting.ts +57 -0
- package/packages/frame/src/kit/settings/store.ts +192 -0
- package/packages/frame/src/kit/sources/file.ts +129 -0
- package/packages/frame/src/kit/sources/follow.ts +155 -0
- package/packages/frame/src/kit/testing.ts +260 -0
- package/packages/frame/src/kit/totals.ts +72 -0
- package/packages/frame/src/kit/ui/entry.ts +75 -0
- package/packages/frame/src/kit/ui/frame.ts +212 -0
- package/packages/frame/src/kit/ui/glyphs.ts +132 -0
- package/packages/frame/src/kit/ui/scroll.ts +14 -0
- package/packages/frame/src/render/diff.ts +55 -2
- package/packages/frame/src/render/fit.ts +1 -1
- package/packages/frame/src/render/format.ts +1 -1
- package/packages/frame/src/render/segments.ts +1 -1
- package/packages/frame/src/render/streaming.ts +1 -1
- package/packages/frame/src/render/tools.ts +1 -1
- package/packages/frame/src/settings.ts +1 -1
- package/packages/frame/src/ui/editor.ts +1 -1
- package/packages/frame/src/ui/effort.ts +1 -1
- package/packages/frame/src/ui/header.ts +1 -1
- package/packages/frame/src/ui/outputs.ts +1 -1
- package/packages/frame/src/ui/pi-entries.ts +28 -6
- package/packages/frame/src/ui/previews.ts +1 -1
- package/packages/frame/src/ui/queue.ts +1 -1
- package/packages/frame/src/ui/todos.ts +1 -1
- package/packages/frame/src/ui/tool-frame.ts +1 -1
- package/packages/frame/src/view/state.ts +1 -1
- package/packages/frame/src/view/transcript.ts +1 -1
- package/packages/frame/src/view/view.ts +1 -1
- package/packages/web/package.json +1 -3
- package/packages/web/src/extract.ts +2 -2
- package/packages/web/src/markdown.ts +122 -0
- package/src/index.ts +1 -1
- package/src/settings.ts +1 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { ExtensionAPI, MessageRenderer } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import type { SettingsStore } from "../settings/store.ts";
|
|
4
|
+
import { attributed } from "./attribution.ts";
|
|
5
|
+
import { describe } from "./attribution.ts";
|
|
6
|
+
import type {
|
|
7
|
+
FeatureScope,
|
|
8
|
+
ScreenEntry,
|
|
9
|
+
ScreenGroup,
|
|
10
|
+
SessionHook,
|
|
11
|
+
ShutdownHook,
|
|
12
|
+
} from "./feature.ts";
|
|
13
|
+
import { forgetRenderers, keepMessageRenderer, keepToolRenderer } from "./renderers.ts";
|
|
14
|
+
|
|
15
|
+
export interface Hook<T> {
|
|
16
|
+
source: string;
|
|
17
|
+
run: T;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AppState {
|
|
21
|
+
name: string;
|
|
22
|
+
pi: ExtensionAPI;
|
|
23
|
+
settings: SettingsStore;
|
|
24
|
+
mounted: ReadonlySet<string>;
|
|
25
|
+
commands: Map<string, string>;
|
|
26
|
+
starts: Hook<SessionHook>[];
|
|
27
|
+
shutdowns: Hook<ShutdownHook>[];
|
|
28
|
+
screen: Map<string, ScreenEntry[]>;
|
|
29
|
+
screenGroups: Map<string, ScreenGroup[]>;
|
|
30
|
+
live(): boolean;
|
|
31
|
+
/** Ends the session from inside a deferral a refused context was found in. */
|
|
32
|
+
stop(): void;
|
|
33
|
+
report(source: string, message: string): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createScope(state: AppState, featureId: string): FeatureScope {
|
|
37
|
+
const label = `${state.name}: ${featureId}`;
|
|
38
|
+
const rows: ScreenEntry[] = state.screen.get(featureId) ?? [];
|
|
39
|
+
state.screen.set(featureId, rows);
|
|
40
|
+
const groups: ScreenGroup[] = state.screenGroups.get(featureId) ?? [];
|
|
41
|
+
state.screenGroups.set(featureId, groups);
|
|
42
|
+
forgetRenderers(featureId);
|
|
43
|
+
const add = (row: ScreenEntry): void => {
|
|
44
|
+
if (rows.some((entry) => entry.id === row.id)) {
|
|
45
|
+
state.report(featureId, `the screen row "${row.id}" was added twice; keeping the first`);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
rows.push(row);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
...state.pi,
|
|
53
|
+
id: featureId,
|
|
54
|
+
settings: state.settings,
|
|
55
|
+
on: attributedOn(state.pi, label),
|
|
56
|
+
registerCommand(name, options) {
|
|
57
|
+
const owner = state.commands.get(name);
|
|
58
|
+
if (owner !== undefined) {
|
|
59
|
+
state.report(featureId, `/${name} is already registered by ${owner}; skipped`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
state.commands.set(name, featureId);
|
|
63
|
+
state.pi.registerCommand(name, {
|
|
64
|
+
...options,
|
|
65
|
+
handler: attributed(`${label}: /${name}`, options.handler),
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
registerToolRenderer(resolver) {
|
|
69
|
+
keepToolRenderer(featureId, resolver);
|
|
70
|
+
state.pi.registerToolRenderer(resolver);
|
|
71
|
+
},
|
|
72
|
+
registerMessageRenderer(customType, renderer) {
|
|
73
|
+
keepMessageRenderer(featureId, customType, renderer as MessageRenderer);
|
|
74
|
+
state.pi.registerMessageRenderer(customType, renderer);
|
|
75
|
+
},
|
|
76
|
+
defer(run, ms = 0) {
|
|
77
|
+
// `unref` only keeps a pending timer from holding the process open; the guard at the top
|
|
78
|
+
// of the callback is what keeps the work from running once the session is over.
|
|
79
|
+
setTimeout(() => {
|
|
80
|
+
if (!state.live()) return;
|
|
81
|
+
try {
|
|
82
|
+
run();
|
|
83
|
+
} catch (error) {
|
|
84
|
+
// The host takes the context away before it announces the shutdown, so `live` is
|
|
85
|
+
// still true here; its refusal is the only notice that the session is over.
|
|
86
|
+
if (refusedByHost(error)) {
|
|
87
|
+
state.stop();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
state.report(featureId, `deferred work failed: ${describe(error)}`);
|
|
91
|
+
}
|
|
92
|
+
}, ms).unref();
|
|
93
|
+
},
|
|
94
|
+
onSessionStart: (run) => state.starts.push({ source: featureId, run }),
|
|
95
|
+
onShutdown: (run) => state.shutdowns.push({ source: featureId, run }),
|
|
96
|
+
warn: (message) => state.report(featureId, message),
|
|
97
|
+
has: (id) => state.mounted.has(id),
|
|
98
|
+
screen: {
|
|
99
|
+
value: (row) => add({ kind: "value", ...row }),
|
|
100
|
+
action: (row) => add({ kind: "action", ...row }),
|
|
101
|
+
info: (row) => add({ kind: "info", ...row }),
|
|
102
|
+
rows: (provider) => void groups.push(provider),
|
|
103
|
+
},
|
|
104
|
+
} as FeatureScope;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Pi's message for a stale context (`dist/core/extensions/runner.js` in 1.4.0). */
|
|
108
|
+
const STALE = "stale after session replacement or reload";
|
|
109
|
+
|
|
110
|
+
/** A session that ended announces itself by the host refusing its context. */
|
|
111
|
+
function refusedByHost(error: unknown): boolean {
|
|
112
|
+
return error instanceof Error && error.message.includes(STALE);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type UntypedHandler = (...args: unknown[]) => unknown;
|
|
116
|
+
type UntypedOn = (event: string, handler: UntypedHandler) => () => void;
|
|
117
|
+
|
|
118
|
+
// pi.on has one overload per event and TypeScript cannot implement an overloaded signature
|
|
119
|
+
// generically, so this is the one place that casts. Callers keep the exact overload types.
|
|
120
|
+
function attributedOn(pi: ExtensionAPI, label: string): ExtensionAPI["on"] {
|
|
121
|
+
const on = pi.on.bind(pi) as unknown as UntypedOn;
|
|
122
|
+
const wrapped: UntypedOn = (event, handler) =>
|
|
123
|
+
on(event, attributed(`${label}: ${event}`, handler));
|
|
124
|
+
return wrapped as unknown as ExtensionAPI["on"];
|
|
125
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// A session's board is a fold of its operations, so `/tree` and forks carry it and nothing merges.
|
|
2
|
+
import type { By } from "../contracts/by.ts";
|
|
3
|
+
import { isBy } from "../contracts/by.ts";
|
|
4
|
+
import { isObject } from "../decode.ts";
|
|
5
|
+
|
|
6
|
+
export type BoardColumn = "backlog" | "todo" | "doing" | "review" | "done" | "dropped";
|
|
7
|
+
|
|
8
|
+
/** Names the thing, never the reader: `operator` reads "you" only on the operator's screen. */
|
|
9
|
+
export type BoardBlocker = { why: string } & (
|
|
10
|
+
| { on: "operator" }
|
|
11
|
+
| { on: "outside" }
|
|
12
|
+
| { on: "agent"; handle: string }
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
export interface BoardCard {
|
|
16
|
+
id: string;
|
|
17
|
+
title: string;
|
|
18
|
+
column: BoardColumn;
|
|
19
|
+
pr?: number;
|
|
20
|
+
now?: string;
|
|
21
|
+
blocked?: BoardBlocker;
|
|
22
|
+
reviewer?: string;
|
|
23
|
+
result?: string;
|
|
24
|
+
why?: string;
|
|
25
|
+
at: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface BoardDecision {
|
|
29
|
+
id: string;
|
|
30
|
+
text: string;
|
|
31
|
+
by?: By;
|
|
32
|
+
at: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Board {
|
|
36
|
+
next: number;
|
|
37
|
+
cards: BoardCard[];
|
|
38
|
+
decisions: BoardDecision[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type BoardOp =
|
|
42
|
+
| {
|
|
43
|
+
op: "add";
|
|
44
|
+
title: string;
|
|
45
|
+
column?: BoardColumn;
|
|
46
|
+
pr?: number;
|
|
47
|
+
blocked?: BoardBlocker;
|
|
48
|
+
at: number;
|
|
49
|
+
}
|
|
50
|
+
| { op: "move"; id: string; column: BoardColumn; reviewer?: string; result?: string; at: number }
|
|
51
|
+
| {
|
|
52
|
+
op: "edit";
|
|
53
|
+
id: string;
|
|
54
|
+
title?: string;
|
|
55
|
+
pr?: number;
|
|
56
|
+
now?: string;
|
|
57
|
+
blocked?: BoardBlocker | null;
|
|
58
|
+
at: number;
|
|
59
|
+
}
|
|
60
|
+
| { op: "drop"; id: string; why: string; at: number }
|
|
61
|
+
| { op: "decide"; text: string; by?: By; at: number };
|
|
62
|
+
|
|
63
|
+
export type BoardResult = { ok: true; board: Board } | { ok: false; why: string };
|
|
64
|
+
|
|
65
|
+
/** Who writes: the agent keeps the board small, the operator may go past the limits. */
|
|
66
|
+
export type BoardWriter = "agent" | "operator";
|
|
67
|
+
|
|
68
|
+
const IN_PROGRESS = 3;
|
|
69
|
+
const OPEN = 15;
|
|
70
|
+
const TITLE_WORDS = 8;
|
|
71
|
+
const TOO_MANY = 10;
|
|
72
|
+
|
|
73
|
+
export function emptyBoard(): Board {
|
|
74
|
+
return { next: 1, cards: [], decisions: [] };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const BOARD_COLUMNS: readonly {
|
|
78
|
+
column: BoardColumn;
|
|
79
|
+
label: string;
|
|
80
|
+
/** Open cards count toward the cap and the panel. */
|
|
81
|
+
open: boolean;
|
|
82
|
+
}[] = [
|
|
83
|
+
{ column: "doing", label: "Doing", open: true },
|
|
84
|
+
{ column: "review", label: "In review", open: true },
|
|
85
|
+
{ column: "todo", label: "To do", open: true },
|
|
86
|
+
{ column: "backlog", label: "Backlog", open: false },
|
|
87
|
+
{ column: "done", label: "Done", open: false },
|
|
88
|
+
{ column: "dropped", label: "Dropped", open: false },
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
const OPEN_COLUMNS: ReadonlySet<BoardColumn> = new Set(
|
|
92
|
+
BOARD_COLUMNS.filter((one) => one.open).map((one) => one.column),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
export function labelOf(column: BoardColumn): string {
|
|
96
|
+
return BOARD_COLUMNS.find((one) => one.column === column)?.label ?? column;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function applyBoard(board: Board, op: BoardOp, writer: BoardWriter = "agent"): BoardResult {
|
|
100
|
+
switch (op.op) {
|
|
101
|
+
case "add":
|
|
102
|
+
return add(board, op, writer);
|
|
103
|
+
case "move":
|
|
104
|
+
return move(board, op, writer);
|
|
105
|
+
case "edit":
|
|
106
|
+
return edit(board, op);
|
|
107
|
+
case "drop":
|
|
108
|
+
return drop(board, op);
|
|
109
|
+
case "decide": {
|
|
110
|
+
const id = `d${board.next}`;
|
|
111
|
+
return {
|
|
112
|
+
ok: true,
|
|
113
|
+
board: {
|
|
114
|
+
next: board.next + 1,
|
|
115
|
+
cards: board.cards,
|
|
116
|
+
decisions: [
|
|
117
|
+
...board.decisions,
|
|
118
|
+
{ id, text: op.text.trim(), ...(op.by === undefined ? {} : { by: op.by }), at: op.at },
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function add(board: Board, op: Extract<BoardOp, { op: "add" }>, writer: BoardWriter): BoardResult {
|
|
127
|
+
const title = op.title.trim();
|
|
128
|
+
const bad = badTitle(title);
|
|
129
|
+
if (bad !== undefined) return { ok: false, why: bad };
|
|
130
|
+
if (op.pr !== undefined && board.cards.some((card) => card.pr === op.pr)) {
|
|
131
|
+
return { ok: false, why: `a card for #${op.pr} already exists` };
|
|
132
|
+
}
|
|
133
|
+
const column = op.column ?? "todo";
|
|
134
|
+
const id = `c${board.next}`;
|
|
135
|
+
const card: BoardCard = {
|
|
136
|
+
id,
|
|
137
|
+
title,
|
|
138
|
+
column,
|
|
139
|
+
at: op.at,
|
|
140
|
+
...(op.pr === undefined ? {} : { pr: op.pr }),
|
|
141
|
+
...(op.blocked === undefined ? {} : { blocked: op.blocked }),
|
|
142
|
+
};
|
|
143
|
+
return guarded(
|
|
144
|
+
{ next: board.next + 1, cards: [...board.cards, card], decisions: board.decisions },
|
|
145
|
+
writer,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function move(
|
|
150
|
+
board: Board,
|
|
151
|
+
op: Extract<BoardOp, { op: "move" }>,
|
|
152
|
+
writer: BoardWriter,
|
|
153
|
+
): BoardResult {
|
|
154
|
+
const card = board.cards.find((one) => one.id === op.id);
|
|
155
|
+
if (card === undefined) return { ok: false, why: `no card ${op.id}` };
|
|
156
|
+
if (op.column === "review" && op.reviewer === undefined)
|
|
157
|
+
return { ok: false, why: "a card in review needs its reviewer" };
|
|
158
|
+
if (op.column === "dropped")
|
|
159
|
+
return { ok: false, why: "a card is dropped with a reason, through drop" };
|
|
160
|
+
const at = op.at;
|
|
161
|
+
const next: BoardCard = { ...card, column: op.column, at };
|
|
162
|
+
// A field belongs to its column; leaving the column clears it, whoever wrote.
|
|
163
|
+
if (op.column === "doing") next.now = card.now;
|
|
164
|
+
else delete next.now;
|
|
165
|
+
if (op.column === "review") next.reviewer = op.reviewer;
|
|
166
|
+
else delete next.reviewer;
|
|
167
|
+
if (op.column === "done") next.result = op.result ?? card.result;
|
|
168
|
+
else delete next.result;
|
|
169
|
+
// A finished card waits on no one.
|
|
170
|
+
if (op.column === "done") delete next.blocked;
|
|
171
|
+
return guarded(
|
|
172
|
+
{ ...board, cards: board.cards.map((one) => (one.id === card.id ? next : one)) },
|
|
173
|
+
writer,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function edit(board: Board, op: Extract<BoardOp, { op: "edit" }>): BoardResult {
|
|
178
|
+
const card = board.cards.find((one) => one.id === op.id);
|
|
179
|
+
if (card === undefined) return { ok: false, why: `no card ${op.id}` };
|
|
180
|
+
const title = op.title?.trim();
|
|
181
|
+
if (title !== undefined) {
|
|
182
|
+
const bad = badTitle(title);
|
|
183
|
+
if (bad !== undefined) return { ok: false, why: bad };
|
|
184
|
+
}
|
|
185
|
+
if (op.pr !== undefined && board.cards.some((one) => one.pr === op.pr && one.id !== card.id)) {
|
|
186
|
+
return { ok: false, why: `a card for #${op.pr} already exists` };
|
|
187
|
+
}
|
|
188
|
+
const next: BoardCard = { ...card, at: op.at };
|
|
189
|
+
if (title !== undefined) next.title = title;
|
|
190
|
+
if (op.pr !== undefined) next.pr = op.pr;
|
|
191
|
+
if (op.now !== undefined) next.now = op.now;
|
|
192
|
+
if (op.blocked === null) delete next.blocked;
|
|
193
|
+
else if (op.blocked !== undefined) next.blocked = op.blocked;
|
|
194
|
+
// `now` only means something while the card is being done; the fold enforces it for every writer.
|
|
195
|
+
if (next.column !== "doing") delete next.now;
|
|
196
|
+
return {
|
|
197
|
+
ok: true,
|
|
198
|
+
board: { ...board, cards: board.cards.map((one) => (one.id === card.id ? next : one)) },
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function drop(board: Board, op: Extract<BoardOp, { op: "drop" }>): BoardResult {
|
|
203
|
+
const card = board.cards.find((one) => one.id === op.id);
|
|
204
|
+
if (card === undefined) return { ok: false, why: `no card ${op.id}` };
|
|
205
|
+
const why = op.why.trim();
|
|
206
|
+
if (why === "") return { ok: false, why: "a dropped card needs its reason" };
|
|
207
|
+
const next: BoardCard = { ...card, column: "dropped", why, at: op.at };
|
|
208
|
+
delete next.now;
|
|
209
|
+
delete next.reviewer;
|
|
210
|
+
delete next.result;
|
|
211
|
+
delete next.blocked;
|
|
212
|
+
return {
|
|
213
|
+
ok: true,
|
|
214
|
+
board: { ...board, cards: board.cards.map((one) => (one.id === card.id ? next : one)) },
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** A write that would take the board past its limits is refused, whichever operation made it. */
|
|
219
|
+
function guarded(next: Board, writer: BoardWriter): BoardResult {
|
|
220
|
+
if (writer === "operator") return { ok: true, board: next };
|
|
221
|
+
const open = next.cards.filter((card) => OPEN_COLUMNS.has(card.column));
|
|
222
|
+
const doing = open.filter((card) => card.column === "doing");
|
|
223
|
+
if (open.length > OPEN) {
|
|
224
|
+
return {
|
|
225
|
+
ok: false,
|
|
226
|
+
why: `the board already has ${open.length} open cards; finish, drop or move one to the backlog: ${open.map((card) => card.id).join(", ")}`,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
if (doing.length > IN_PROGRESS) {
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
why: `there are already ${IN_PROGRESS} cards in progress; finish one first: ${doing.map((card) => card.id).join(", ")}`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
return { ok: true, board: next };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function badTitle(title: string): string | undefined {
|
|
239
|
+
const words = title.split(/\s+/).filter((word) => word !== "");
|
|
240
|
+
if (title === "" || words.length > TITLE_WORDS)
|
|
241
|
+
return `a title says the result in 1 to ${TITLE_WORDS} words`;
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** What other sessions see: the open cards, the backlog as a count, the last three done. */
|
|
246
|
+
export interface BoardView {
|
|
247
|
+
open: BoardCard[];
|
|
248
|
+
backlog: number;
|
|
249
|
+
done: BoardCard[];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function boardView(board: Board): BoardView {
|
|
253
|
+
const open = board.cards.filter((card) => OPEN_COLUMNS.has(card.column)).slice(0, OPEN);
|
|
254
|
+
const backlog = board.cards.filter((card) => card.column === "backlog").length;
|
|
255
|
+
const done = board.cards.filter((card) => card.column === "done").slice(-3);
|
|
256
|
+
return { open, backlog, done };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Searches deliveries and decisions, at most ten at a time. */
|
|
260
|
+
export function findBoard(
|
|
261
|
+
board: Board,
|
|
262
|
+
text: string,
|
|
263
|
+
): { matches: (BoardCard | BoardDecision)[]; more: number } {
|
|
264
|
+
const needle = text.trim().toLowerCase();
|
|
265
|
+
if (needle === "") return { matches: [], more: 0 };
|
|
266
|
+
const cards = board.cards.filter(
|
|
267
|
+
(card) => card.column === "done" && card.title.toLowerCase().includes(needle),
|
|
268
|
+
);
|
|
269
|
+
const decisions = board.decisions.filter((decision) =>
|
|
270
|
+
decision.text.toLowerCase().includes(needle),
|
|
271
|
+
);
|
|
272
|
+
const all = [...cards, ...decisions];
|
|
273
|
+
return { matches: all.slice(0, TOO_MANY), more: Math.max(0, all.length - TOO_MANY) };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The one translation of the blocker: the operator reads "você", a third session reads the operator. */
|
|
277
|
+
export function blockedText(blocker: BoardBlocker, reader: "operator" | "agent"): string {
|
|
278
|
+
if (blocker.on === "operator") {
|
|
279
|
+
return `${reader === "operator" ? "você" : "o operador"}: ${blocker.why}`;
|
|
280
|
+
}
|
|
281
|
+
if (blocker.on === "outside") return `fora: ${blocker.why}`;
|
|
282
|
+
const handle = blocker.handle.startsWith("@") ? blocker.handle : `@${blocker.handle}`;
|
|
283
|
+
return `${handle}: ${blocker.why}`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function foldBoard(ops: readonly BoardOp[], writer: BoardWriter = "agent"): Board {
|
|
287
|
+
let board = emptyBoard();
|
|
288
|
+
for (const op of ops) {
|
|
289
|
+
const result = applyBoard(board, op, writer);
|
|
290
|
+
if (result.ok) board = result.board;
|
|
291
|
+
}
|
|
292
|
+
return board;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const COLUMNS: ReadonlySet<string> = new Set(BOARD_COLUMNS.map((one) => one.column));
|
|
296
|
+
|
|
297
|
+
/** A shape this version cannot read is skipped, never guessed. */
|
|
298
|
+
export function isBoard(value: unknown): value is Board {
|
|
299
|
+
if (!isObject(value)) return false;
|
|
300
|
+
const { cards, decisions, next } = value;
|
|
301
|
+
if (typeof next !== "number" || !Array.isArray(cards) || !Array.isArray(decisions)) return false;
|
|
302
|
+
return cards.every(isCard) && decisions.every(isDecision);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function isCard(value: unknown): value is BoardCard {
|
|
306
|
+
if (!isObject(value)) return false;
|
|
307
|
+
if (
|
|
308
|
+
typeof value.id !== "string" ||
|
|
309
|
+
typeof value.title !== "string" ||
|
|
310
|
+
typeof value.at !== "number"
|
|
311
|
+
)
|
|
312
|
+
return false;
|
|
313
|
+
if (typeof value.column !== "string" || !COLUMNS.has(value.column)) return false;
|
|
314
|
+
if (value.pr !== undefined && typeof value.pr !== "number") return false;
|
|
315
|
+
if (value.now !== undefined && typeof value.now !== "string") return false;
|
|
316
|
+
if (value.reviewer !== undefined && typeof value.reviewer !== "string") return false;
|
|
317
|
+
if (value.result !== undefined && typeof value.result !== "string") return false;
|
|
318
|
+
if (value.why !== undefined && typeof value.why !== "string") return false;
|
|
319
|
+
return value.blocked === undefined || isBlocker(value.blocked);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function isBlocker(value: unknown): value is BoardBlocker {
|
|
323
|
+
if (!isObject(value) || typeof value.why !== "string") return false;
|
|
324
|
+
if (value.on === "operator" || value.on === "outside") return true;
|
|
325
|
+
return value.on === "agent" && typeof value.handle === "string";
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function isDecision(value: unknown): value is BoardDecision {
|
|
329
|
+
if (!isObject(value)) return false;
|
|
330
|
+
if (
|
|
331
|
+
typeof value.id !== "string" ||
|
|
332
|
+
typeof value.text !== "string" ||
|
|
333
|
+
typeof value.at !== "number"
|
|
334
|
+
)
|
|
335
|
+
return false;
|
|
336
|
+
return value.by === undefined || isBy(value.by);
|
|
337
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// State one feature owns and others read over pi.events. The bus is synchronous, so a read is never
|
|
2
|
+
// stale, and a change is announced with the whole value. Values are JSON, decoded on arrival, since
|
|
3
|
+
// the other side may be another version.
|
|
4
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import { type Decoder, formatProblems, isObject } from "./decode.ts";
|
|
7
|
+
|
|
8
|
+
export interface EventScope {
|
|
9
|
+
readonly events: ExtensionAPI["events"];
|
|
10
|
+
warn(message: string): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type KeyArgs<K> = [K] extends [void] ? [] : [key: K];
|
|
14
|
+
|
|
15
|
+
export interface Contract<T, K extends string | void = void> {
|
|
16
|
+
readonly channel: string;
|
|
17
|
+
/** Undefined when nobody provides it, or when the answer does not decode (which warns). */
|
|
18
|
+
current(scope: EventScope, ...key: KeyArgs<K>): T | undefined;
|
|
19
|
+
/** An undefined answer leaves the question to another provider. */
|
|
20
|
+
provide(scope: EventScope, answer: (key: K) => T | undefined): () => void;
|
|
21
|
+
/** Undefined says there is none any more. */
|
|
22
|
+
changed(scope: EventScope, value: T | undefined, ...key: KeyArgs<K>): void;
|
|
23
|
+
onChanged(scope: EventScope, listener: (value: T | undefined, key: K) => void): () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Question {
|
|
27
|
+
key?: unknown;
|
|
28
|
+
answered?: boolean;
|
|
29
|
+
value?: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Named `<area>` or `<area>:<what>`, like `tasks` or `calls:ruling`. */
|
|
33
|
+
export function defineContract<T, K extends string | void = void>(
|
|
34
|
+
name: string,
|
|
35
|
+
decoder: Decoder<T>,
|
|
36
|
+
): Contract<T, K> {
|
|
37
|
+
const channel = `harness:${name}`;
|
|
38
|
+
const changes = `${channel}:changed`;
|
|
39
|
+
|
|
40
|
+
function decoded(scope: EventScope, value: unknown, what: string): T | undefined {
|
|
41
|
+
const result = decoder.decode(value, name);
|
|
42
|
+
if (result.ok) return result.value;
|
|
43
|
+
scope.warn(`ignored ${what} ${name}: ${formatProblems(result.problems).join("; ")}`);
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
channel,
|
|
49
|
+
current(scope, ...key) {
|
|
50
|
+
const question: Question = { key: key[0] };
|
|
51
|
+
scope.events.emit(channel, question);
|
|
52
|
+
if (question.answered !== true) return undefined;
|
|
53
|
+
return decoded(scope, question.value, "an answer to");
|
|
54
|
+
},
|
|
55
|
+
provide(scope, answer) {
|
|
56
|
+
return scope.events.on(channel, (data) => {
|
|
57
|
+
if (!isObject(data) || data.answered === true) return;
|
|
58
|
+
const value = answer(data.key as K);
|
|
59
|
+
if (value === undefined) return;
|
|
60
|
+
data.value = value;
|
|
61
|
+
data.answered = true;
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
changed(scope, value, ...key) {
|
|
65
|
+
scope.events.emit(changes, { key: key[0], value });
|
|
66
|
+
},
|
|
67
|
+
onChanged(scope, listener) {
|
|
68
|
+
return scope.events.on(changes, (data) => {
|
|
69
|
+
if (!isObject(data)) return;
|
|
70
|
+
if (data.value === undefined) {
|
|
71
|
+
listener(undefined, data.key as K);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const value = decoded(scope, data.value, "a change of");
|
|
75
|
+
if (value !== undefined) listener(value, data.key as K);
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { defineContract } from "../contract.ts";
|
|
2
|
+
import { guarded, isObject } from "../decode.ts";
|
|
3
|
+
|
|
4
|
+
export interface AccountWindow {
|
|
5
|
+
name: string;
|
|
6
|
+
/** Percent used of that window, 0..100. */
|
|
7
|
+
used: number;
|
|
8
|
+
/** Time left in the window, like `11m`. */
|
|
9
|
+
resetsIn?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface AccountState {
|
|
13
|
+
provider: string;
|
|
14
|
+
label: string;
|
|
15
|
+
windows: AccountWindow[];
|
|
16
|
+
needsLogin?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Keyed by provider; nobody answers for a provider with no accounts. */
|
|
20
|
+
export const ACCOUNT = defineContract<AccountState, string>(
|
|
21
|
+
"accounts:state",
|
|
22
|
+
guarded(isAccountState, "an account with a provider, a label and windows"),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
function isAccountState(value: unknown): value is AccountState {
|
|
26
|
+
if (!isObject(value) || typeof value.provider !== "string" || typeof value.label !== "string")
|
|
27
|
+
return false;
|
|
28
|
+
return Array.isArray(value.windows) && value.windows.every(isWindow);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isWindow(value: unknown): value is AccountWindow {
|
|
32
|
+
return isObject(value) && typeof value.name === "string" && typeof value.used === "number";
|
|
33
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// One dialog for every question. The bus is synchronous, so the answer comes on a second channel.
|
|
2
|
+
import { isObject } from "../decode.ts";
|
|
3
|
+
import { GLYPH } from "../ui/glyphs.ts";
|
|
4
|
+
import type { By } from "./by.ts";
|
|
5
|
+
|
|
6
|
+
export const AVAILABLE = "harness:ask:available";
|
|
7
|
+
export const ASK = "harness:ask";
|
|
8
|
+
export const ANSWER = "harness:ask:answer";
|
|
9
|
+
// Only the asker emits it. A provider whose dialog closes on its own answers `cancelled: true`
|
|
10
|
+
// instead, or the asker waits forever.
|
|
11
|
+
export const CANCEL = "harness:ask.cancel";
|
|
12
|
+
|
|
13
|
+
export interface AskOption {
|
|
14
|
+
label: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
/** Shown beside the options while this one is focused. */
|
|
17
|
+
preview?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AskQuestion {
|
|
21
|
+
header: string;
|
|
22
|
+
question: string;
|
|
23
|
+
options: AskOption[];
|
|
24
|
+
multiSelect?: boolean;
|
|
25
|
+
/** A last row for an answer in the user's own words. On unless it is `false`. */
|
|
26
|
+
typed?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AskRequest {
|
|
30
|
+
id: string;
|
|
31
|
+
questions: AskQuestion[];
|
|
32
|
+
/** The asker's own terminal already draws this question, so a provider that draws skips it. */
|
|
33
|
+
drawn?: true;
|
|
34
|
+
answer?: AskResult;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AskNote {
|
|
38
|
+
option: string;
|
|
39
|
+
note: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface AskAnswer {
|
|
43
|
+
question: string;
|
|
44
|
+
header: string;
|
|
45
|
+
picked: string[];
|
|
46
|
+
typed?: string;
|
|
47
|
+
notes: AskNote[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface AskResult {
|
|
51
|
+
answers: AskAnswer[];
|
|
52
|
+
cancelled: boolean;
|
|
53
|
+
error?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface AskReply extends AskResult {
|
|
57
|
+
by?: By;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface AnswerEvent {
|
|
61
|
+
id: string;
|
|
62
|
+
/** Without it the answer is logged as from nobody known, never as the terminal. */
|
|
63
|
+
by?: By;
|
|
64
|
+
result: AskResult;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface AskCancel {
|
|
68
|
+
id: string;
|
|
69
|
+
reason: "answered" | "aborted";
|
|
70
|
+
by?: By;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function isAskResult(value: unknown): value is AskResult {
|
|
74
|
+
if (!isObject(value)) return false;
|
|
75
|
+
return Array.isArray(value.answers) && typeof value.cancelled === "boolean";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function isAnswered(answer: AskAnswer): boolean {
|
|
79
|
+
return answer.picked.length > 0 || (answer.typed ?? "").trim() !== "";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function describeAnswers(answers: readonly AskAnswer[]): string[] {
|
|
83
|
+
const lines: string[] = [];
|
|
84
|
+
for (const answer of answers) {
|
|
85
|
+
lines.push(`- ${answer.header}: "${answer.question}" ${GLYPH.right} ${answerSummary(answer)}`);
|
|
86
|
+
for (const { option, note } of answer.notes) {
|
|
87
|
+
const tag = answer.picked.includes(option) ? "" : " (not picked)";
|
|
88
|
+
lines.push(` note on "${option}"${tag}: ${note}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return lines;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function answerSummary(answer: AskAnswer): string {
|
|
95
|
+
const parts = answer.picked.map((label) => `"${label}"`);
|
|
96
|
+
const typed = answer.typed?.trim();
|
|
97
|
+
if (typed) parts.push(`in their words: "${typed}"`);
|
|
98
|
+
return parts.length === 0 ? "(nothing)" : parts.join(", ");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function decodeAskRequest(value: unknown): string | undefined {
|
|
102
|
+
if (!isObject(value)) return "not an object";
|
|
103
|
+
if (typeof value.id !== "string") return "no id";
|
|
104
|
+
if (!Array.isArray(value.questions)) return "no questions";
|
|
105
|
+
for (const question of value.questions) {
|
|
106
|
+
if (!isObject(question)) return "a question is not an object";
|
|
107
|
+
if (typeof question.header !== "string" || typeof question.question !== "string")
|
|
108
|
+
return "a question has no header or text";
|
|
109
|
+
if (!Array.isArray(question.options)) return `"${question.header}" has no options`;
|
|
110
|
+
if (question.options.length === 0 && question.typed === false)
|
|
111
|
+
return `"${question.header}" has no options and no typed row`;
|
|
112
|
+
if (question.typed !== undefined && typeof question.typed !== "boolean")
|
|
113
|
+
return `"${question.header}" has a typed flag that is not a boolean`;
|
|
114
|
+
for (const option of question.options) {
|
|
115
|
+
if (!isObject(option) || typeof option.label !== "string")
|
|
116
|
+
return `"${question.header}" has an option with no label`;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Offered whole, since every reader needs the same state.
|
|
2
|
+
import { isBoard, type Board } from "../board/model.ts";
|
|
3
|
+
import { defineContract } from "../contract.ts";
|
|
4
|
+
import { guarded } from "../decode.ts";
|
|
5
|
+
|
|
6
|
+
export const BOARD = defineContract<Board>("board", guarded(isBoard, "a board"));
|