@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,426 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineTool,
|
|
3
|
+
type AgentToolResult,
|
|
4
|
+
type ExtensionAPI,
|
|
5
|
+
type ExtensionContext,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { type Static, Type } from "typebox";
|
|
8
|
+
import {
|
|
9
|
+
applyTaskMutation,
|
|
10
|
+
type Op,
|
|
11
|
+
} from "../session-todos/state/state-reducer.js";
|
|
12
|
+
import { commitState, getState } from "../session-todos/state/store.js";
|
|
13
|
+
import type { Task } from "../session-todos/tool/types.js";
|
|
14
|
+
import {
|
|
15
|
+
errorToolDetails,
|
|
16
|
+
okToolDetails,
|
|
17
|
+
textToolResult,
|
|
18
|
+
} from "../shared/tool-result.js";
|
|
19
|
+
import { runQueuedMutation } from "./mutation-queue.js";
|
|
20
|
+
import { runTsqJson } from "./runner.js";
|
|
21
|
+
import type { TsqShowData, TsqTask } from "./types.js";
|
|
22
|
+
|
|
23
|
+
export const TSQ_CLAIM_TOOL_NAME = "tsq_claim";
|
|
24
|
+
|
|
25
|
+
export const TsqClaimParamsSchema = Type.Object(
|
|
26
|
+
{
|
|
27
|
+
id: Type.String({ description: "Named Tasque task id to claim." }),
|
|
28
|
+
assignee: Type.Optional(
|
|
29
|
+
Type.String({
|
|
30
|
+
description: "Agent or role claiming the task. Defaults to pi.",
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
33
|
+
start: Type.Optional(
|
|
34
|
+
Type.Boolean({
|
|
35
|
+
description:
|
|
36
|
+
"Start the task after claiming. Defaults to true because claim means work has begun.",
|
|
37
|
+
}),
|
|
38
|
+
),
|
|
39
|
+
requireSpec: Type.Optional(
|
|
40
|
+
Type.Boolean({
|
|
41
|
+
description:
|
|
42
|
+
"Require an attached Tasque spec before the claim succeeds.",
|
|
43
|
+
}),
|
|
44
|
+
),
|
|
45
|
+
createTodo: Type.Optional(
|
|
46
|
+
Type.Boolean({
|
|
47
|
+
description:
|
|
48
|
+
"After a successful claim, create one linked session todo for the claimed task.",
|
|
49
|
+
}),
|
|
50
|
+
),
|
|
51
|
+
},
|
|
52
|
+
{ additionalProperties: false },
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
export type TsqClaimParams = Static<typeof TsqClaimParamsSchema>;
|
|
56
|
+
|
|
57
|
+
export interface TsqClaimTodoError {
|
|
58
|
+
readonly code: string;
|
|
59
|
+
readonly message: string;
|
|
60
|
+
readonly error: Record<string, unknown>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface TsqClaimSuccessData {
|
|
64
|
+
readonly id: string;
|
|
65
|
+
readonly assignee: string;
|
|
66
|
+
readonly start: boolean;
|
|
67
|
+
readonly requireSpec: boolean;
|
|
68
|
+
readonly createTodo: boolean;
|
|
69
|
+
readonly argv: readonly string[];
|
|
70
|
+
readonly claimResult: unknown;
|
|
71
|
+
readonly task?: TsqTask;
|
|
72
|
+
readonly todo?: Task;
|
|
73
|
+
readonly todoError?: TsqClaimTodoError;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type TsqClaimDetails = ReturnType<
|
|
77
|
+
typeof okToolDetails<TsqClaimSuccessData>
|
|
78
|
+
>;
|
|
79
|
+
|
|
80
|
+
type ValidationResult =
|
|
81
|
+
| {
|
|
82
|
+
readonly ok: true;
|
|
83
|
+
readonly id: string;
|
|
84
|
+
readonly assignee: string;
|
|
85
|
+
readonly start: boolean;
|
|
86
|
+
readonly requireSpec: boolean;
|
|
87
|
+
readonly createTodo: boolean;
|
|
88
|
+
readonly argv: string[];
|
|
89
|
+
}
|
|
90
|
+
| { readonly ok: false; readonly message: string };
|
|
91
|
+
|
|
92
|
+
const DEFAULT_ASSIGNEE = "pi";
|
|
93
|
+
|
|
94
|
+
export function registerTsqClaimTool(pi: ExtensionAPI): void {
|
|
95
|
+
pi.registerTool(
|
|
96
|
+
defineTool({
|
|
97
|
+
name: TSQ_CLAIM_TOOL_NAME,
|
|
98
|
+
label: "Tasque Claim",
|
|
99
|
+
description:
|
|
100
|
+
"Claim a named durable Tasque task. Requires an explicit id; does not auto-select next ready work.",
|
|
101
|
+
promptSnippet:
|
|
102
|
+
"Use tsq_claim for named durable Tasque ownership. Provide id; assignee defaults to pi; start defaults to true.",
|
|
103
|
+
promptGuidelines: [
|
|
104
|
+
"Pass your own role/name as assignee when available, e.g. developer, worker, oracle.",
|
|
105
|
+
"Use createTodo only when you want one session todo linked to the claimed durable task.",
|
|
106
|
+
"Completing a linked session todo does not mark the Tasque task done; durable completion must be explicit.",
|
|
107
|
+
],
|
|
108
|
+
parameters: TsqClaimParamsSchema,
|
|
109
|
+
executionMode: "sequential",
|
|
110
|
+
|
|
111
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
112
|
+
return executeTsqClaim(
|
|
113
|
+
pi,
|
|
114
|
+
params as Readonly<Record<string, unknown>>,
|
|
115
|
+
signal,
|
|
116
|
+
ctx,
|
|
117
|
+
);
|
|
118
|
+
},
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function executeTsqClaim(
|
|
124
|
+
pi: ExtensionAPI,
|
|
125
|
+
params: Readonly<Record<string, unknown>>,
|
|
126
|
+
signal: AbortSignal | undefined,
|
|
127
|
+
ctx: Pick<ExtensionContext, "cwd">,
|
|
128
|
+
): Promise<AgentToolResult<TsqClaimDetails>> {
|
|
129
|
+
const command = buildClaimCommand(params);
|
|
130
|
+
if (!command.ok) {
|
|
131
|
+
return validationErrorResult(command.message);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const options = buildRunOptions(signal);
|
|
135
|
+
let claimResult: unknown;
|
|
136
|
+
try {
|
|
137
|
+
claimResult = await runQueuedMutation(ctx.cwd, () =>
|
|
138
|
+
runTsqJson(pi, { cwd: ctx.cwd }, command.argv, options),
|
|
139
|
+
);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
return claimFailureResult(command, error);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let linked: { readonly task: TsqTask; readonly todo: Task } | undefined;
|
|
145
|
+
let todoError: TsqClaimTodoError | undefined;
|
|
146
|
+
if (command.createTodo) {
|
|
147
|
+
try {
|
|
148
|
+
linked = await createLinkedTodoForClaim(pi, ctx, command.id, options);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
todoError = {
|
|
151
|
+
code: getErrorCode(error),
|
|
152
|
+
message: getErrorMessage(error),
|
|
153
|
+
error: serializeError(error),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const data: TsqClaimSuccessData = {
|
|
159
|
+
id: command.id,
|
|
160
|
+
assignee: command.assignee,
|
|
161
|
+
start: command.start,
|
|
162
|
+
requireSpec: command.requireSpec,
|
|
163
|
+
createTodo: command.createTodo,
|
|
164
|
+
argv: command.argv,
|
|
165
|
+
claimResult,
|
|
166
|
+
...(linked === undefined ? {} : linked),
|
|
167
|
+
...(todoError === undefined ? {} : { todoError }),
|
|
168
|
+
};
|
|
169
|
+
const warnings =
|
|
170
|
+
todoError === undefined
|
|
171
|
+
? undefined
|
|
172
|
+
: [`Linked todo creation failed: ${todoError.message}`];
|
|
173
|
+
|
|
174
|
+
return textToolResult(
|
|
175
|
+
formatSuccess(data),
|
|
176
|
+
okToolDetails(data, warnings === undefined ? {} : { warnings }),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function buildClaimCommand(
|
|
181
|
+
params: Readonly<Record<string, unknown>>,
|
|
182
|
+
): ValidationResult {
|
|
183
|
+
const id = requireNonEmptyString(params, "id");
|
|
184
|
+
if (!id.ok) {
|
|
185
|
+
return id;
|
|
186
|
+
}
|
|
187
|
+
const assignee = optionalNonEmptyString(params, "assignee", DEFAULT_ASSIGNEE);
|
|
188
|
+
if (!assignee.ok) {
|
|
189
|
+
return assignee;
|
|
190
|
+
}
|
|
191
|
+
const start = optionalBoolean(params, "start", true);
|
|
192
|
+
if (!start.ok) {
|
|
193
|
+
return start;
|
|
194
|
+
}
|
|
195
|
+
const requireSpec = optionalBoolean(params, "requireSpec", false);
|
|
196
|
+
if (!requireSpec.ok) {
|
|
197
|
+
return requireSpec;
|
|
198
|
+
}
|
|
199
|
+
const createTodo = optionalBoolean(params, "createTodo", false);
|
|
200
|
+
if (!createTodo.ok) {
|
|
201
|
+
return createTodo;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const argv = ["claim", id.value, `--assignee=${assignee.value}`];
|
|
205
|
+
if (start.value) {
|
|
206
|
+
argv.push("--start");
|
|
207
|
+
}
|
|
208
|
+
if (requireSpec.value) {
|
|
209
|
+
argv.push("--require-spec");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
ok: true,
|
|
214
|
+
id: id.value,
|
|
215
|
+
assignee: assignee.value,
|
|
216
|
+
start: start.value,
|
|
217
|
+
requireSpec: requireSpec.value,
|
|
218
|
+
createTodo: createTodo.value,
|
|
219
|
+
argv,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function createLinkedTodoForClaim(
|
|
224
|
+
pi: ExtensionAPI,
|
|
225
|
+
ctx: Pick<ExtensionContext, "cwd">,
|
|
226
|
+
id: string,
|
|
227
|
+
options: { readonly signal?: AbortSignal },
|
|
228
|
+
): Promise<{ readonly task: TsqTask; readonly todo: Task }> {
|
|
229
|
+
const show = await runTsqJson<TsqShowData>(
|
|
230
|
+
pi,
|
|
231
|
+
{ cwd: ctx.cwd },
|
|
232
|
+
["show", id],
|
|
233
|
+
options,
|
|
234
|
+
);
|
|
235
|
+
const task = requireTaskWithTitle(show, id);
|
|
236
|
+
const subject = `Work on ${id}: ${task.title}`;
|
|
237
|
+
const mutation = applyTaskMutation(getState(), "create", {
|
|
238
|
+
subject,
|
|
239
|
+
metadata: { tsqId: id },
|
|
240
|
+
});
|
|
241
|
+
const taskId = getCreatedTodoId(mutation.op);
|
|
242
|
+
commitState(mutation.state);
|
|
243
|
+
const todo = mutation.state.tasks.find(
|
|
244
|
+
(candidate) => candidate.id === taskId,
|
|
245
|
+
);
|
|
246
|
+
if (todo === undefined) {
|
|
247
|
+
throw new Error("could not create linked todo: created task missing");
|
|
248
|
+
}
|
|
249
|
+
return { task, todo };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function getCreatedTodoId(op: Op): number {
|
|
253
|
+
if (op.kind === "create") {
|
|
254
|
+
return op.taskId;
|
|
255
|
+
}
|
|
256
|
+
const message =
|
|
257
|
+
op.kind === "error" ? op.message : `unexpected todo operation ${op.kind}`;
|
|
258
|
+
throw new Error(`could not create linked todo: ${message}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function requireTaskWithTitle(show: TsqShowData, id: string): TsqTask {
|
|
262
|
+
const task = show.task;
|
|
263
|
+
if (!isRecord(task) || typeof task.title !== "string") {
|
|
264
|
+
throw new Error(`tsq show ${id} did not return task title`);
|
|
265
|
+
}
|
|
266
|
+
return task as TsqTask;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function buildRunOptions(signal: AbortSignal | undefined): {
|
|
270
|
+
readonly signal?: AbortSignal;
|
|
271
|
+
} {
|
|
272
|
+
return signal === undefined ? {} : { signal };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function requireNonEmptyString(
|
|
276
|
+
params: Readonly<Record<string, unknown>>,
|
|
277
|
+
field: string,
|
|
278
|
+
):
|
|
279
|
+
| { readonly ok: true; readonly value: string }
|
|
280
|
+
| { readonly ok: false; readonly message: string } {
|
|
281
|
+
const value = params[field];
|
|
282
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
283
|
+
return { ok: false, message: `${field} is required` };
|
|
284
|
+
}
|
|
285
|
+
return { ok: true, value: value.trim() };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function optionalNonEmptyString(
|
|
289
|
+
params: Readonly<Record<string, unknown>>,
|
|
290
|
+
field: string,
|
|
291
|
+
fallback: string,
|
|
292
|
+
):
|
|
293
|
+
| { readonly ok: true; readonly value: string }
|
|
294
|
+
| { readonly ok: false; readonly message: string } {
|
|
295
|
+
const value = params[field];
|
|
296
|
+
if (value === undefined) {
|
|
297
|
+
return { ok: true, value: fallback };
|
|
298
|
+
}
|
|
299
|
+
if (typeof value !== "string") {
|
|
300
|
+
return { ok: false, message: `${field} must be a string` };
|
|
301
|
+
}
|
|
302
|
+
const trimmed = value.trim();
|
|
303
|
+
return { ok: true, value: trimmed.length === 0 ? fallback : trimmed };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function optionalBoolean(
|
|
307
|
+
params: Readonly<Record<string, unknown>>,
|
|
308
|
+
field: string,
|
|
309
|
+
fallback: boolean,
|
|
310
|
+
):
|
|
311
|
+
| { readonly ok: true; readonly value: boolean }
|
|
312
|
+
| { readonly ok: false; readonly message: string } {
|
|
313
|
+
const value = params[field];
|
|
314
|
+
if (value === undefined) {
|
|
315
|
+
return { ok: true, value: fallback };
|
|
316
|
+
}
|
|
317
|
+
if (typeof value !== "boolean") {
|
|
318
|
+
return { ok: false, message: `${field} must be a boolean` };
|
|
319
|
+
}
|
|
320
|
+
return { ok: true, value };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function validationErrorResult(
|
|
324
|
+
message: string,
|
|
325
|
+
): AgentToolResult<TsqClaimDetails> {
|
|
326
|
+
return textToolResult(
|
|
327
|
+
`Error: ${message}`,
|
|
328
|
+
errorToolDetails({
|
|
329
|
+
code: "validation_error",
|
|
330
|
+
message,
|
|
331
|
+
}),
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function claimFailureResult(
|
|
336
|
+
command: Extract<ValidationResult, { readonly ok: true }>,
|
|
337
|
+
error: unknown,
|
|
338
|
+
): AgentToolResult<TsqClaimDetails> {
|
|
339
|
+
const message = getErrorMessage(error);
|
|
340
|
+
return textToolResult(
|
|
341
|
+
`Error: ${message}`,
|
|
342
|
+
errorToolDetails({
|
|
343
|
+
code: getErrorCode(error),
|
|
344
|
+
message,
|
|
345
|
+
details: {
|
|
346
|
+
id: command.id,
|
|
347
|
+
assignee: command.assignee,
|
|
348
|
+
argv: command.argv,
|
|
349
|
+
error: serializeError(error),
|
|
350
|
+
},
|
|
351
|
+
}),
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function formatSuccess(data: TsqClaimSuccessData): string {
|
|
356
|
+
const lines = [
|
|
357
|
+
`Claimed ${data.id} as ${data.assignee}${data.start ? " and started" : ""}`,
|
|
358
|
+
];
|
|
359
|
+
if (data.requireSpec) {
|
|
360
|
+
lines.push("Spec required");
|
|
361
|
+
}
|
|
362
|
+
if (data.todo !== undefined) {
|
|
363
|
+
lines.push(`Created todo #${data.todo.id}: ${data.todo.subject}`);
|
|
364
|
+
}
|
|
365
|
+
if (data.todoError !== undefined) {
|
|
366
|
+
lines.push(`Linked todo creation failed: ${data.todoError.message}`);
|
|
367
|
+
}
|
|
368
|
+
return lines.join("\n");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function getErrorMessage(error: unknown): string {
|
|
372
|
+
if (error instanceof Error) {
|
|
373
|
+
return error.message;
|
|
374
|
+
}
|
|
375
|
+
return String(error);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function getErrorCode(error: unknown): string {
|
|
379
|
+
const record = asRecord(error);
|
|
380
|
+
if (typeof record?.code === "string") {
|
|
381
|
+
return record.code;
|
|
382
|
+
}
|
|
383
|
+
return "tsq_error";
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function serializeError(error: unknown): Record<string, unknown> {
|
|
387
|
+
if (error instanceof Error) {
|
|
388
|
+
return {
|
|
389
|
+
name: error.name,
|
|
390
|
+
message: error.message,
|
|
391
|
+
...(error.stack === undefined ? {} : { stack: error.stack }),
|
|
392
|
+
...copyKnownErrorFields(error),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
return { value: String(error) };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function copyKnownErrorFields(error: Error): Record<string, unknown> {
|
|
399
|
+
const record = error as unknown as Record<string, unknown>;
|
|
400
|
+
const output: Record<string, unknown> = {};
|
|
401
|
+
for (const key of [
|
|
402
|
+
"code",
|
|
403
|
+
"command",
|
|
404
|
+
"details",
|
|
405
|
+
"stderr",
|
|
406
|
+
"stdout",
|
|
407
|
+
"killed",
|
|
408
|
+
"args",
|
|
409
|
+
] as const) {
|
|
410
|
+
if (record[key] !== undefined) {
|
|
411
|
+
output[key] = record[key];
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return output;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
418
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
419
|
+
return undefined;
|
|
420
|
+
}
|
|
421
|
+
return value as Record<string, unknown>;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
425
|
+
return asRecord(value) !== undefined;
|
|
426
|
+
}
|