@bumpyclock/pi-tasque 0.1.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/LICENSE +21 -0
- package/NOTICE.md +7 -0
- package/README.md +315 -0
- package/package.json +39 -0
- package/src/bridge/bridge-tool.ts +185 -0
- package/src/bridge/import-tsq.ts +502 -0
- package/src/bridge/link-store.ts +97 -0
- package/src/bridge/promote-todo.ts +331 -0
- package/src/bridge/types.ts +156 -0
- package/src/durable-tasks/cache.ts +167 -0
- package/src/durable-tasks/mutation-queue.ts +30 -0
- package/src/durable-tasks/runner.ts +234 -0
- package/src/durable-tasks/status.ts +184 -0
- package/src/durable-tasks/tools-change.ts +600 -0
- package/src/durable-tasks/tools-claim.ts +426 -0
- package/src/durable-tasks/tools-query.ts +496 -0
- package/src/durable-tasks/types.ts +193 -0
- package/src/index.ts +21 -0
- package/src/session-todos/state/invariants.ts +17 -0
- package/src/session-todos/state/replay.ts +272 -0
- package/src/session-todos/state/selectors.ts +140 -0
- package/src/session-todos/state/state-reducer.ts +292 -0
- package/src/session-todos/state/state.ts +69 -0
- package/src/session-todos/state/store.ts +37 -0
- package/src/session-todos/state/task-graph.ts +58 -0
- package/src/session-todos/todo-overlay.ts +223 -0
- package/src/session-todos/todo.ts +239 -0
- package/src/session-todos/tool/response-envelope.ts +143 -0
- package/src/session-todos/tool/types.ts +149 -0
- package/src/session-todos/view/format.ts +264 -0
- package/src/shared/tool-result.ts +81 -0
- package/src/shared/truncation.ts +150 -0
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import {
|
|
3
|
+
defineTool,
|
|
4
|
+
type ExtensionAPI,
|
|
5
|
+
type ExtensionContext,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { type Static, Type } from "typebox";
|
|
8
|
+
import { runQueuedMutation } from "./mutation-queue.js";
|
|
9
|
+
import { runTsqJson } from "./runner.js";
|
|
10
|
+
import {
|
|
11
|
+
errorToolDetails,
|
|
12
|
+
okToolDetails,
|
|
13
|
+
textToolResult,
|
|
14
|
+
} from "../shared/tool-result.js";
|
|
15
|
+
|
|
16
|
+
export const TSQ_CHANGE_TOOL_NAME = "tsq_change";
|
|
17
|
+
|
|
18
|
+
const TSQ_CHANGE_ACTIONS = [
|
|
19
|
+
"create",
|
|
20
|
+
"note",
|
|
21
|
+
"done",
|
|
22
|
+
"reopen",
|
|
23
|
+
"defer",
|
|
24
|
+
"start",
|
|
25
|
+
"claim_assign_only",
|
|
26
|
+
"block",
|
|
27
|
+
"unblock",
|
|
28
|
+
"order",
|
|
29
|
+
"unorder",
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
export type TsqChangeAction = (typeof TSQ_CHANGE_ACTIONS)[number];
|
|
33
|
+
|
|
34
|
+
export const TsqChangeParamsSchema = Type.Object(
|
|
35
|
+
{
|
|
36
|
+
action: StringEnum(TSQ_CHANGE_ACTIONS, {
|
|
37
|
+
description: "Durable Tasque mutation to run",
|
|
38
|
+
}),
|
|
39
|
+
title: Type.Optional(
|
|
40
|
+
Type.String({ description: "Task title (required for create)" }),
|
|
41
|
+
),
|
|
42
|
+
id: Type.Optional(
|
|
43
|
+
Type.String({
|
|
44
|
+
description: "Tasque task id for lifecycle/note/claim actions",
|
|
45
|
+
}),
|
|
46
|
+
),
|
|
47
|
+
kind: Type.Optional(
|
|
48
|
+
Type.String({ description: "Tasque task kind (required for create)" }),
|
|
49
|
+
),
|
|
50
|
+
priority: Type.Optional(
|
|
51
|
+
Type.Integer({ description: "Tasque priority (required for create)" }),
|
|
52
|
+
),
|
|
53
|
+
description: Type.Optional(
|
|
54
|
+
Type.String({ description: "Task description (create only)" }),
|
|
55
|
+
),
|
|
56
|
+
parent: Type.Optional(
|
|
57
|
+
Type.String({ description: "Parent Tasque task id (create only)" }),
|
|
58
|
+
),
|
|
59
|
+
planned: Type.Optional(
|
|
60
|
+
Type.Boolean({ description: "Mark created task planned" }),
|
|
61
|
+
),
|
|
62
|
+
needsPlan: Type.Optional(
|
|
63
|
+
Type.Boolean({ description: "Mark created task as needing planning" }),
|
|
64
|
+
),
|
|
65
|
+
assignee: Type.Optional(
|
|
66
|
+
Type.String({
|
|
67
|
+
description: "Assignee for claim_assign_only",
|
|
68
|
+
}),
|
|
69
|
+
),
|
|
70
|
+
note: Type.Optional(
|
|
71
|
+
Type.String({
|
|
72
|
+
description: "Note text for note, done, and defer actions",
|
|
73
|
+
}),
|
|
74
|
+
),
|
|
75
|
+
child: Type.Optional(
|
|
76
|
+
Type.String({
|
|
77
|
+
description: "Task id of the blocked task for block/unblock actions",
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
blocker: Type.Optional(
|
|
81
|
+
Type.String({
|
|
82
|
+
description: "Task id blocking child for block/unblock actions",
|
|
83
|
+
}),
|
|
84
|
+
),
|
|
85
|
+
later: Type.Optional(
|
|
86
|
+
Type.String({
|
|
87
|
+
description: "Task id ordered after earlier for order/unorder actions",
|
|
88
|
+
}),
|
|
89
|
+
),
|
|
90
|
+
earlier: Type.Optional(
|
|
91
|
+
Type.String({
|
|
92
|
+
description:
|
|
93
|
+
"Task id that must happen before later for order/unorder actions",
|
|
94
|
+
}),
|
|
95
|
+
),
|
|
96
|
+
},
|
|
97
|
+
{ additionalProperties: false },
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
export type TsqChangeParams = Static<typeof TsqChangeParamsSchema>;
|
|
101
|
+
|
|
102
|
+
export interface TsqChangeSuccessData {
|
|
103
|
+
readonly action: TsqChangeAction;
|
|
104
|
+
readonly argv: readonly string[];
|
|
105
|
+
readonly result: unknown;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type TsqChangeDetails = ReturnType<
|
|
109
|
+
typeof okToolDetails<TsqChangeSuccessData>
|
|
110
|
+
>;
|
|
111
|
+
|
|
112
|
+
type ValidationResult =
|
|
113
|
+
| {
|
|
114
|
+
readonly ok: true;
|
|
115
|
+
readonly action: TsqChangeAction;
|
|
116
|
+
readonly argv: string[];
|
|
117
|
+
}
|
|
118
|
+
| { readonly ok: false; readonly message: string };
|
|
119
|
+
|
|
120
|
+
export function registerTsqChangeTool(pi: ExtensionAPI): void {
|
|
121
|
+
pi.registerTool(
|
|
122
|
+
defineTool({
|
|
123
|
+
name: TSQ_CHANGE_TOOL_NAME,
|
|
124
|
+
label: "Tasque Change",
|
|
125
|
+
description:
|
|
126
|
+
"Run minimal durable Tasque mutations. Supports create, note, done, reopen, defer, start, assignment-only claim, and block/order edge changes. No raw tsq passthrough.",
|
|
127
|
+
promptSnippet:
|
|
128
|
+
"tsq_change: mutate durable Tasque tasks only through approved lifecycle/note/edge actions.",
|
|
129
|
+
promptGuidelines: [
|
|
130
|
+
"Use tsq_change only for explicit durable Tasque mutations; do not use it as a raw tsq passthrough.",
|
|
131
|
+
"Use todo for current-session checklist steps; use tsq_change when durable Tasque state must change.",
|
|
132
|
+
"Use block/unblock for hard blockers and order/unorder for sequencing where one task should happen after another.",
|
|
133
|
+
"Use tsq_query with action deps or show to inspect durable graph state before or after edge changes.",
|
|
134
|
+
],
|
|
135
|
+
parameters: TsqChangeParamsSchema,
|
|
136
|
+
executionMode: "sequential",
|
|
137
|
+
|
|
138
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
139
|
+
const command = buildMutationCommand(
|
|
140
|
+
params as Readonly<Record<string, unknown>>,
|
|
141
|
+
);
|
|
142
|
+
if (!command.ok) {
|
|
143
|
+
return validationErrorResult(command.message);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const result = await runMutation(pi, ctx, command.argv, signal);
|
|
148
|
+
return textToolResult(
|
|
149
|
+
formatSuccess(command.action, params, result),
|
|
150
|
+
okToolDetails({
|
|
151
|
+
action: command.action,
|
|
152
|
+
argv: command.argv,
|
|
153
|
+
result,
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
const message = getErrorMessage(error);
|
|
158
|
+
return textToolResult(
|
|
159
|
+
`Error: ${message}`,
|
|
160
|
+
errorToolDetails({
|
|
161
|
+
code: getErrorCode(error),
|
|
162
|
+
message,
|
|
163
|
+
details: {
|
|
164
|
+
action: command.action,
|
|
165
|
+
argv: command.argv,
|
|
166
|
+
error: serializeError(error),
|
|
167
|
+
},
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function runMutation(
|
|
177
|
+
pi: ExtensionAPI,
|
|
178
|
+
ctx: ExtensionContext,
|
|
179
|
+
argv: readonly string[],
|
|
180
|
+
signal: AbortSignal | undefined,
|
|
181
|
+
): Promise<unknown> {
|
|
182
|
+
const options = signal === undefined ? {} : { signal };
|
|
183
|
+
return runQueuedMutation(ctx.cwd, () =>
|
|
184
|
+
runTsqJson(pi, { cwd: ctx.cwd }, argv, options),
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function buildMutationCommand(
|
|
189
|
+
params: Readonly<Record<string, unknown>>,
|
|
190
|
+
): ValidationResult {
|
|
191
|
+
const action = params.action;
|
|
192
|
+
if (!isTsqChangeAction(action)) {
|
|
193
|
+
return {
|
|
194
|
+
ok: false,
|
|
195
|
+
message: "action must be a supported tsq_change action",
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
switch (action) {
|
|
200
|
+
case "create":
|
|
201
|
+
return buildCreateArgv(params, action);
|
|
202
|
+
case "note":
|
|
203
|
+
return buildNoteArgv(params, action);
|
|
204
|
+
case "done":
|
|
205
|
+
return buildOptionalNoteArgv(params, action, "done");
|
|
206
|
+
case "reopen":
|
|
207
|
+
return buildIdOnlyArgv(params, action, "reopen");
|
|
208
|
+
case "defer":
|
|
209
|
+
return buildOptionalNoteArgv(params, action, "defer");
|
|
210
|
+
case "start":
|
|
211
|
+
return buildIdOnlyArgv(params, action, "start");
|
|
212
|
+
case "claim_assign_only":
|
|
213
|
+
return buildClaimAssignOnlyArgv(params, action);
|
|
214
|
+
case "block":
|
|
215
|
+
return buildBlockArgv(params, action, "block");
|
|
216
|
+
case "unblock":
|
|
217
|
+
return buildBlockArgv(params, action, "unblock");
|
|
218
|
+
case "order":
|
|
219
|
+
return buildOrderArgv(params, action, "order");
|
|
220
|
+
case "unorder":
|
|
221
|
+
return buildOrderArgv(params, action, "unorder");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function buildCreateArgv(
|
|
226
|
+
params: Readonly<Record<string, unknown>>,
|
|
227
|
+
action: TsqChangeAction,
|
|
228
|
+
): ValidationResult {
|
|
229
|
+
const title = requireNonEmptyString(params, "title");
|
|
230
|
+
if (!title.ok) {
|
|
231
|
+
return title;
|
|
232
|
+
}
|
|
233
|
+
const kind = requireNonEmptyString(params, "kind");
|
|
234
|
+
if (!kind.ok) {
|
|
235
|
+
return kind;
|
|
236
|
+
}
|
|
237
|
+
const priority = requireInteger(params, "priority");
|
|
238
|
+
if (!priority.ok) {
|
|
239
|
+
return priority;
|
|
240
|
+
}
|
|
241
|
+
const planned = getOptionalBoolean(params, "planned");
|
|
242
|
+
if (!planned.ok) {
|
|
243
|
+
return planned;
|
|
244
|
+
}
|
|
245
|
+
const needsPlan = getOptionalBoolean(params, "needsPlan");
|
|
246
|
+
if (!needsPlan.ok) {
|
|
247
|
+
return needsPlan;
|
|
248
|
+
}
|
|
249
|
+
if (planned.value === true && needsPlan.value === true) {
|
|
250
|
+
return {
|
|
251
|
+
ok: false,
|
|
252
|
+
message: "planned and needsPlan cannot both be true",
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const argv = ["create", `--kind=${kind.value}`, "-p", String(priority.value)];
|
|
257
|
+
const description = appendOptionalStringFlag(
|
|
258
|
+
argv,
|
|
259
|
+
params,
|
|
260
|
+
"description",
|
|
261
|
+
"--description",
|
|
262
|
+
);
|
|
263
|
+
if (description !== undefined) {
|
|
264
|
+
return description;
|
|
265
|
+
}
|
|
266
|
+
const parent = appendOptionalStringFlag(argv, params, "parent", "--parent");
|
|
267
|
+
if (parent !== undefined) {
|
|
268
|
+
return parent;
|
|
269
|
+
}
|
|
270
|
+
if (planned.value === true) {
|
|
271
|
+
argv.push("--planned");
|
|
272
|
+
} else if (needsPlan.value === true) {
|
|
273
|
+
argv.push("--needs-plan");
|
|
274
|
+
}
|
|
275
|
+
argv.push("--", title.value);
|
|
276
|
+
|
|
277
|
+
return { ok: true, action, argv };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function buildNoteArgv(
|
|
281
|
+
params: Readonly<Record<string, unknown>>,
|
|
282
|
+
action: TsqChangeAction,
|
|
283
|
+
): ValidationResult {
|
|
284
|
+
const id = requireNonEmptyString(params, "id");
|
|
285
|
+
if (!id.ok) {
|
|
286
|
+
return id;
|
|
287
|
+
}
|
|
288
|
+
const note = requireNonEmptyString(params, "note");
|
|
289
|
+
if (!note.ok) {
|
|
290
|
+
return note;
|
|
291
|
+
}
|
|
292
|
+
return { ok: true, action, argv: ["note", id.value, "--", note.value] };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function buildOptionalNoteArgv(
|
|
296
|
+
params: Readonly<Record<string, unknown>>,
|
|
297
|
+
action: TsqChangeAction,
|
|
298
|
+
command: "done" | "defer",
|
|
299
|
+
): ValidationResult {
|
|
300
|
+
const id = requireNonEmptyString(params, "id");
|
|
301
|
+
if (!id.ok) {
|
|
302
|
+
return id;
|
|
303
|
+
}
|
|
304
|
+
const argv = [command, id.value];
|
|
305
|
+
const note = getOptionalNonEmptyString(params, "note");
|
|
306
|
+
if (!note.ok) {
|
|
307
|
+
return note;
|
|
308
|
+
}
|
|
309
|
+
if (note.value !== undefined) {
|
|
310
|
+
argv.push(`--note=${note.value}`);
|
|
311
|
+
}
|
|
312
|
+
return { ok: true, action, argv };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function buildIdOnlyArgv(
|
|
316
|
+
params: Readonly<Record<string, unknown>>,
|
|
317
|
+
action: TsqChangeAction,
|
|
318
|
+
command: "reopen" | "start",
|
|
319
|
+
): ValidationResult {
|
|
320
|
+
const id = requireNonEmptyString(params, "id");
|
|
321
|
+
if (!id.ok) {
|
|
322
|
+
return id;
|
|
323
|
+
}
|
|
324
|
+
return { ok: true, action, argv: [command, id.value] };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function buildClaimAssignOnlyArgv(
|
|
328
|
+
params: Readonly<Record<string, unknown>>,
|
|
329
|
+
action: TsqChangeAction,
|
|
330
|
+
): ValidationResult {
|
|
331
|
+
const id = requireNonEmptyString(params, "id");
|
|
332
|
+
if (!id.ok) {
|
|
333
|
+
return id;
|
|
334
|
+
}
|
|
335
|
+
const assignee = requireNonEmptyString(params, "assignee");
|
|
336
|
+
if (!assignee.ok) {
|
|
337
|
+
return assignee;
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
ok: true,
|
|
341
|
+
action,
|
|
342
|
+
argv: ["claim", id.value, `--assignee=${assignee.value}`],
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function buildBlockArgv(
|
|
347
|
+
params: Readonly<Record<string, unknown>>,
|
|
348
|
+
action: TsqChangeAction,
|
|
349
|
+
command: "block" | "unblock",
|
|
350
|
+
): ValidationResult {
|
|
351
|
+
const child = requireNonEmptyString(params, "child");
|
|
352
|
+
if (!child.ok) {
|
|
353
|
+
return child;
|
|
354
|
+
}
|
|
355
|
+
const blocker = requireNonEmptyString(params, "blocker");
|
|
356
|
+
if (!blocker.ok) {
|
|
357
|
+
return blocker;
|
|
358
|
+
}
|
|
359
|
+
if (child.value === blocker.value) {
|
|
360
|
+
return { ok: false, message: "child and blocker cannot be the same task" };
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
ok: true,
|
|
364
|
+
action,
|
|
365
|
+
argv: [command, child.value, "by", blocker.value],
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function buildOrderArgv(
|
|
370
|
+
params: Readonly<Record<string, unknown>>,
|
|
371
|
+
action: TsqChangeAction,
|
|
372
|
+
command: "order" | "unorder",
|
|
373
|
+
): ValidationResult {
|
|
374
|
+
const later = requireNonEmptyString(params, "later");
|
|
375
|
+
if (!later.ok) {
|
|
376
|
+
return later;
|
|
377
|
+
}
|
|
378
|
+
const earlier = requireNonEmptyString(params, "earlier");
|
|
379
|
+
if (!earlier.ok) {
|
|
380
|
+
return earlier;
|
|
381
|
+
}
|
|
382
|
+
if (later.value === earlier.value) {
|
|
383
|
+
return { ok: false, message: "later and earlier cannot be the same task" };
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
ok: true,
|
|
387
|
+
action,
|
|
388
|
+
argv: [command, later.value, "after", earlier.value],
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function appendOptionalStringFlag(
|
|
393
|
+
argv: string[],
|
|
394
|
+
params: Readonly<Record<string, unknown>>,
|
|
395
|
+
field: "description" | "parent",
|
|
396
|
+
flag: string,
|
|
397
|
+
): ValidationResult | undefined {
|
|
398
|
+
const value = getOptionalNonEmptyString(params, field);
|
|
399
|
+
if (!value.ok) {
|
|
400
|
+
return value;
|
|
401
|
+
}
|
|
402
|
+
if (value.value !== undefined) {
|
|
403
|
+
argv.push(`${flag}=${value.value}`);
|
|
404
|
+
}
|
|
405
|
+
return undefined;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function requireNonEmptyString(
|
|
409
|
+
params: Readonly<Record<string, unknown>>,
|
|
410
|
+
field: string,
|
|
411
|
+
):
|
|
412
|
+
| { readonly ok: true; readonly value: string }
|
|
413
|
+
| { readonly ok: false; readonly message: string } {
|
|
414
|
+
const value = params[field];
|
|
415
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
416
|
+
return { ok: false, message: `${field} is required` };
|
|
417
|
+
}
|
|
418
|
+
return { ok: true, value };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function getOptionalNonEmptyString(
|
|
422
|
+
params: Readonly<Record<string, unknown>>,
|
|
423
|
+
field: string,
|
|
424
|
+
):
|
|
425
|
+
| { readonly ok: true; readonly value: string | undefined }
|
|
426
|
+
| { readonly ok: false; readonly message: string } {
|
|
427
|
+
const value = params[field];
|
|
428
|
+
if (value === undefined) {
|
|
429
|
+
return { ok: true, value: undefined };
|
|
430
|
+
}
|
|
431
|
+
if (typeof value !== "string") {
|
|
432
|
+
return { ok: false, message: `${field} must be a string` };
|
|
433
|
+
}
|
|
434
|
+
if (value.trim().length === 0) {
|
|
435
|
+
return { ok: true, value: undefined };
|
|
436
|
+
}
|
|
437
|
+
return { ok: true, value };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function requireInteger(
|
|
441
|
+
params: Readonly<Record<string, unknown>>,
|
|
442
|
+
field: string,
|
|
443
|
+
):
|
|
444
|
+
| { readonly ok: true; readonly value: number }
|
|
445
|
+
| { readonly ok: false; readonly message: string } {
|
|
446
|
+
const value = params[field];
|
|
447
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
448
|
+
return { ok: false, message: `${field} is required` };
|
|
449
|
+
}
|
|
450
|
+
return { ok: true, value };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function getOptionalBoolean(
|
|
454
|
+
params: Readonly<Record<string, unknown>>,
|
|
455
|
+
field: string,
|
|
456
|
+
):
|
|
457
|
+
| { readonly ok: true; readonly value: boolean | undefined }
|
|
458
|
+
| { readonly ok: false; readonly message: string } {
|
|
459
|
+
const value = params[field];
|
|
460
|
+
if (value === undefined) {
|
|
461
|
+
return { ok: true, value: undefined };
|
|
462
|
+
}
|
|
463
|
+
if (typeof value !== "boolean") {
|
|
464
|
+
return { ok: false, message: `${field} must be a boolean` };
|
|
465
|
+
}
|
|
466
|
+
return { ok: true, value };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function isTsqChangeAction(value: unknown): value is TsqChangeAction {
|
|
470
|
+
return (
|
|
471
|
+
typeof value === "string" &&
|
|
472
|
+
(TSQ_CHANGE_ACTIONS as readonly string[]).includes(value)
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function validationErrorResult(message: string) {
|
|
477
|
+
return textToolResult(
|
|
478
|
+
`Error: ${message}`,
|
|
479
|
+
errorToolDetails({
|
|
480
|
+
code: "validation_error",
|
|
481
|
+
message,
|
|
482
|
+
}),
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function formatSuccess(
|
|
487
|
+
action: TsqChangeAction,
|
|
488
|
+
params: TsqChangeParams,
|
|
489
|
+
result: unknown,
|
|
490
|
+
): string {
|
|
491
|
+
const task = extractTaskLike(result);
|
|
492
|
+
const id = task.id ?? params.id;
|
|
493
|
+
const title = task.title ?? params.title;
|
|
494
|
+
|
|
495
|
+
switch (action) {
|
|
496
|
+
case "create":
|
|
497
|
+
return formatCreated(id, title);
|
|
498
|
+
case "note":
|
|
499
|
+
return `Added note to ${id ?? "task"}`;
|
|
500
|
+
case "done":
|
|
501
|
+
return `Marked done ${id ?? "task"}`;
|
|
502
|
+
case "reopen":
|
|
503
|
+
return `Reopened ${id ?? "task"}`;
|
|
504
|
+
case "defer":
|
|
505
|
+
return `Deferred ${id ?? "task"}`;
|
|
506
|
+
case "start":
|
|
507
|
+
return `Started ${id ?? "task"}`;
|
|
508
|
+
case "claim_assign_only":
|
|
509
|
+
return `Assigned ${id ?? "task"} to ${params.assignee ?? "assignee"}`;
|
|
510
|
+
case "block":
|
|
511
|
+
return `Added block edge: ${params.child ?? "child"} blocked by ${params.blocker ?? "blocker"}`;
|
|
512
|
+
case "unblock":
|
|
513
|
+
return `Removed block edge: ${params.child ?? "child"} no longer blocked by ${params.blocker ?? "blocker"}`;
|
|
514
|
+
case "order":
|
|
515
|
+
return `Added order edge: ${params.later ?? "later"} after ${params.earlier ?? "earlier"}`;
|
|
516
|
+
case "unorder":
|
|
517
|
+
return `Removed order edge: ${params.later ?? "later"} no longer ordered after ${params.earlier ?? "earlier"}`;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function formatCreated(
|
|
522
|
+
id: string | undefined,
|
|
523
|
+
title: string | undefined,
|
|
524
|
+
): string {
|
|
525
|
+
if (id !== undefined && title !== undefined) {
|
|
526
|
+
return `Created ${id}: ${title}`;
|
|
527
|
+
}
|
|
528
|
+
if (id !== undefined) {
|
|
529
|
+
return `Created ${id}`;
|
|
530
|
+
}
|
|
531
|
+
if (title !== undefined) {
|
|
532
|
+
return `Created task: ${title}`;
|
|
533
|
+
}
|
|
534
|
+
return "Created task";
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function extractTaskLike(result: unknown): {
|
|
538
|
+
readonly id: string | undefined;
|
|
539
|
+
readonly title: string | undefined;
|
|
540
|
+
} {
|
|
541
|
+
const root = asRecord(result);
|
|
542
|
+
const candidate = asRecord(root?.task) ?? root;
|
|
543
|
+
return {
|
|
544
|
+
id: typeof candidate?.id === "string" ? candidate.id : undefined,
|
|
545
|
+
title: typeof candidate?.title === "string" ? candidate.title : undefined,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
550
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
551
|
+
return undefined;
|
|
552
|
+
}
|
|
553
|
+
return value as Record<string, unknown>;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function getErrorMessage(error: unknown): string {
|
|
557
|
+
if (error instanceof Error) {
|
|
558
|
+
return error.message;
|
|
559
|
+
}
|
|
560
|
+
return String(error);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function getErrorCode(error: unknown): string {
|
|
564
|
+
const record = asRecord(error);
|
|
565
|
+
if (typeof record?.code === "string") {
|
|
566
|
+
return record.code;
|
|
567
|
+
}
|
|
568
|
+
return "tsq_error";
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function serializeError(error: unknown): Record<string, unknown> {
|
|
572
|
+
if (error instanceof Error) {
|
|
573
|
+
return {
|
|
574
|
+
name: error.name,
|
|
575
|
+
message: error.message,
|
|
576
|
+
...(error.stack === undefined ? {} : { stack: error.stack }),
|
|
577
|
+
...copyKnownErrorFields(error),
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
return { value: String(error) };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function copyKnownErrorFields(error: Error): Record<string, unknown> {
|
|
584
|
+
const record = error as unknown as Record<string, unknown>;
|
|
585
|
+
const output: Record<string, unknown> = {};
|
|
586
|
+
for (const key of [
|
|
587
|
+
"code",
|
|
588
|
+
"command",
|
|
589
|
+
"details",
|
|
590
|
+
"stderr",
|
|
591
|
+
"stdout",
|
|
592
|
+
"killed",
|
|
593
|
+
"args",
|
|
594
|
+
] as const) {
|
|
595
|
+
if (record[key] !== undefined) {
|
|
596
|
+
output[key] = record[key];
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return output;
|
|
600
|
+
}
|