@bumpyclock/pi-tasque 0.1.0 → 0.2.1

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.
Files changed (34) hide show
  1. package/README.md +168 -140
  2. package/package.json +24 -6
  3. package/src/bridge/bridge-tool.ts +10 -13
  4. package/src/bridge/import-tsq.ts +5 -30
  5. package/src/bridge/promote-todo.ts +1 -4
  6. package/src/bridge/types.ts +11 -12
  7. package/src/durable-tasks/bulk-contract.ts +176 -0
  8. package/src/durable-tasks/cache.ts +1 -4
  9. package/src/durable-tasks/change-command-builder.ts +315 -0
  10. package/src/durable-tasks/handoff-guard.ts +329 -0
  11. package/src/durable-tasks/project.ts +71 -0
  12. package/src/durable-tasks/runner.ts +1 -4
  13. package/src/durable-tasks/status.ts +20 -5
  14. package/src/durable-tasks/task-mappers.ts +160 -0
  15. package/src/durable-tasks/task-schema.ts +193 -0
  16. package/src/durable-tasks/task-tool.ts +197 -0
  17. package/src/durable-tasks/task-validation.ts +123 -0
  18. package/src/durable-tasks/tools-bulk.ts +141 -0
  19. package/src/durable-tasks/tools-change.ts +111 -382
  20. package/src/durable-tasks/tools-claim.ts +15 -43
  21. package/src/durable-tasks/tools-handoff.ts +95 -0
  22. package/src/durable-tasks/tools-query.ts +58 -29
  23. package/src/durable-tasks/tools-spec.ts +230 -0
  24. package/src/durable-tasks/tools-tree-create.ts +221 -0
  25. package/src/guidelines/internal-tools.ts +33 -0
  26. package/src/guidelines/task.ts +5 -0
  27. package/src/guidelines/todo.ts +5 -0
  28. package/src/index.ts +2 -13
  29. package/src/session-todos/state/replay.ts +10 -13
  30. package/src/session-todos/todo-overlay.ts +1 -1
  31. package/src/session-todos/todo.ts +11 -15
  32. package/src/session-todos/tool/types.ts +7 -7
  33. package/src/shared/error-utils.ts +29 -0
  34. package/src/shared/validation.ts +25 -0
@@ -23,61 +23,60 @@ export const TaskBridgeParamsSchema = Type.Object(
23
23
  {
24
24
  action: StringEnum(TASK_BRIDGE_ACTIONS, {
25
25
  description:
26
- "Explicit bridge operation between session todo state and durable Tasque tasks.",
26
+ "Explicit bridge operation between session todo state and durable tasks.",
27
27
  }),
28
28
  todoId: Type.Optional(
29
29
  Type.Integer({
30
30
  description:
31
- "Session todo id. Required for link and promote_todo actions.",
31
+ "Session todo id. Required when linking or promoting a todo.",
32
32
  minimum: 1,
33
33
  }),
34
34
  ),
35
35
  tsqId: Type.Optional(
36
36
  Type.String({
37
37
  description:
38
- "Durable Tasque task id. Required for link and import_tsq actions.",
38
+ "Durable task id. Required when linking or importing a task.",
39
39
  }),
40
40
  ),
41
41
  assignee: Type.Optional(
42
42
  Type.String({
43
43
  description:
44
- "Agent/owner name used by promote_todo/import_tsq bridge actions.",
44
+ "Agent/owner name used when creating or importing linked task todos.",
45
45
  }),
46
46
  ),
47
47
  owner: Type.Optional(
48
48
  Type.String({
49
- description: "Todo owner used by import_tsq bridge action.",
49
+ description: "Todo owner used when importing a durable task.",
50
50
  }),
51
51
  ),
52
52
  kind: Type.Optional(
53
53
  Type.String({
54
- description: "Tasque task kind used by promote_todo bridge action.",
54
+ description: "Durable task kind used when promoting a todo.",
55
55
  }),
56
56
  ),
57
57
  priority: Type.Optional(
58
58
  Type.Integer({
59
- description: "Tasque priority used by promote_todo bridge action.",
59
+ description: "Durable task priority used when promoting a todo.",
60
60
  }),
61
61
  ),
62
62
  description: Type.Optional(
63
63
  Type.String({
64
- description: "Description override used by promote_todo bridge action.",
64
+ description: "Description override used when promoting a todo.",
65
65
  }),
66
66
  ),
67
67
  parent: Type.Optional(
68
68
  Type.String({
69
- description:
70
- "Parent Tasque task id used by promote_todo bridge action.",
69
+ description: "Parent durable task id used when promoting a todo.",
71
70
  }),
72
71
  ),
73
72
  planned: Type.Optional(
74
73
  Type.Boolean({
75
- description: "Planning flag used by promote_todo bridge action.",
74
+ description: "Planning flag used when promoting a todo.",
76
75
  }),
77
76
  ),
78
77
  needsPlan: Type.Optional(
79
78
  Type.Boolean({
80
- description: "Planning flag used by promote_todo bridge action.",
79
+ description: "Planning flag used when promoting a todo.",
81
80
  }),
82
81
  ),
83
82
  },
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Public contract types and validation for `bulk` and `create_tree` task actions.
3
+ *
4
+ * Defines the input shapes, validates them eagerly, and exports result type
5
+ * scaffolds used by the separate bulk and create-tree executors. Dispatch
6
+ * happens in the unified task tool.
7
+ */
8
+
9
+ // ── Bulk item contract ─────────────────────────────────────────────
10
+
11
+ export const BULK_ITEM_ACTIONS = [
12
+ "start",
13
+ "finish",
14
+ "reopen",
15
+ "defer",
16
+ "note",
17
+ "mark_planned",
18
+ ] as const;
19
+
20
+ export type BulkItemAction = (typeof BULK_ITEM_ACTIONS)[number];
21
+
22
+ /** A single lifecycle/note mutation inside a `bulk` call. */
23
+ export interface BulkItem {
24
+ /** Lifecycle or note action to run on the target task. */
25
+ readonly action: BulkItemAction;
26
+ /** Durable task id (e.g. "tsq-3"). */
27
+ readonly task: string;
28
+ /** Note/reason text — required for `note`, optional for `finish`/`defer`. */
29
+ readonly because?: string;
30
+ }
31
+
32
+ /** Result shape bulk executors will produce (tsq-6.2). */
33
+ export interface BulkResult {
34
+ readonly completed: readonly string[];
35
+ readonly failed?: { readonly task: string; readonly error: string };
36
+ readonly skipped: readonly string[];
37
+ }
38
+
39
+ // ── Create-tree node contract ──────────────────────────────────────
40
+
41
+ /** A node in the `create_tree` input. Recursive via `children`. */
42
+ export interface CreateTreeNode {
43
+ readonly title: string;
44
+ readonly kind: string;
45
+ readonly priority: number;
46
+ readonly description?: string;
47
+ /** Mark this node as already planned. Contradicts `needsPlan`. */
48
+ readonly planned?: boolean;
49
+ /** Mark this node as needing planning. Contradicts `planned`. */
50
+ readonly needsPlan?: boolean;
51
+ readonly children?: readonly CreateTreeNode[];
52
+ }
53
+
54
+ /** Result shape tree executors will produce (tsq-6.3). */
55
+ export interface CreateTreeResult {
56
+ readonly created: readonly { readonly id: string; readonly title: string }[];
57
+ readonly failed?: { readonly title: string; readonly error: string };
58
+ readonly skipped: readonly { readonly title: string }[];
59
+ }
60
+
61
+ // ── Validation ─────────────────────────────────────────────────────
62
+
63
+ type Ok = { readonly ok: true };
64
+ type Fail = { readonly ok: false; readonly message: string };
65
+ type ValidationResult = Ok | Fail;
66
+
67
+ const OK: Ok = { ok: true } as const;
68
+
69
+ function fail(message: string): Fail {
70
+ return { ok: false, message };
71
+ }
72
+
73
+ /**
74
+ * Validate a `bulk` action's `items` array.
75
+ *
76
+ * Rejects: missing/empty array, missing action/task on any item,
77
+ * unsupported action values, missing `because` when action is `note`.
78
+ */
79
+ export function validateBulkItems(items: unknown): ValidationResult {
80
+ if (!Array.isArray(items) || items.length === 0) {
81
+ return fail("items must be a non-empty array");
82
+ }
83
+
84
+ for (let i = 0; i < items.length; i++) {
85
+ const item = items[i] as Record<string, unknown>;
86
+ const prefix = `items[${i}]`;
87
+
88
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
89
+ return fail(`${prefix} must be an object`);
90
+ }
91
+
92
+ const action = item.action;
93
+ if (typeof action !== "string" || action.trim().length === 0) {
94
+ return fail(`${prefix}.action is required`);
95
+ }
96
+ if (!(BULK_ITEM_ACTIONS as readonly string[]).includes(action)) {
97
+ return fail(
98
+ `${prefix}.action "${action}" is not supported; use one of: ${BULK_ITEM_ACTIONS.join(", ")}`,
99
+ );
100
+ }
101
+
102
+ const task = item.task;
103
+ if (typeof task !== "string" || task.trim().length === 0) {
104
+ return fail(`${prefix}.task is required`);
105
+ }
106
+
107
+ // `note` requires `because`
108
+ if (action === "note") {
109
+ const because = item.because;
110
+ if (typeof because !== "string" || because.trim().length === 0) {
111
+ return fail(`${prefix}.because is required when action is "note"`);
112
+ }
113
+ }
114
+ }
115
+
116
+ return OK;
117
+ }
118
+
119
+ /**
120
+ * Validate a `create_tree` action's `root` node, recursively.
121
+ *
122
+ * Rejects: missing title/kind/priority, contradictory planned+needsPlan,
123
+ * empty children arrays.
124
+ */
125
+ export function validateCreateTreeNode(
126
+ node: unknown,
127
+ path = "root",
128
+ ): ValidationResult {
129
+ if (typeof node !== "object" || node === null || Array.isArray(node)) {
130
+ return fail(`${path} must be an object`);
131
+ }
132
+
133
+ const n = node as Record<string, unknown>;
134
+
135
+ // Required fields
136
+ if (typeof n.title !== "string" || n.title.trim().length === 0) {
137
+ return fail(`${path}.title is required`);
138
+ }
139
+ if (typeof n.kind !== "string" || n.kind.trim().length === 0) {
140
+ return fail(`${path}.kind is required`);
141
+ }
142
+ if (typeof n.priority !== "number" || !Number.isInteger(n.priority)) {
143
+ return fail(`${path}.priority is required`);
144
+ }
145
+
146
+ if (n.description !== undefined && typeof n.description !== "string") {
147
+ return fail(`${path}.description must be a string`);
148
+ }
149
+ if (n.planned !== undefined && typeof n.planned !== "boolean") {
150
+ return fail(`${path}.planned must be a boolean`);
151
+ }
152
+ if (n.needsPlan !== undefined && typeof n.needsPlan !== "boolean") {
153
+ return fail(`${path}.needsPlan must be a boolean`);
154
+ }
155
+
156
+ // Contradictory planning flags
157
+ if (n.planned === true && n.needsPlan === true) {
158
+ return fail(`${path}: planned and needsPlan cannot both be true`);
159
+ }
160
+
161
+ // Recursive children validation
162
+ if (n.children !== undefined) {
163
+ if (!Array.isArray(n.children) || n.children.length === 0) {
164
+ return fail(`${path}.children must be a non-empty array when provided`);
165
+ }
166
+ for (let i = 0; i < n.children.length; i++) {
167
+ const childResult = validateCreateTreeNode(
168
+ n.children[i],
169
+ `${path}.children[${i}]`,
170
+ );
171
+ if (!childResult.ok) return childResult;
172
+ }
173
+ }
174
+
175
+ return OK;
176
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { isRecord } from "../shared/error-utils.js";
2
3
  import { runTsqJson, type TsqRunContext } from "./runner.js";
3
4
 
4
5
  export interface TasqueStatusCacheState {
@@ -161,7 +162,3 @@ function truncateInline(text: string, maxLength: number): string {
161
162
  }
162
163
  return `${text.slice(0, Math.max(0, maxLength - 1))}…`;
163
164
  }
164
-
165
- function isRecord(value: unknown): value is Record<string, unknown> {
166
- return typeof value === "object" && value !== null && !Array.isArray(value);
167
- }
@@ -0,0 +1,315 @@
1
+ export const TSQ_CHANGE_ACTIONS = [
2
+ "create",
3
+ "note",
4
+ "done",
5
+ "reopen",
6
+ "defer",
7
+ "start",
8
+ "claim_assign_only",
9
+ "block",
10
+ "unblock",
11
+ "order",
12
+ "unorder",
13
+ ] as const;
14
+
15
+ export type TsqChangeAction = (typeof TSQ_CHANGE_ACTIONS)[number];
16
+
17
+ export type ValidationResult =
18
+ | {
19
+ readonly ok: true;
20
+ readonly action: TsqChangeAction;
21
+ readonly argv: string[];
22
+ }
23
+ | { readonly ok: false; readonly message: string };
24
+
25
+ export function buildMutationCommand(
26
+ params: Readonly<Record<string, unknown>>,
27
+ ): ValidationResult {
28
+ const action = params.action;
29
+ if (!isTsqChangeAction(action)) {
30
+ return {
31
+ ok: false,
32
+ message: "action must be a supported durable task mutation",
33
+ };
34
+ }
35
+
36
+ switch (action) {
37
+ case "create":
38
+ return buildCreateArgv(params, action);
39
+ case "note":
40
+ return buildNoteArgv(params, action);
41
+ case "done":
42
+ return buildOptionalNoteArgv(params, action, "done");
43
+ case "reopen":
44
+ return buildIdOnlyArgv(params, action, "reopen");
45
+ case "defer":
46
+ return buildOptionalNoteArgv(params, action, "defer");
47
+ case "start":
48
+ return buildIdOnlyArgv(params, action, "start");
49
+ case "claim_assign_only":
50
+ return buildClaimAssignOnlyArgv(params, action);
51
+ case "block":
52
+ return buildBlockArgv(params, action, "block");
53
+ case "unblock":
54
+ return buildBlockArgv(params, action, "unblock");
55
+ case "order":
56
+ return buildOrderArgv(params, action, "order");
57
+ case "unorder":
58
+ return buildOrderArgv(params, action, "unorder");
59
+ }
60
+ }
61
+
62
+ export function isTsqChangeAction(value: unknown): value is TsqChangeAction {
63
+ return (
64
+ typeof value === "string" &&
65
+ (TSQ_CHANGE_ACTIONS as readonly string[]).includes(value)
66
+ );
67
+ }
68
+
69
+ // --- argv builders ---
70
+
71
+ function buildCreateArgv(
72
+ params: Readonly<Record<string, unknown>>,
73
+ action: TsqChangeAction,
74
+ ): ValidationResult {
75
+ const title = requireNonEmptyString(params, "title");
76
+ if (!title.ok) {
77
+ return title;
78
+ }
79
+ const kind = requireNonEmptyString(params, "kind");
80
+ if (!kind.ok) {
81
+ return kind;
82
+ }
83
+ const priority = requireInteger(params, "priority");
84
+ if (!priority.ok) {
85
+ return priority;
86
+ }
87
+ const planned = getOptionalBoolean(params, "planned");
88
+ if (!planned.ok) {
89
+ return planned;
90
+ }
91
+ const needsPlan = getOptionalBoolean(params, "needsPlan");
92
+ if (!needsPlan.ok) {
93
+ return needsPlan;
94
+ }
95
+ if (planned.value === true && needsPlan.value === true) {
96
+ return {
97
+ ok: false,
98
+ message: "planned and needsPlan cannot both be true",
99
+ };
100
+ }
101
+
102
+ const argv = ["create", `--kind=${kind.value}`, "-p", String(priority.value)];
103
+ const description = appendOptionalStringFlag(
104
+ argv,
105
+ params,
106
+ "description",
107
+ "--description",
108
+ );
109
+ if (description !== undefined) {
110
+ return description;
111
+ }
112
+ const parent = appendOptionalStringFlag(argv, params, "parent", "--parent");
113
+ if (parent !== undefined) {
114
+ return parent;
115
+ }
116
+ if (planned.value === true) {
117
+ argv.push("--planned");
118
+ } else if (needsPlan.value === true) {
119
+ argv.push("--needs-plan");
120
+ }
121
+ argv.push("--", title.value);
122
+
123
+ return { ok: true, action, argv };
124
+ }
125
+
126
+ function buildNoteArgv(
127
+ params: Readonly<Record<string, unknown>>,
128
+ action: TsqChangeAction,
129
+ ): ValidationResult {
130
+ const id = requireNonEmptyString(params, "id");
131
+ if (!id.ok) {
132
+ return id;
133
+ }
134
+ const note = requireNonEmptyString(params, "note");
135
+ if (!note.ok) {
136
+ return note;
137
+ }
138
+ return { ok: true, action, argv: ["note", id.value, "--", note.value] };
139
+ }
140
+
141
+ function buildOptionalNoteArgv(
142
+ params: Readonly<Record<string, unknown>>,
143
+ action: TsqChangeAction,
144
+ command: "done" | "defer",
145
+ ): ValidationResult {
146
+ const id = requireNonEmptyString(params, "id");
147
+ if (!id.ok) {
148
+ return id;
149
+ }
150
+ const argv = [command, id.value];
151
+ const note = getOptionalNonEmptyString(params, "note");
152
+ if (!note.ok) {
153
+ return note;
154
+ }
155
+ if (note.value !== undefined) {
156
+ argv.push(`--note=${note.value}`);
157
+ }
158
+ return { ok: true, action, argv };
159
+ }
160
+
161
+ function buildIdOnlyArgv(
162
+ params: Readonly<Record<string, unknown>>,
163
+ action: TsqChangeAction,
164
+ command: "reopen" | "start",
165
+ ): ValidationResult {
166
+ const id = requireNonEmptyString(params, "id");
167
+ if (!id.ok) {
168
+ return id;
169
+ }
170
+ return { ok: true, action, argv: [command, id.value] };
171
+ }
172
+
173
+ function buildClaimAssignOnlyArgv(
174
+ params: Readonly<Record<string, unknown>>,
175
+ action: TsqChangeAction,
176
+ ): ValidationResult {
177
+ const id = requireNonEmptyString(params, "id");
178
+ if (!id.ok) {
179
+ return id;
180
+ }
181
+ const assignee = requireNonEmptyString(params, "assignee");
182
+ if (!assignee.ok) {
183
+ return assignee;
184
+ }
185
+ return {
186
+ ok: true,
187
+ action,
188
+ argv: ["claim", id.value, `--assignee=${assignee.value}`],
189
+ };
190
+ }
191
+
192
+ function buildBlockArgv(
193
+ params: Readonly<Record<string, unknown>>,
194
+ action: TsqChangeAction,
195
+ command: "block" | "unblock",
196
+ ): ValidationResult {
197
+ const child = requireNonEmptyString(params, "child");
198
+ if (!child.ok) {
199
+ return child;
200
+ }
201
+ const blocker = requireNonEmptyString(params, "blocker");
202
+ if (!blocker.ok) {
203
+ return blocker;
204
+ }
205
+ if (child.value === blocker.value) {
206
+ return { ok: false, message: "child and blocker cannot be the same task" };
207
+ }
208
+ return {
209
+ ok: true,
210
+ action,
211
+ argv: [command, child.value, "by", blocker.value],
212
+ };
213
+ }
214
+
215
+ function buildOrderArgv(
216
+ params: Readonly<Record<string, unknown>>,
217
+ action: TsqChangeAction,
218
+ command: "order" | "unorder",
219
+ ): ValidationResult {
220
+ const later = requireNonEmptyString(params, "later");
221
+ if (!later.ok) {
222
+ return later;
223
+ }
224
+ const earlier = requireNonEmptyString(params, "earlier");
225
+ if (!earlier.ok) {
226
+ return earlier;
227
+ }
228
+ if (later.value === earlier.value) {
229
+ return { ok: false, message: "later and earlier cannot be the same task" };
230
+ }
231
+ return {
232
+ ok: true,
233
+ action,
234
+ argv: [command, later.value, "after", earlier.value],
235
+ };
236
+ }
237
+
238
+ function appendOptionalStringFlag(
239
+ argv: string[],
240
+ params: Readonly<Record<string, unknown>>,
241
+ field: "description" | "parent",
242
+ flag: string,
243
+ ): ValidationResult | undefined {
244
+ const value = getOptionalNonEmptyString(params, field);
245
+ if (!value.ok) {
246
+ return value;
247
+ }
248
+ if (value.value !== undefined) {
249
+ argv.push(`${flag}=${value.value}`);
250
+ }
251
+ return undefined;
252
+ }
253
+
254
+ // --- validation helpers (params+field pattern, specific to builders) ---
255
+
256
+ function requireNonEmptyString(
257
+ params: Readonly<Record<string, unknown>>,
258
+ field: string,
259
+ ):
260
+ | { readonly ok: true; readonly value: string }
261
+ | { readonly ok: false; readonly message: string } {
262
+ const value = params[field];
263
+ if (typeof value !== "string" || value.trim().length === 0) {
264
+ return { ok: false, message: `${field} is required` };
265
+ }
266
+ return { ok: true, value };
267
+ }
268
+
269
+ function getOptionalNonEmptyString(
270
+ params: Readonly<Record<string, unknown>>,
271
+ field: string,
272
+ ):
273
+ | { readonly ok: true; readonly value: string | undefined }
274
+ | { readonly ok: false; readonly message: string } {
275
+ const value = params[field];
276
+ if (value === undefined) {
277
+ return { ok: true, value: undefined };
278
+ }
279
+ if (typeof value !== "string") {
280
+ return { ok: false, message: `${field} must be a string` };
281
+ }
282
+ if (value.trim().length === 0) {
283
+ return { ok: true, value: undefined };
284
+ }
285
+ return { ok: true, value };
286
+ }
287
+
288
+ function requireInteger(
289
+ params: Readonly<Record<string, unknown>>,
290
+ field: string,
291
+ ):
292
+ | { readonly ok: true; readonly value: number }
293
+ | { readonly ok: false; readonly message: string } {
294
+ const value = params[field];
295
+ if (typeof value !== "number" || !Number.isInteger(value)) {
296
+ return { ok: false, message: `${field} is required` };
297
+ }
298
+ return { ok: true, value };
299
+ }
300
+
301
+ function getOptionalBoolean(
302
+ params: Readonly<Record<string, unknown>>,
303
+ field: string,
304
+ ):
305
+ | { readonly ok: true; readonly value: boolean | undefined }
306
+ | { readonly ok: false; readonly message: string } {
307
+ const value = params[field];
308
+ if (value === undefined) {
309
+ return { ok: true, value: undefined };
310
+ }
311
+ if (typeof value !== "boolean") {
312
+ return { ok: false, message: `${field} must be a boolean` };
313
+ }
314
+ return { ok: true, value };
315
+ }