@bumpyclock/pi-tasque 0.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bumpyclock/pi-tasque",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Pi extension package for Tasque durable tasks plus in-session todos.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,6 +20,11 @@ import {
20
20
  } from "../session-todos/state/state-reducer.js";
21
21
  import { commitState, getState } from "../session-todos/state/store.js";
22
22
  import type { Task, TaskStatus } from "../session-todos/tool/types.js";
23
+ import {
24
+ asRecord,
25
+ copyKnownErrorFields,
26
+ isRecord,
27
+ } from "../shared/error-utils.js";
23
28
  import {
24
29
  errorToolDetails,
25
30
  okToolDetails,
@@ -470,33 +475,3 @@ function serializeError(error: unknown): Record<string, unknown> {
470
475
  }
471
476
  return { value: String(error) };
472
477
  }
473
-
474
- function copyKnownErrorFields(error: Error): Record<string, unknown> {
475
- const record = error as unknown as Record<string, unknown>;
476
- const output: Record<string, unknown> = {};
477
- for (const key of [
478
- "code",
479
- "command",
480
- "details",
481
- "stderr",
482
- "stdout",
483
- "killed",
484
- "args",
485
- ] as const) {
486
- if (record[key] !== undefined) {
487
- output[key] = record[key];
488
- }
489
- }
490
- return output;
491
- }
492
-
493
- function asRecord(value: unknown): Record<string, unknown> | undefined {
494
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
495
- return undefined;
496
- }
497
- return value as Record<string, unknown>;
498
- }
499
-
500
- function isRecord(value: unknown): value is Record<string, unknown> {
501
- return asRecord(value) !== undefined;
502
- }
@@ -5,6 +5,7 @@ import { cloneTaskState } from "../session-todos/state/state.js";
5
5
  import { applyTaskMutation } from "../session-todos/state/state-reducer.js";
6
6
  import { commitState, getState } from "../session-todos/state/store.js";
7
7
  import type { Task } from "../session-todos/tool/types.js";
8
+ import { isRecord } from "../shared/error-utils.js";
8
9
  import {
9
10
  errorToolDetails,
10
11
  okToolDetails,
@@ -325,7 +326,3 @@ function serializeError(error: unknown): unknown {
325
326
  }
326
327
  return error;
327
328
  }
328
-
329
- function isRecord(value: unknown): value is Record<string, unknown> {
330
- return typeof value === "object" && value !== null && !Array.isArray(value);
331
- }
@@ -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
+ }
@@ -3,6 +3,7 @@ import type {
3
3
  ExecResult,
4
4
  ExtensionAPI,
5
5
  } from "@earendil-works/pi-coding-agent";
6
+ import { isRecord } from "../shared/error-utils.js";
6
7
  import {
7
8
  TSQ_SCHEMA_VERSION,
8
9
  type JsonValue,
@@ -228,7 +229,3 @@ function buildProcessErrorMessage(result: ExecResult): string {
228
229
  }
229
230
  return `tsq failed with exit code ${result.code}${killed}: ${summary}`;
230
231
  }
231
-
232
- function isRecord(value: unknown): value is Record<string, unknown> {
233
- return typeof value === "object" && value !== null && !Array.isArray(value);
234
- }
@@ -2,6 +2,7 @@ import type {
2
2
  ExtensionAPI,
3
3
  ExtensionContext,
4
4
  } from "@earendil-works/pi-coding-agent";
5
+ import { isRecord } from "../shared/error-utils.js";
5
6
  import {
6
7
  createTasqueStatusCache,
7
8
  formatTasqueStatusText,
@@ -196,7 +197,3 @@ function hasStatusUi(ctx: ExtensionContext): boolean {
196
197
  function getErrorMessage(error: unknown): string {
197
198
  return error instanceof Error ? error.message : String(error);
198
199
  }
199
-
200
- function isRecord(value: unknown): value is Record<string, unknown> {
201
- return typeof value === "object" && value !== null && !Array.isArray(value);
202
- }
@@ -0,0 +1,160 @@
1
+ import type { TaskBridgeParams } from "../bridge/types.js";
2
+ import { definedParams } from "../shared/validation.js";
3
+ import type { TaskParams } from "./task-schema.js";
4
+ import { getTodoId, hasWith } from "./task-validation.js";
5
+ import type { TsqChangeParams } from "./tools-change.js";
6
+ import type { TsqQueryParams } from "./tools-query.js";
7
+
8
+ export function toQueryParams(params: TaskParams): TsqQueryParams {
9
+ switch (params.action) {
10
+ case "doctor":
11
+ return { action: "doctor" };
12
+ case "find":
13
+ if (params.view === "tree") {
14
+ return definedParams<TsqQueryParams>({
15
+ action: "find_tree",
16
+ id: params.task,
17
+ });
18
+ }
19
+ return params.tasks === "open"
20
+ ? definedParams<TsqQueryParams>({
21
+ action: "find_open",
22
+ assignee: params.for,
23
+ })
24
+ : definedParams<TsqQueryParams>({
25
+ action: "find_ready",
26
+ lane: params.lane,
27
+ assignee: params.for,
28
+ });
29
+ case "show":
30
+ return definedParams<TsqQueryParams>({
31
+ action: hasWith(params, "spec") ? "show_with_spec" : "show",
32
+ id: params.task,
33
+ });
34
+ case "deps":
35
+ return definedParams<TsqQueryParams>({ action: "deps", id: params.task });
36
+ case "notes":
37
+ return definedParams<TsqQueryParams>({
38
+ action: "notes",
39
+ id: params.task,
40
+ });
41
+ case "similar":
42
+ return definedParams<TsqQueryParams>({
43
+ action: "similar",
44
+ query: params.query,
45
+ });
46
+ default:
47
+ throw new Error(`Unsupported query action: ${params.action}`);
48
+ }
49
+ }
50
+
51
+ export function toChangeParams(params: TaskParams): TsqChangeParams {
52
+ switch (params.action) {
53
+ case "create":
54
+ return definedParams<TsqChangeParams>({
55
+ action: "create",
56
+ title: params.task,
57
+ kind: params.kind,
58
+ priority: params.priority,
59
+ description: params.description,
60
+ parent: params.under,
61
+ planned: params.planned,
62
+ needsPlan: params.needsPlan,
63
+ });
64
+ case "note":
65
+ return definedParams<TsqChangeParams>({
66
+ action: "note",
67
+ id: params.task,
68
+ note: params.because,
69
+ });
70
+ case "finish":
71
+ return definedParams<TsqChangeParams>({
72
+ action: "done",
73
+ id: params.task,
74
+ note: params.because,
75
+ });
76
+ case "reopen":
77
+ case "start":
78
+ return definedParams<TsqChangeParams>({
79
+ action: params.action,
80
+ id: params.task,
81
+ });
82
+ case "defer":
83
+ return definedParams<TsqChangeParams>({
84
+ action: "defer",
85
+ id: params.task,
86
+ note: params.because,
87
+ });
88
+ case "block":
89
+ return definedParams<TsqChangeParams>({
90
+ action: "block",
91
+ child: params.task,
92
+ blocker: params.by,
93
+ });
94
+ case "unblock":
95
+ return definedParams<TsqChangeParams>({
96
+ action: "unblock",
97
+ child: params.task,
98
+ blocker: params.by,
99
+ });
100
+ case "order":
101
+ return definedParams<TsqChangeParams>({
102
+ action: "order",
103
+ later: params.task,
104
+ earlier: params.after,
105
+ });
106
+ case "unorder":
107
+ return definedParams<TsqChangeParams>({
108
+ action: "unorder",
109
+ later: params.task,
110
+ earlier: params.after,
111
+ });
112
+ default:
113
+ throw new Error(`Unsupported change action: ${params.action}`);
114
+ }
115
+ }
116
+
117
+ export function toClaimParams(
118
+ params: TaskParams,
119
+ ): Readonly<Record<string, unknown>> {
120
+ return definedParams<Readonly<Record<string, unknown>>>({
121
+ id: params.task,
122
+ assignee: params.for,
123
+ start: params.start,
124
+ requireSpec: params.requireSpec,
125
+ createTodo: params.todo === true,
126
+ });
127
+ }
128
+
129
+ export function toBridgeParams(params: TaskParams): TaskBridgeParams {
130
+ switch (params.action) {
131
+ case "link":
132
+ return definedParams<TaskBridgeParams>({
133
+ action: "link",
134
+ todoId: getTodoId(params.todo),
135
+ tsqId: params.task,
136
+ });
137
+ case "list_links":
138
+ return { action: "list_links" };
139
+ case "promote":
140
+ return definedParams<TaskBridgeParams>({
141
+ action: "promote_todo",
142
+ todoId: getTodoId(params.todo),
143
+ assignee: params.for,
144
+ kind: params.kind,
145
+ priority: params.priority,
146
+ description: params.description,
147
+ parent: params.under,
148
+ planned: params.planned,
149
+ needsPlan: params.needsPlan,
150
+ });
151
+ case "import":
152
+ return definedParams<TaskBridgeParams>({
153
+ action: "import_tsq",
154
+ tsqId: params.task,
155
+ owner: params.for,
156
+ });
157
+ default:
158
+ throw new Error(`Unsupported bridge action: ${params.action}`);
159
+ }
160
+ }