@polderlabs/bizar-plugin 0.5.4
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/LICENSE +21 -0
- package/README.md +448 -0
- package/bun.lock +88 -0
- package/index.ts +1113 -0
- package/package.json +42 -0
- package/scripts/check-forbidden-imports.sh +33 -0
- package/src/background-state.ts +463 -0
- package/src/background.ts +964 -0
- package/src/commands-impl.ts +369 -0
- package/src/commands.ts +880 -0
- package/src/event-stream.ts +574 -0
- package/src/fingerprint.ts +120 -0
- package/src/handoff.ts +79 -0
- package/src/http-client.ts +467 -0
- package/src/logger.ts +144 -0
- package/src/loop.ts +176 -0
- package/src/options.ts +421 -0
- package/src/plan-fs.ts +323 -0
- package/src/report.ts +178 -0
- package/src/research-prompt.ts +35 -0
- package/src/serve.ts +476 -0
- package/src/settings.ts +349 -0
- package/src/state.ts +298 -0
- package/src/tools/bg-collect.ts +104 -0
- package/src/tools/bg-get-comments.ts +239 -0
- package/src/tools/bg-kill.ts +87 -0
- package/src/tools/bg-spawn.ts +263 -0
- package/src/tools/bg-status.ts +99 -0
- package/src/tools/plan-action.ts +767 -0
- package/src/tools/wait-for-feedback.ts +402 -0
- package/tests/attach-handler-bug.test.ts +166 -0
- package/tests/background-state.test.ts +277 -0
- package/tests/background.test.ts +402 -0
- package/tests/block.test.ts +193 -0
- package/tests/canonical-key-order.test.ts +71 -0
- package/tests/commands-impl.test.ts +442 -0
- package/tests/commands.test.ts +548 -0
- package/tests/config.test.ts +122 -0
- package/tests/dispose.test.ts +336 -0
- package/tests/event-stream.test.ts +409 -0
- package/tests/event.test.ts +262 -0
- package/tests/fingerprint.test.ts +161 -0
- package/tests/http-client.test.ts +403 -0
- package/tests/init-helpers.test.ts +203 -0
- package/tests/integration/slash-command.test.ts +348 -0
- package/tests/integration/tool-routing.test.ts +314 -0
- package/tests/loop.test.ts +397 -0
- package/tests/options.test.ts +274 -0
- package/tests/serve.test.ts +335 -0
- package/tests/settings.test.ts +351 -0
- package/tests/stall-think.test.ts +749 -0
- package/tests/state.test.ts +275 -0
- package/tests/tools/bg-collect.test.ts +337 -0
- package/tests/tools/bg-get-comments.test.ts +485 -0
- package/tests/tools/bg-kill.test.ts +231 -0
- package/tests/tools/bg-spawn.test.ts +311 -0
- package/tests/tools/bg-status.test.ts +216 -0
- package/tests/tools/plan-action.test.ts +599 -0
- package/tests/tools/wait-for-feedback.test.ts +390 -0
- package/tsconfig.json +29 -0
package/src/commands.ts
ADDED
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commands.ts
|
|
3
|
+
*
|
|
4
|
+
* v0.5.0 — Pure slash-command parser.
|
|
5
|
+
*
|
|
6
|
+
* The plugin's `chat.message` hook calls `parseSlashCommand(text, ctx)`
|
|
7
|
+
* on every user message. If the message starts with `/`, this function
|
|
8
|
+
* classifies it as a known command or returns `null` (so the message
|
|
9
|
+
* passes through to the LLM normally).
|
|
10
|
+
*
|
|
11
|
+
* Design constraints:
|
|
12
|
+
* - Pure function. No I/O, no environment reads, no Date.now() calls
|
|
13
|
+
* inside the parser itself.
|
|
14
|
+
* - The function is the **only** place slash commands are parsed.
|
|
15
|
+
* The hook gathers context (current settings, available plan slugs)
|
|
16
|
+
* and feeds it to the parser, then dispatches the returned
|
|
17
|
+
* `sideEffect` via `executeSideEffect()` in commands-impl.ts.
|
|
18
|
+
* - Returns `null` for non-slash messages so the LLM still sees them.
|
|
19
|
+
*
|
|
20
|
+
* Output shape (per slash command):
|
|
21
|
+
* `{ handled: true, response, settingsPatch?, sideEffect? }`
|
|
22
|
+
*
|
|
23
|
+
* - `response`: text surfaced to the user / LLM.
|
|
24
|
+
* - `settingsPatch`: optional partial `PlanSettings` to apply.
|
|
25
|
+
* - `sideEffect`: optional descriptor for the hook to execute. The
|
|
26
|
+
* `tool_invocation` kind routes through `executeToolInvocation`,
|
|
27
|
+
* which builds a synthetic `ToolContext` and invokes the
|
|
28
|
+
* registered tool directly. The other kinds (`create_plan`,
|
|
29
|
+
* `list_plans`, `open_plan_url`) are handled by
|
|
30
|
+
* `executeSideEffect` in commands-impl.ts.
|
|
31
|
+
*
|
|
32
|
+
* The parser is unit-tested directly (tests/commands.test.ts).
|
|
33
|
+
* Side-effect execution is tested in tests/commands-impl.test.ts.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import type { DefaultTemplate, PlanSettings } from "./settings.js";
|
|
37
|
+
import { KNOWN_TEMPLATES } from "./settings.js";
|
|
38
|
+
|
|
39
|
+
// --- Public types --------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* What the parser asks the chat.message hook to do on the host side.
|
|
43
|
+
*
|
|
44
|
+
* v0.5.0 — extended with the `tool_invocation` kind (per C3). Subcommand
|
|
45
|
+
* parsers translate the user's typed subcommand into a `tool_invocation`
|
|
46
|
+
* side-effect that calls a real `bizar_*` tool with a synthetic
|
|
47
|
+
* `ToolContext`. The chat hook does not need to know about the tool
|
|
48
|
+
* layer; it just hands the descriptor to `executeToolInvocation`.
|
|
49
|
+
*/
|
|
50
|
+
export type SideEffect =
|
|
51
|
+
| {
|
|
52
|
+
kind: "create_plan";
|
|
53
|
+
slug: string;
|
|
54
|
+
template: DefaultTemplate | null;
|
|
55
|
+
}
|
|
56
|
+
| {
|
|
57
|
+
kind: "open_plan_url";
|
|
58
|
+
slug: string;
|
|
59
|
+
}
|
|
60
|
+
| {
|
|
61
|
+
kind: "list_plans";
|
|
62
|
+
}
|
|
63
|
+
| {
|
|
64
|
+
kind: "tool_invocation";
|
|
65
|
+
toolName: string;
|
|
66
|
+
args: unknown;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export interface SlashCommandResult {
|
|
70
|
+
handled: true;
|
|
71
|
+
/** Text shown to the user / LLM. */
|
|
72
|
+
response: string;
|
|
73
|
+
/** Optional settings mutation. */
|
|
74
|
+
settingsPatch?: Partial<PlanSettings>;
|
|
75
|
+
/** Optional side-effect to perform (file I/O lives in the hook, not here). */
|
|
76
|
+
sideEffect?: SideEffect;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Context passed to the parser. The parser does NOT call `getCurrentSettings`
|
|
81
|
+
* itself — instead the caller injects the live values. This keeps the parser
|
|
82
|
+
* pure and lets tests stub the context.
|
|
83
|
+
*/
|
|
84
|
+
export interface ParseContext {
|
|
85
|
+
/** Current plan settings (already loaded by the caller). */
|
|
86
|
+
currentSettings: PlanSettings;
|
|
87
|
+
/** Slugs of plans that exist in the worktree's plans/ directory. */
|
|
88
|
+
availablePlanSlugs?: readonly string[];
|
|
89
|
+
/**
|
|
90
|
+
* The default port for `/plan open <slug>` URLs. The viewer runs in-process
|
|
91
|
+
* on 127.0.0.1:4321-4330. v0.5.0 defers actually starting the server; the
|
|
92
|
+
* URL is informational only.
|
|
93
|
+
*/
|
|
94
|
+
defaultPort?: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --- Internals -----------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
/** Same slug rule used by `cli/plan.mjs`. */
|
|
100
|
+
const SLUG_REGEX = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
101
|
+
|
|
102
|
+
function isValidSlug(s: string): boolean {
|
|
103
|
+
return SLUG_REGEX.test(s);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isKnownTemplate(t: string): t is DefaultTemplate {
|
|
107
|
+
return (KNOWN_TEMPLATES as readonly string[]).includes(t);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function titleCase(slug: string): string {
|
|
111
|
+
return slug
|
|
112
|
+
.split(/[-_]/)
|
|
113
|
+
.map((w) => (w.length === 0 ? w : w[0]!.toUpperCase() + w.slice(1)))
|
|
114
|
+
.join(" ");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Valid status values for `/plan status`. Mirrors plan-action.ts. */
|
|
118
|
+
const PLAN_STATUSES = [
|
|
119
|
+
"draft",
|
|
120
|
+
"approved",
|
|
121
|
+
"rejected",
|
|
122
|
+
"in-progress",
|
|
123
|
+
"done",
|
|
124
|
+
] as const;
|
|
125
|
+
type PlanStatus = (typeof PLAN_STATUSES)[number];
|
|
126
|
+
|
|
127
|
+
function isPlanStatus(s: string): s is PlanStatus {
|
|
128
|
+
return (PLAN_STATUSES as readonly string[]).includes(s);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- Tokenization --------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Tokenize a subcommand argument string into argv-like tokens, with
|
|
135
|
+
* double-quoted strings preserved as a single token. This is just
|
|
136
|
+
* enough for our flag parser; it does not support escape sequences
|
|
137
|
+
* beyond `\"`.
|
|
138
|
+
*
|
|
139
|
+
* Examples:
|
|
140
|
+
* `add foo --title "Hello world" --type task` →
|
|
141
|
+
* ["add", "foo", "--title", "Hello world", "--type", "task"]
|
|
142
|
+
* `comment foo "Make this bigger"` →
|
|
143
|
+
* ["comment", "foo", "Make this bigger"]
|
|
144
|
+
*/
|
|
145
|
+
function tokenize(input: string): string[] {
|
|
146
|
+
const tokens: string[] = [];
|
|
147
|
+
let cur = "";
|
|
148
|
+
let inQuotes = false;
|
|
149
|
+
for (let i = 0; i < input.length; i++) {
|
|
150
|
+
const ch = input[i]!;
|
|
151
|
+
if (inQuotes) {
|
|
152
|
+
if (ch === "\\" && i + 1 < input.length && input[i + 1] === '"') {
|
|
153
|
+
cur += '"';
|
|
154
|
+
i++;
|
|
155
|
+
} else if (ch === '"') {
|
|
156
|
+
inQuotes = false;
|
|
157
|
+
} else {
|
|
158
|
+
cur += ch;
|
|
159
|
+
}
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (ch === '"') {
|
|
163
|
+
inQuotes = true;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (ch === " " || ch === "\t") {
|
|
167
|
+
if (cur !== "") {
|
|
168
|
+
tokens.push(cur);
|
|
169
|
+
cur = "";
|
|
170
|
+
}
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
cur += ch;
|
|
174
|
+
}
|
|
175
|
+
if (cur !== "") tokens.push(cur);
|
|
176
|
+
return tokens;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
interface ParsedFlags {
|
|
180
|
+
positional: string[];
|
|
181
|
+
flags: Record<string, string>;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Parse a flag list of the form `--key value` (or `--key=value`).
|
|
186
|
+
* Positional arguments (no leading `--`) come back in order. Flags
|
|
187
|
+
* without a value (e.g. `--verbose`) are stored as `""`.
|
|
188
|
+
*/
|
|
189
|
+
function parseFlags(tokens: string[]): ParsedFlags {
|
|
190
|
+
const positional: string[] = [];
|
|
191
|
+
const flags: Record<string, string> = {};
|
|
192
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
193
|
+
const t = tokens[i]!;
|
|
194
|
+
if (t.startsWith("--")) {
|
|
195
|
+
const eq = t.indexOf("=");
|
|
196
|
+
if (eq !== -1) {
|
|
197
|
+
flags[t.slice(2, eq)] = t.slice(eq + 1);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const key = t.slice(2);
|
|
201
|
+
const next = tokens[i + 1];
|
|
202
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
203
|
+
flags[key] = next;
|
|
204
|
+
i++;
|
|
205
|
+
} else {
|
|
206
|
+
flags[key] = "";
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
positional.push(t);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return { positional, flags };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// --- The main parser -----------------------------------------------------
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Parse a user message and decide whether it's a slash command.
|
|
219
|
+
*
|
|
220
|
+
* Returns:
|
|
221
|
+
* - `null` — not a slash command; pass through.
|
|
222
|
+
* - `SlashCommandResult` — slash command was recognized.
|
|
223
|
+
*
|
|
224
|
+
* Recognized commands:
|
|
225
|
+
* - `/visual-plan on|off|status` — settings patch only
|
|
226
|
+
* - `/plan new <slug> [template]` — create_plan
|
|
227
|
+
* - `/plan list` — list_plans
|
|
228
|
+
* - `/plan open <slug>` — open_plan_url
|
|
229
|
+
* - `/plan get <slug>` — tool_invocation: bizar_plan_action (get_canvas)
|
|
230
|
+
* - `/plan add <slug> [--title … --type … --x N --y N …]`
|
|
231
|
+
* — tool_invocation: bizar_plan_action (add_element)
|
|
232
|
+
* - `/plan update <slug> <id> [--x N --y N --title T --content C]`
|
|
233
|
+
* — tool_invocation: bizar_plan_action (update_element)
|
|
234
|
+
* - `/plan delete <slug> <id>` — tool_invocation: bizar_plan_action (delete_element)
|
|
235
|
+
* - `/plan comment <slug> [id] "text"`
|
|
236
|
+
* — tool_invocation: bizar_plan_action (add_comment)
|
|
237
|
+
* - `/plan status <slug> <status>` — tool_invocation: bizar_plan_action (set_status)
|
|
238
|
+
* - `/plan comments <slug> [id]` — tool_invocation: bizar_get_plan_comments
|
|
239
|
+
* - `/plan wait <slug> [--timeout N]`
|
|
240
|
+
* — deferred (returns response; no tool call)
|
|
241
|
+
* - `/help` / `/commands` — help text
|
|
242
|
+
*/
|
|
243
|
+
export function parseSlashCommand(
|
|
244
|
+
text: string,
|
|
245
|
+
ctx: ParseContext,
|
|
246
|
+
): SlashCommandResult | null {
|
|
247
|
+
if (typeof text !== "string") return null;
|
|
248
|
+
const trimmed = text.trim();
|
|
249
|
+
if (trimmed === "") return null;
|
|
250
|
+
if (!trimmed.startsWith("/")) return null;
|
|
251
|
+
|
|
252
|
+
// Just a bare slash — not a command
|
|
253
|
+
if (trimmed === "/") return null;
|
|
254
|
+
|
|
255
|
+
// Match `/<command>` optionally followed by whitespace and args
|
|
256
|
+
const match = /^\/([a-zA-Z][\w-]*)(?:\s+(.*))?$/.exec(trimmed);
|
|
257
|
+
if (match === null) return null;
|
|
258
|
+
|
|
259
|
+
const command = match[1]!.toLowerCase();
|
|
260
|
+
const rest = (match[2] ?? "").trim();
|
|
261
|
+
|
|
262
|
+
switch (command) {
|
|
263
|
+
case "visual-plan":
|
|
264
|
+
return handleVisualPlan(rest, ctx);
|
|
265
|
+
case "plan":
|
|
266
|
+
return handlePlan(rest, ctx);
|
|
267
|
+
case "help":
|
|
268
|
+
case "commands":
|
|
269
|
+
return helpResult();
|
|
270
|
+
default:
|
|
271
|
+
return {
|
|
272
|
+
handled: true,
|
|
273
|
+
response: `Unknown command: /${command}. Try /help for a list of available commands.`,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// --- Command handlers ----------------------------------------------------
|
|
279
|
+
|
|
280
|
+
function handleVisualPlan(arg: string, ctx: ParseContext): SlashCommandResult {
|
|
281
|
+
const lc = arg.toLowerCase();
|
|
282
|
+
|
|
283
|
+
if (lc === "") {
|
|
284
|
+
// No argument — return current state
|
|
285
|
+
const on = ctx.currentSettings.visualPlanEnabled ? "on" : "off";
|
|
286
|
+
return {
|
|
287
|
+
handled: true,
|
|
288
|
+
response:
|
|
289
|
+
`Visual plan mode is **${on}**.\n` +
|
|
290
|
+
`Default template: ${ctx.currentSettings.defaultTemplate}\n` +
|
|
291
|
+
(ctx.currentSettings.lastUsedSlug
|
|
292
|
+
? `Last used plan: ${ctx.currentSettings.lastUsedSlug}\n`
|
|
293
|
+
: "") +
|
|
294
|
+
`\nUsage: /visual-plan on | off`,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (lc === "on" || lc === "true" || lc === "1" || lc === "enable") {
|
|
299
|
+
return {
|
|
300
|
+
handled: true,
|
|
301
|
+
response: "Visual plan mode is now **on**. The agent will create a plan and wait for feedback on complex tasks.",
|
|
302
|
+
settingsPatch: { visualPlanEnabled: true },
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (lc === "off" || lc === "false" || lc === "0" || lc === "disable") {
|
|
307
|
+
return {
|
|
308
|
+
handled: true,
|
|
309
|
+
response: "Visual plan mode is now **off**.",
|
|
310
|
+
settingsPatch: { visualPlanEnabled: false },
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (lc === "status" || lc === "state" || lc === "?") {
|
|
315
|
+
const on = ctx.currentSettings.visualPlanEnabled ? "on" : "off";
|
|
316
|
+
return {
|
|
317
|
+
handled: true,
|
|
318
|
+
response: `Visual plan mode: ${on}`,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
handled: true,
|
|
324
|
+
response: `Unknown argument to /visual-plan: "${arg}". Use "on" or "off".`,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function handlePlan(arg: string, ctx: ParseContext): SlashCommandResult {
|
|
329
|
+
if (arg === "") {
|
|
330
|
+
return helpPlan();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Split subcommand from its args
|
|
334
|
+
const tokens = tokenize(arg);
|
|
335
|
+
const sub = (tokens[0] ?? "").toLowerCase();
|
|
336
|
+
const subTokens = tokens.slice(1);
|
|
337
|
+
|
|
338
|
+
switch (sub) {
|
|
339
|
+
case "new":
|
|
340
|
+
return handlePlanNew(subTokens, ctx);
|
|
341
|
+
case "list":
|
|
342
|
+
case "ls":
|
|
343
|
+
return handlePlanList(ctx);
|
|
344
|
+
case "open":
|
|
345
|
+
return handlePlanOpen(subTokens, ctx);
|
|
346
|
+
case "get":
|
|
347
|
+
return handlePlanGet(subTokens);
|
|
348
|
+
case "add":
|
|
349
|
+
return handlePlanAdd(subTokens);
|
|
350
|
+
case "update":
|
|
351
|
+
return handlePlanUpdate(subTokens);
|
|
352
|
+
case "delete":
|
|
353
|
+
case "del":
|
|
354
|
+
case "rm":
|
|
355
|
+
return handlePlanDelete(subTokens);
|
|
356
|
+
case "comment":
|
|
357
|
+
case "comments":
|
|
358
|
+
// `/plan comment` is the add-comment verb; `/plan comments` is the
|
|
359
|
+
// list-comments verb. Disambiguate by looking at the first token.
|
|
360
|
+
if (sub === "comments") return handlePlanComments(subTokens);
|
|
361
|
+
return handlePlanComment(subTokens);
|
|
362
|
+
case "status":
|
|
363
|
+
return handlePlanStatus(subTokens);
|
|
364
|
+
case "comments-list":
|
|
365
|
+
return handlePlanComments(subTokens);
|
|
366
|
+
case "wait":
|
|
367
|
+
return handlePlanWait(subTokens);
|
|
368
|
+
default:
|
|
369
|
+
return {
|
|
370
|
+
handled: true,
|
|
371
|
+
response: `Unknown /plan subcommand: "${sub}". Try /plan for usage.`,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function helpPlan(): SlashCommandResult {
|
|
377
|
+
return {
|
|
378
|
+
handled: true,
|
|
379
|
+
response:
|
|
380
|
+
`Plan commands:\n` +
|
|
381
|
+
` /plan new <slug> [template] — Create a new plan\n` +
|
|
382
|
+
` /plan list — List all plans in the worktree\n` +
|
|
383
|
+
` /plan open <slug> — Return the URL for a plan\n` +
|
|
384
|
+
` /plan get <slug> — Fetch the full canvas\n` +
|
|
385
|
+
` /plan add <slug> --title T --type kind — Add an element to a plan\n` +
|
|
386
|
+
` /plan update <slug> <id> [--x N --y N …] — Patch an existing element\n` +
|
|
387
|
+
` /plan delete <slug> <id> — Remove an element\n` +
|
|
388
|
+
` /plan comment <slug> [id] "text" — Add a comment (canvas-pinned if no id)\n` +
|
|
389
|
+
` /plan comments <slug> [id] — Read comments on a plan\n` +
|
|
390
|
+
` /plan status <slug> <status> — Set the plan's status\n` +
|
|
391
|
+
` /plan wait <slug> [--timeout N] — Wait for feedback (deferred — see note)\n` +
|
|
392
|
+
`\nAvailable templates: ${KNOWN_TEMPLATES.join(", ")}\n` +
|
|
393
|
+
`Available statuses: ${PLAN_STATUSES.join(", ")}`,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// --- /plan new ------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
function handlePlanNew(args: string[], ctx: ParseContext): SlashCommandResult {
|
|
400
|
+
if (args.length === 0 || args[0] === "") {
|
|
401
|
+
return {
|
|
402
|
+
handled: true,
|
|
403
|
+
response: "Usage: /plan new <slug> [template]",
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const slug = args[0]!;
|
|
408
|
+
if (!isValidSlug(slug)) {
|
|
409
|
+
return {
|
|
410
|
+
handled: true,
|
|
411
|
+
response:
|
|
412
|
+
`Invalid slug "${slug}". Slug must be lowercase, may contain hyphens, ` +
|
|
413
|
+
`must start with an alphanumeric character, and be 1–64 characters.`,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Optional second arg is the template name
|
|
418
|
+
let template: DefaultTemplate | null = null;
|
|
419
|
+
if (args.length >= 2 && args[1] !== "") {
|
|
420
|
+
const candidate = args[1]!.toLowerCase();
|
|
421
|
+
if (!isKnownTemplate(candidate)) {
|
|
422
|
+
return {
|
|
423
|
+
handled: true,
|
|
424
|
+
response:
|
|
425
|
+
`Unknown template "${args[1]}". Available: ${KNOWN_TEMPLATES.join(", ")}.`,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
template = candidate;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// The response surfaces the resolved template name (the user-supplied
|
|
432
|
+
// argument, or the user's current default). The sideEffect carries
|
|
433
|
+
// the explicit `null` so the executor knows to fall back to
|
|
434
|
+
// `currentSettings.defaultTemplate` at write time.
|
|
435
|
+
const resolvedTemplate = template ?? ctx.currentSettings.defaultTemplate;
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
handled: true,
|
|
439
|
+
response:
|
|
440
|
+
`Plan "${titleCase(slug)}" (slug: ${slug}) will be created with the ` +
|
|
441
|
+
`"${resolvedTemplate}" template.\n` +
|
|
442
|
+
`After creation, use /plan open ${slug} to get the URL.`,
|
|
443
|
+
sideEffect: {
|
|
444
|
+
kind: "create_plan",
|
|
445
|
+
slug,
|
|
446
|
+
template,
|
|
447
|
+
},
|
|
448
|
+
settingsPatch: { lastUsedSlug: slug },
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// --- /plan list ----------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
function handlePlanList(ctx: ParseContext): SlashCommandResult {
|
|
455
|
+
const slugs = ctx.availablePlanSlugs ?? [];
|
|
456
|
+
if (slugs.length === 0) {
|
|
457
|
+
return {
|
|
458
|
+
handled: true,
|
|
459
|
+
response:
|
|
460
|
+
"No plans found in this worktree. Use /plan new <slug> to create one.",
|
|
461
|
+
sideEffect: { kind: "list_plans" },
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
const lines = slugs.map((s) => ` - ${s}`);
|
|
465
|
+
return {
|
|
466
|
+
handled: true,
|
|
467
|
+
response: `Plans in this worktree (${slugs.length}):\n${lines.join("\n")}`,
|
|
468
|
+
sideEffect: { kind: "list_plans" },
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// --- /plan open ----------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
function handlePlanOpen(args: string[], ctx: ParseContext): SlashCommandResult {
|
|
475
|
+
if (args.length === 0 || args[0] === "") {
|
|
476
|
+
return {
|
|
477
|
+
handled: true,
|
|
478
|
+
response: "Usage: /plan open <slug>",
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const slug = args[0]!;
|
|
483
|
+
if (!isValidSlug(slug)) {
|
|
484
|
+
return {
|
|
485
|
+
handled: true,
|
|
486
|
+
response: `Invalid slug "${slug}". Slug must match ^[a-z0-9][a-z0-9-]{0,63}$.`,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const port = ctx.defaultPort ?? 4321;
|
|
491
|
+
const url = `http://localhost:${port}/${slug}/`;
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
handled: true,
|
|
495
|
+
response:
|
|
496
|
+
`Plan URL: ${url}\n` +
|
|
497
|
+
`(v0.5.0 MVP — server startup is a future enhancement; the URL is ` +
|
|
498
|
+
`informational. Use "bizar plan open ${slug}" in the terminal to ` +
|
|
499
|
+
`start the local viewer.)`,
|
|
500
|
+
settingsPatch: { lastUsedSlug: slug },
|
|
501
|
+
sideEffect: {
|
|
502
|
+
kind: "open_plan_url",
|
|
503
|
+
slug,
|
|
504
|
+
},
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// --- /plan get -----------------------------------------------------------
|
|
509
|
+
|
|
510
|
+
function handlePlanGet(args: string[]): SlashCommandResult {
|
|
511
|
+
if (args.length === 0 || args[0] === "") {
|
|
512
|
+
return { handled: true, response: "Usage: /plan get <slug>" };
|
|
513
|
+
}
|
|
514
|
+
const slug = args[0]!;
|
|
515
|
+
if (!isValidSlug(slug)) {
|
|
516
|
+
return {
|
|
517
|
+
handled: true,
|
|
518
|
+
response: `Invalid slug "${slug}". Slug must match ^[a-z0-9][a-z0-9-]{0,63}$.`,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
return {
|
|
522
|
+
handled: true,
|
|
523
|
+
response: `Fetching canvas for plan "${slug}"…`,
|
|
524
|
+
sideEffect: {
|
|
525
|
+
kind: "tool_invocation",
|
|
526
|
+
toolName: "bizar_plan_action",
|
|
527
|
+
args: { action: "get_canvas", planSlug: slug },
|
|
528
|
+
},
|
|
529
|
+
settingsPatch: { lastUsedSlug: slug },
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// --- /plan add -----------------------------------------------------------
|
|
534
|
+
|
|
535
|
+
const ELEMENT_FLAG_KEYS = [
|
|
536
|
+
"title",
|
|
537
|
+
"content",
|
|
538
|
+
"type",
|
|
539
|
+
"id",
|
|
540
|
+
] as const;
|
|
541
|
+
type ElementFlagKey = (typeof ELEMENT_FLAG_KEYS)[number];
|
|
542
|
+
|
|
543
|
+
const ELEMENT_NUMERIC_FLAG_KEYS = ["x", "y", "width", "height"] as const;
|
|
544
|
+
type ElementNumericFlagKey = (typeof ELEMENT_NUMERIC_FLAG_KEYS)[number];
|
|
545
|
+
|
|
546
|
+
function readElementFlags(flags: Record<string, string>): Record<string, unknown> {
|
|
547
|
+
const out: Record<string, unknown> = {};
|
|
548
|
+
for (const k of ELEMENT_FLAG_KEYS) {
|
|
549
|
+
const v = flags[k];
|
|
550
|
+
if (v !== undefined && v !== "") out[k] = v;
|
|
551
|
+
}
|
|
552
|
+
for (const k of ELEMENT_NUMERIC_FLAG_KEYS) {
|
|
553
|
+
const v = flags[k];
|
|
554
|
+
if (v !== undefined && v !== "") {
|
|
555
|
+
const n = Number(v);
|
|
556
|
+
if (Number.isFinite(n)) out[k] = n;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return out;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function handlePlanAdd(args: string[]): SlashCommandResult {
|
|
563
|
+
if (args.length === 0 || args[0] === "") {
|
|
564
|
+
return {
|
|
565
|
+
handled: true,
|
|
566
|
+
response: 'Usage: /plan add <slug> --title "Title" --type task [--x N --y N --width N --height N --content "…"]',
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
const slug = args[0]!;
|
|
570
|
+
if (!isValidSlug(slug)) {
|
|
571
|
+
return {
|
|
572
|
+
handled: true,
|
|
573
|
+
response: `Invalid slug "${slug}". Slug must match ^[a-z0-9][a-z0-9-]{0,63}$.`,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
const { flags } = parseFlags(args.slice(1));
|
|
577
|
+
const element = readElementFlags(flags);
|
|
578
|
+
if (Object.keys(element).length === 0) {
|
|
579
|
+
return {
|
|
580
|
+
handled: true,
|
|
581
|
+
response:
|
|
582
|
+
`Usage: /plan add ${slug} --title "Title" --type task [--x N --y N --width N --height N --content "…"]\n` +
|
|
583
|
+
`At least one of --title / --type / --content / --x / --y is required.`,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
return {
|
|
587
|
+
handled: true,
|
|
588
|
+
response: `Adding element to plan "${slug}"…`,
|
|
589
|
+
sideEffect: {
|
|
590
|
+
kind: "tool_invocation",
|
|
591
|
+
toolName: "bizar_plan_action",
|
|
592
|
+
args: { action: "add_element", planSlug: slug, element },
|
|
593
|
+
},
|
|
594
|
+
settingsPatch: { lastUsedSlug: slug },
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// --- /plan update --------------------------------------------------------
|
|
599
|
+
|
|
600
|
+
function handlePlanUpdate(args: string[]): SlashCommandResult {
|
|
601
|
+
if (args.length < 2 || args[0] === "" || args[1] === "") {
|
|
602
|
+
return {
|
|
603
|
+
handled: true,
|
|
604
|
+
response:
|
|
605
|
+
'Usage: /plan update <slug> <elementId> [--x N --y N --title T --content C --width N --height N --type kind]',
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
const slug = args[0]!;
|
|
609
|
+
const elementId = args[1]!;
|
|
610
|
+
if (!isValidSlug(slug)) {
|
|
611
|
+
return {
|
|
612
|
+
handled: true,
|
|
613
|
+
response: `Invalid slug "${slug}". Slug must match ^[a-z0-9][a-z0-9-]{0,63}$.`,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
if (elementId === "") {
|
|
617
|
+
return {
|
|
618
|
+
handled: true,
|
|
619
|
+
response: "Element id cannot be empty.",
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
const { flags } = parseFlags(args.slice(2));
|
|
623
|
+
const element = readElementFlags(flags);
|
|
624
|
+
if (Object.keys(element).length === 0) {
|
|
625
|
+
return {
|
|
626
|
+
handled: true,
|
|
627
|
+
response:
|
|
628
|
+
`Usage: /plan update ${slug} ${elementId} [--x N --y N --title T --content C --width N --height N --type kind]\n` +
|
|
629
|
+
`At least one update flag is required.`,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
return {
|
|
633
|
+
handled: true,
|
|
634
|
+
response: `Updating element ${elementId} in plan "${slug}"…`,
|
|
635
|
+
sideEffect: {
|
|
636
|
+
kind: "tool_invocation",
|
|
637
|
+
toolName: "bizar_plan_action",
|
|
638
|
+
args: { action: "update_element", planSlug: slug, elementId, element },
|
|
639
|
+
},
|
|
640
|
+
settingsPatch: { lastUsedSlug: slug },
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// --- /plan delete --------------------------------------------------------
|
|
645
|
+
|
|
646
|
+
function handlePlanDelete(args: string[]): SlashCommandResult {
|
|
647
|
+
if (args.length < 2 || args[0] === "" || args[1] === "") {
|
|
648
|
+
return {
|
|
649
|
+
handled: true,
|
|
650
|
+
response: "Usage: /plan delete <slug> <elementId>",
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
const slug = args[0]!;
|
|
654
|
+
const elementId = args[1]!;
|
|
655
|
+
if (!isValidSlug(slug)) {
|
|
656
|
+
return {
|
|
657
|
+
handled: true,
|
|
658
|
+
response: `Invalid slug "${slug}". Slug must match ^[a-z0-9][a-z0-9-]{0,63}$.`,
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
return {
|
|
662
|
+
handled: true,
|
|
663
|
+
response: `Deleting element ${elementId} from plan "${slug}"…`,
|
|
664
|
+
sideEffect: {
|
|
665
|
+
kind: "tool_invocation",
|
|
666
|
+
toolName: "bizar_plan_action",
|
|
667
|
+
args: { action: "delete_element", planSlug: slug, elementId },
|
|
668
|
+
},
|
|
669
|
+
settingsPatch: { lastUsedSlug: slug },
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// --- /plan comment (add) -------------------------------------------------
|
|
674
|
+
|
|
675
|
+
function handlePlanComment(args: string[]): SlashCommandResult {
|
|
676
|
+
// /plan comment <slug> [elementId] "text"
|
|
677
|
+
// The text is the LAST positional arg. The slug is first. The middle
|
|
678
|
+
// arg (if any) is the elementId. We re-tokenize the raw arg string
|
|
679
|
+
// (NOT the tokenized form) so that quoted text is preserved as a
|
|
680
|
+
// single token. We've already tokenized at the parent level, so
|
|
681
|
+
// we re-tokenize the raw form here by joining back together.
|
|
682
|
+
// The parent passes a tokenized array, so we look at the trailing
|
|
683
|
+
// token and assume the text is the last non-flag argument. The
|
|
684
|
+
// parent already did quote-preserving tokenization, so trailing
|
|
685
|
+
// tokens after the slug/elementId are the text.
|
|
686
|
+
if (args.length < 2 || args[0] === "") {
|
|
687
|
+
return {
|
|
688
|
+
handled: true,
|
|
689
|
+
response: 'Usage: /plan comment <slug> [elementId] "text"',
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
const slug = args[0]!;
|
|
693
|
+
if (!isValidSlug(slug)) {
|
|
694
|
+
return {
|
|
695
|
+
handled: true,
|
|
696
|
+
response: `Invalid slug "${slug}". Slug must match ^[a-z0-9][a-z0-9-]{0,63}$.`,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
// Disambiguate: if the second token is a flag, then there's no
|
|
700
|
+
// elementId and the rest is the text.
|
|
701
|
+
if (args[1]!.startsWith("--")) {
|
|
702
|
+
// No elementId; everything after slug is text.
|
|
703
|
+
const text = args.slice(1).join(" ").trim();
|
|
704
|
+
if (text === "") {
|
|
705
|
+
return { handled: true, response: "Comment text is required." };
|
|
706
|
+
}
|
|
707
|
+
return commentSideEffect(slug, null, text);
|
|
708
|
+
}
|
|
709
|
+
// If exactly 2 args, treat second as text (no elementId).
|
|
710
|
+
if (args.length === 2) {
|
|
711
|
+
return commentSideEffect(slug, null, args[1]!);
|
|
712
|
+
}
|
|
713
|
+
// 3+ args: slug, elementId, text. The text may itself be quoted
|
|
714
|
+
// already (the parent tokenizer preserves quoted strings), so we
|
|
715
|
+
// join the rest with spaces.
|
|
716
|
+
const elementId = args[1]!;
|
|
717
|
+
const text = args.slice(2).join(" ").trim();
|
|
718
|
+
if (text === "") {
|
|
719
|
+
return { handled: true, response: "Comment text is required." };
|
|
720
|
+
}
|
|
721
|
+
return commentSideEffect(slug, elementId, text);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function commentSideEffect(
|
|
725
|
+
slug: string,
|
|
726
|
+
elementId: string | null,
|
|
727
|
+
text: string,
|
|
728
|
+
): SlashCommandResult {
|
|
729
|
+
return {
|
|
730
|
+
handled: true,
|
|
731
|
+
response:
|
|
732
|
+
elementId === null
|
|
733
|
+
? `Adding canvas-pinned comment to plan "${slug}"…`
|
|
734
|
+
: `Adding comment to element ${elementId} on plan "${slug}"…`,
|
|
735
|
+
sideEffect: {
|
|
736
|
+
kind: "tool_invocation",
|
|
737
|
+
toolName: "bizar_plan_action",
|
|
738
|
+
args: {
|
|
739
|
+
action: "add_comment",
|
|
740
|
+
planSlug: slug,
|
|
741
|
+
comment: {
|
|
742
|
+
elementId,
|
|
743
|
+
author: "user",
|
|
744
|
+
text,
|
|
745
|
+
},
|
|
746
|
+
},
|
|
747
|
+
},
|
|
748
|
+
settingsPatch: { lastUsedSlug: slug },
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// --- /plan comments (list) -----------------------------------------------
|
|
753
|
+
|
|
754
|
+
function handlePlanComments(args: string[]): SlashCommandResult {
|
|
755
|
+
if (args.length === 0 || args[0] === "") {
|
|
756
|
+
return { handled: true, response: "Usage: /plan comments <slug> [elementId]" };
|
|
757
|
+
}
|
|
758
|
+
const slug = args[0]!;
|
|
759
|
+
if (!isValidSlug(slug)) {
|
|
760
|
+
return {
|
|
761
|
+
handled: true,
|
|
762
|
+
response: `Invalid slug "${slug}". Slug must match ^[a-z0-9][a-z0-9-]{0,63}$.`,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
const elementId = args.length >= 2 ? args[1]! : undefined;
|
|
766
|
+
return {
|
|
767
|
+
handled: true,
|
|
768
|
+
response:
|
|
769
|
+
elementId === undefined
|
|
770
|
+
? `Reading comments on plan "${slug}"…`
|
|
771
|
+
: `Reading comments on element ${elementId} of plan "${slug}"…`,
|
|
772
|
+
sideEffect: {
|
|
773
|
+
kind: "tool_invocation",
|
|
774
|
+
toolName: "bizar_get_plan_comments",
|
|
775
|
+
args: elementId === undefined ? { planSlug: slug } : { planSlug: slug, elementId },
|
|
776
|
+
},
|
|
777
|
+
settingsPatch: { lastUsedSlug: slug },
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// --- /plan status --------------------------------------------------------
|
|
782
|
+
|
|
783
|
+
function handlePlanStatus(args: string[]): SlashCommandResult {
|
|
784
|
+
if (args.length < 2 || args[0] === "" || args[1] === "") {
|
|
785
|
+
return {
|
|
786
|
+
handled: true,
|
|
787
|
+
response: `Usage: /plan status <slug> <status>\nAvailable: ${PLAN_STATUSES.join(", ")}`,
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
const slug = args[0]!;
|
|
791
|
+
if (!isValidSlug(slug)) {
|
|
792
|
+
return {
|
|
793
|
+
handled: true,
|
|
794
|
+
response: `Invalid slug "${slug}". Slug must match ^[a-z0-9][a-z0-9-]{0,63}$.`,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
const status = args[1]!;
|
|
798
|
+
if (!isPlanStatus(status)) {
|
|
799
|
+
return {
|
|
800
|
+
handled: true,
|
|
801
|
+
response: `Invalid status "${status}". Available: ${PLAN_STATUSES.join(", ")}.`,
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
return {
|
|
805
|
+
handled: true,
|
|
806
|
+
response: `Setting plan "${slug}" status to "${status}"…`,
|
|
807
|
+
sideEffect: {
|
|
808
|
+
kind: "tool_invocation",
|
|
809
|
+
toolName: "bizar_plan_action",
|
|
810
|
+
args: { action: "set_status", planSlug: slug, status },
|
|
811
|
+
},
|
|
812
|
+
settingsPatch: { lastUsedSlug: slug },
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// --- /plan wait (deferred) -----------------------------------------------
|
|
817
|
+
|
|
818
|
+
function handlePlanWait(args: string[]): SlashCommandResult {
|
|
819
|
+
if (args.length === 0 || args[0] === "") {
|
|
820
|
+
return { handled: true, response: "Usage: /plan wait <slug> [--timeout N]" };
|
|
821
|
+
}
|
|
822
|
+
const slug = args[0]!;
|
|
823
|
+
if (!isValidSlug(slug)) {
|
|
824
|
+
return {
|
|
825
|
+
handled: true,
|
|
826
|
+
response: `Invalid slug "${slug}". Slug must match ^[a-z0-9][a-z0-9-]{0,63}$.`,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
// Per the audit: /plan wait is deferred from MVP. We acknowledge the
|
|
830
|
+
// command and point the user at the real tool. The slash command
|
|
831
|
+
// does NOT block — that's the hard-rule requirement.
|
|
832
|
+
return {
|
|
833
|
+
handled: true,
|
|
834
|
+
response:
|
|
835
|
+
`"/plan wait" is deferred from the v0.5.0 MVP.\n` +
|
|
836
|
+
`Until the SSE-based command ships, the agent should call ` +
|
|
837
|
+
`\`bizar_wait_for_feedback\` directly as a tool call. It polls every 2s ` +
|
|
838
|
+
`and returns when the user adds a comment, approves/rejects the plan, ` +
|
|
839
|
+
`or the timeout fires (default 10 min, max 30 min).\n` +
|
|
840
|
+
`\n` +
|
|
841
|
+
`For now, the LLM can call the tool itself — this is the path the ` +
|
|
842
|
+
`visual-plan flow uses. A future release will wire /plan wait to an ` +
|
|
843
|
+
`SSE push from the local viewer so the user does not see a polling ` +
|
|
844
|
+
`loop.`,
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// --- /help ----------------------------------------------------------------
|
|
849
|
+
|
|
850
|
+
function helpResult(): SlashCommandResult {
|
|
851
|
+
return {
|
|
852
|
+
handled: true,
|
|
853
|
+
response:
|
|
854
|
+
`Available commands:\n` +
|
|
855
|
+
` /visual-plan on | off — Toggle visual plan mode\n` +
|
|
856
|
+
` /visual-plan — Show current visual plan state\n` +
|
|
857
|
+
`\n` +
|
|
858
|
+
` /plan new <slug> [template] — Create a new plan\n` +
|
|
859
|
+
` /plan list — List all plans in the worktree\n` +
|
|
860
|
+
` /plan open <slug> — Return the URL for a plan\n` +
|
|
861
|
+
`\n` +
|
|
862
|
+
` /plan get <slug> — Fetch the full canvas (via bizar_plan_action)\n` +
|
|
863
|
+
` /plan add <slug> --title T --type kind [--x N --y N …]\n` +
|
|
864
|
+
` — Add an element to a plan (via bizar_plan_action)\n` +
|
|
865
|
+
` /plan update <slug> <id> [--x N --y N --title T --content C …]\n` +
|
|
866
|
+
` — Patch an existing element (via bizar_plan_action)\n` +
|
|
867
|
+
` /plan delete <slug> <id> — Remove an element (via bizar_plan_action)\n` +
|
|
868
|
+
` /plan comment <slug> [id] "text"\n` +
|
|
869
|
+
` — Add a comment (via bizar_plan_action)\n` +
|
|
870
|
+
` /plan comments <slug> [id] — Read comments (via bizar_get_plan_comments)\n` +
|
|
871
|
+
` /plan status <slug> <status> — Set the plan's status (via bizar_plan_action)\n` +
|
|
872
|
+
` /plan wait <slug> [--timeout N]\n` +
|
|
873
|
+
` — Wait for feedback (deferred — see /plan wait)\n` +
|
|
874
|
+
`\n` +
|
|
875
|
+
` /help | /commands — Show this help\n` +
|
|
876
|
+
`\n` +
|
|
877
|
+
`Available templates: ${KNOWN_TEMPLATES.join(", ")}\n` +
|
|
878
|
+
`Available statuses: ${PLAN_STATUSES.join(", ")}`,
|
|
879
|
+
};
|
|
880
|
+
}
|