@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.
- package/README.md +168 -140
- package/package.json +24 -6
- package/src/bridge/bridge-tool.ts +10 -13
- package/src/bridge/import-tsq.ts +5 -30
- package/src/bridge/promote-todo.ts +1 -4
- package/src/bridge/types.ts +11 -12
- package/src/durable-tasks/bulk-contract.ts +176 -0
- package/src/durable-tasks/cache.ts +1 -4
- package/src/durable-tasks/change-command-builder.ts +315 -0
- package/src/durable-tasks/handoff-guard.ts +329 -0
- package/src/durable-tasks/project.ts +71 -0
- package/src/durable-tasks/runner.ts +1 -4
- package/src/durable-tasks/status.ts +20 -5
- package/src/durable-tasks/task-mappers.ts +160 -0
- package/src/durable-tasks/task-schema.ts +193 -0
- package/src/durable-tasks/task-tool.ts +197 -0
- package/src/durable-tasks/task-validation.ts +123 -0
- package/src/durable-tasks/tools-bulk.ts +141 -0
- package/src/durable-tasks/tools-change.ts +111 -382
- package/src/durable-tasks/tools-claim.ts +15 -43
- package/src/durable-tasks/tools-handoff.ts +95 -0
- package/src/durable-tasks/tools-query.ts +58 -29
- package/src/durable-tasks/tools-spec.ts +230 -0
- package/src/durable-tasks/tools-tree-create.ts +221 -0
- package/src/guidelines/internal-tools.ts +33 -0
- package/src/guidelines/task.ts +5 -0
- package/src/guidelines/todo.ts +5 -0
- package/src/index.ts +2 -13
- package/src/session-todos/state/replay.ts +10 -13
- package/src/session-todos/todo-overlay.ts +1 -1
- package/src/session-todos/todo.ts +11 -15
- package/src/session-todos/tool/types.ts +7 -7
- package/src/shared/error-utils.ts +29 -0
- package/src/shared/validation.ts +25 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nested create_tree executor for the `task` tool.
|
|
3
|
+
*
|
|
4
|
+
* Walks a validated `CreateTreeNode` tree depth-first, creating each parent
|
|
5
|
+
* before its children and passing the generated parent id via `--parent`.
|
|
6
|
+
*
|
|
7
|
+
* Fail-fast: on the first creation failure the entire remaining tree is
|
|
8
|
+
* skipped — no orphan children are created. No rollback of already-created
|
|
9
|
+
* nodes.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
AgentToolResult,
|
|
14
|
+
ExtensionAPI,
|
|
15
|
+
ExtensionContext,
|
|
16
|
+
} from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import type { CreateTreeNode, CreateTreeResult } from "./bulk-contract.js";
|
|
18
|
+
import { runQueuedMutation } from "./mutation-queue.js";
|
|
19
|
+
import { runTsqJson } from "./runner.js";
|
|
20
|
+
import { asRecord } from "../shared/error-utils.js";
|
|
21
|
+
import {
|
|
22
|
+
okToolDetails,
|
|
23
|
+
textToolResult,
|
|
24
|
+
type StandardToolDetails,
|
|
25
|
+
} from "../shared/tool-result.js";
|
|
26
|
+
|
|
27
|
+
// ── Types ──────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
interface CreatedEntry {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
readonly title: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface FailedEntry {
|
|
35
|
+
readonly title: string;
|
|
36
|
+
readonly error: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface SkippedEntry {
|
|
40
|
+
readonly title: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Mutable accumulator threaded through the walk. */
|
|
44
|
+
interface TreeWalkState {
|
|
45
|
+
readonly created: CreatedEntry[];
|
|
46
|
+
failed?: FailedEntry;
|
|
47
|
+
readonly skipped: SkippedEntry[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type CreateTreeDetails = ReturnType<
|
|
51
|
+
typeof okToolDetails<CreateTreeResult>
|
|
52
|
+
>;
|
|
53
|
+
|
|
54
|
+
// ── Public API ─────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
export async function executeCreateTree(
|
|
57
|
+
pi: ExtensionAPI,
|
|
58
|
+
root: CreateTreeNode,
|
|
59
|
+
signal: AbortSignal | undefined,
|
|
60
|
+
ctx: Pick<ExtensionContext, "cwd">,
|
|
61
|
+
): Promise<AgentToolResult<StandardToolDetails<CreateTreeResult>>> {
|
|
62
|
+
const state: TreeWalkState = { created: [], skipped: [] };
|
|
63
|
+
|
|
64
|
+
await walkAndCreate(pi, ctx, signal, root, undefined, state);
|
|
65
|
+
|
|
66
|
+
const result: CreateTreeResult = {
|
|
67
|
+
created: state.created,
|
|
68
|
+
...(state.failed ? { failed: state.failed } : {}),
|
|
69
|
+
skipped: state.skipped,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return textToolResult(formatResultText(result), okToolDetails(result));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Tree walk ──────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
async function walkAndCreate(
|
|
78
|
+
pi: ExtensionAPI,
|
|
79
|
+
ctx: Pick<ExtensionContext, "cwd">,
|
|
80
|
+
signal: AbortSignal | undefined,
|
|
81
|
+
node: CreateTreeNode,
|
|
82
|
+
parentId: string | undefined,
|
|
83
|
+
state: TreeWalkState,
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
// If a prior node already failed, skip this entire subtree.
|
|
86
|
+
if (state.failed) {
|
|
87
|
+
collectSkipped(node, state.skipped);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const argv = buildCreateArgv(node, parentId);
|
|
92
|
+
|
|
93
|
+
let createdId: string;
|
|
94
|
+
try {
|
|
95
|
+
const result = await runMutation(pi, ctx, argv, signal);
|
|
96
|
+
const extracted = extractCreatedId(result, node.title);
|
|
97
|
+
createdId = extracted.id;
|
|
98
|
+
state.created.push({ id: extracted.id, title: extracted.title });
|
|
99
|
+
} catch (error) {
|
|
100
|
+
state.failed = { title: node.title, error: getErrorMessage(error) };
|
|
101
|
+
// Skip all children of this failed node
|
|
102
|
+
if (node.children) {
|
|
103
|
+
for (const child of node.children) {
|
|
104
|
+
collectSkipped(child, state.skipped);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Recurse into children with the newly created parent id.
|
|
111
|
+
if (node.children) {
|
|
112
|
+
for (const child of node.children) {
|
|
113
|
+
await walkAndCreate(pi, ctx, signal, child, createdId, state);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── CLI argv builder ───────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
function buildCreateArgv(
|
|
121
|
+
node: CreateTreeNode,
|
|
122
|
+
parentId: string | undefined,
|
|
123
|
+
): string[] {
|
|
124
|
+
const argv = [
|
|
125
|
+
"create",
|
|
126
|
+
`--kind=${node.kind}`,
|
|
127
|
+
"-p",
|
|
128
|
+
String(node.priority),
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
if (node.description) {
|
|
132
|
+
argv.push(`--description=${node.description}`);
|
|
133
|
+
}
|
|
134
|
+
if (parentId) {
|
|
135
|
+
argv.push(`--parent=${parentId}`);
|
|
136
|
+
}
|
|
137
|
+
if (node.planned === true) {
|
|
138
|
+
argv.push("--planned");
|
|
139
|
+
} else if (node.needsPlan === true) {
|
|
140
|
+
argv.push("--needs-plan");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
argv.push("--", node.title);
|
|
144
|
+
return argv;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Mutation runner ────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
function runMutation(
|
|
150
|
+
pi: ExtensionAPI,
|
|
151
|
+
ctx: Pick<ExtensionContext, "cwd">,
|
|
152
|
+
argv: readonly string[],
|
|
153
|
+
signal: AbortSignal | undefined,
|
|
154
|
+
): Promise<unknown> {
|
|
155
|
+
const options = signal === undefined ? {} : { signal };
|
|
156
|
+
return runQueuedMutation(ctx.cwd, () =>
|
|
157
|
+
runTsqJson(pi, { cwd: ctx.cwd }, argv, options),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Result extraction ──────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
function extractCreatedId(
|
|
164
|
+
result: unknown,
|
|
165
|
+
fallbackTitle: string,
|
|
166
|
+
): { readonly id: string; readonly title: string } {
|
|
167
|
+
const root = asRecord(result);
|
|
168
|
+
const task = asRecord(root?.task) ?? root;
|
|
169
|
+
|
|
170
|
+
const id = typeof task?.id === "string" ? task.id : undefined;
|
|
171
|
+
if (!id) {
|
|
172
|
+
throw new Error("tsq create did not return a task id");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const title =
|
|
176
|
+
typeof task?.title === "string" ? task.title : fallbackTitle;
|
|
177
|
+
return { id, title };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Skipped collector ──────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
function collectSkipped(
|
|
183
|
+
node: CreateTreeNode,
|
|
184
|
+
skipped: SkippedEntry[],
|
|
185
|
+
): void {
|
|
186
|
+
skipped.push({ title: node.title });
|
|
187
|
+
if (node.children) {
|
|
188
|
+
for (const child of node.children) {
|
|
189
|
+
collectSkipped(child, skipped);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Text formatting ────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
function formatResultText(result: CreateTreeResult): string {
|
|
197
|
+
const lines: string[] = [];
|
|
198
|
+
|
|
199
|
+
if (result.created.length > 0) {
|
|
200
|
+
const noun = result.created.length === 1 ? "task" : "tasks";
|
|
201
|
+
lines.push(
|
|
202
|
+
`Created ${result.created.length} ${noun}: ${result.created.map((c) => c.id).join(", ")}`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (result.failed) {
|
|
207
|
+
lines.push(`Failed: "${result.failed.title}" — ${result.failed.error}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (result.skipped.length > 0) {
|
|
211
|
+
lines.push(`${result.skipped.length} skipped`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return lines.join("\n");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── Utilities ──────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
function getErrorMessage(error: unknown): string {
|
|
220
|
+
return error instanceof Error ? error.message : String(error);
|
|
221
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export const READ_TASKS_PROMPT_SNIPPET = "Read durable task state.";
|
|
2
|
+
|
|
3
|
+
export const READ_TASKS_PROMPT_GUIDELINES = [
|
|
4
|
+
"Use task read actions for fresh durable task state; read actions do not mutate tasks.",
|
|
5
|
+
"Include spec content only when needed; regular task details are more concise.",
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
export const CHANGE_TASKS_PROMPT_SNIPPET = "Mutate durable tasks.";
|
|
9
|
+
|
|
10
|
+
export const CHANGE_TASKS_PROMPT_GUIDELINES = [
|
|
11
|
+
"Use task mutations for explicit durable task changes; use `todo` for current-session checklist steps.",
|
|
12
|
+
"Use block/unblock for hard blockers and order/unorder for task sequencing.",
|
|
13
|
+
"Inspect task details or dependencies before and after graph changes when the relationship is not obvious.",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export const CLAIM_TASK_PROMPT_SNIPPET = "Claim durable task ownership.";
|
|
17
|
+
|
|
18
|
+
export const CLAIM_TASK_PROMPT_GUIDELINES = [
|
|
19
|
+
"Pass your own role/name as assignee when available, e.g. developer, worker, oracle.",
|
|
20
|
+
"Create a linked todo only when you want one session todo for the claimed task.",
|
|
21
|
+
"Completing a linked todo does not mark the durable task done; durable completion must be explicit.",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export const TASK_TODO_BRIDGE_PROMPT_SNIPPET =
|
|
25
|
+
"Link session todos and durable tasks.";
|
|
26
|
+
|
|
27
|
+
export const TASK_TODO_BRIDGE_PROMPT_GUIDELINES = [
|
|
28
|
+
"Use link to associate an existing todo with an existing durable task via todo metadata.",
|
|
29
|
+
"Use list links to inspect current todo ↔ durable task associations.",
|
|
30
|
+
"Use promote to create a durable task from a todo and link the promoted todo explicitly.",
|
|
31
|
+
"Use import to create or reuse session todos from durable task state and link them explicitly.",
|
|
32
|
+
"Todo completion does not mark a durable task done; durable completion stays explicit.",
|
|
33
|
+
];
|
package/src/index.ts
CHANGED
|
@@ -1,21 +1,10 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
import { importTsqHandler } from "./bridge/import-tsq.js";
|
|
4
|
-
import { promoteTodoHandler } from "./bridge/promote-todo.js";
|
|
2
|
+
import { registerTaskTool } from "./durable-tasks/task-tool.js";
|
|
5
3
|
import { registerTasqueStatusLifecycle } from "./durable-tasks/status.js";
|
|
6
|
-
import { registerTsqChangeTool } from "./durable-tasks/tools-change.js";
|
|
7
|
-
import { registerTsqClaimTool } from "./durable-tasks/tools-claim.js";
|
|
8
|
-
import { registerTsqQueryTool } from "./durable-tasks/tools-query.js";
|
|
9
4
|
import { registerSessionTodoModule } from "./session-todos/todo.js";
|
|
10
5
|
|
|
11
6
|
export default function piTasqueExtension(pi: ExtensionAPI): void {
|
|
12
7
|
registerSessionTodoModule(pi);
|
|
13
|
-
|
|
14
|
-
registerTsqChangeTool(pi);
|
|
15
|
-
registerTsqClaimTool(pi);
|
|
16
|
-
registerTaskBridgeTool(pi, {
|
|
17
|
-
promote_todo: promoteTodoHandler,
|
|
18
|
-
import_tsq: importTsqHandler,
|
|
19
|
-
});
|
|
8
|
+
registerTaskTool(pi);
|
|
20
9
|
registerTasqueStatusLifecycle(pi);
|
|
21
10
|
}
|
|
@@ -33,8 +33,7 @@ const VALID_STATUSES = new Set<TaskStatus>([
|
|
|
33
33
|
"deleted",
|
|
34
34
|
]);
|
|
35
35
|
|
|
36
|
-
const
|
|
37
|
-
const TSQ_CLAIM_REPLAY_TOOL_NAME = "tsq_claim";
|
|
36
|
+
const DURABLE_TASK_REPLAY_TOOL_NAME = "task";
|
|
38
37
|
const TASK_BRIDGE_MUTATION_ACTIONS = new Set(["promote_todo", "import_tsq"]);
|
|
39
38
|
|
|
40
39
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
@@ -226,11 +225,10 @@ function applyReplayableClaimTodo(state: TaskState, todo: Task): TaskState {
|
|
|
226
225
|
|
|
227
226
|
/**
|
|
228
227
|
* Rebuild todo state from the current session branch. The latest compatible
|
|
229
|
-
* `todo` tool result wins; malformed snapshots are skipped. Successful
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
* imports/promotions, and claim-created todos survive reload/branch replay.
|
|
228
|
+
* `todo` tool result wins; malformed snapshots are skipped. Successful durable
|
|
229
|
+
* `task` results replay todo snapshots, links, and claim-created todos so
|
|
230
|
+
* bridge metadata, imports/promotions, and claim-created todos survive
|
|
231
|
+
* reload/branch replay.
|
|
234
232
|
*/
|
|
235
233
|
export function replayFromBranch(ctx: BranchContext): TaskState {
|
|
236
234
|
let result = emptyState();
|
|
@@ -248,7 +246,7 @@ export function replayFromBranch(ctx: BranchContext): TaskState {
|
|
|
248
246
|
continue;
|
|
249
247
|
}
|
|
250
248
|
|
|
251
|
-
if (message.toolName ===
|
|
249
|
+
if (message.toolName === DURABLE_TASK_REPLAY_TOOL_NAME) {
|
|
252
250
|
const snapshot = getReplayableBridgeTodoSnapshot(message.details);
|
|
253
251
|
if (snapshot !== undefined) {
|
|
254
252
|
result = snapshot;
|
|
@@ -256,12 +254,11 @@ export function replayFromBranch(ctx: BranchContext): TaskState {
|
|
|
256
254
|
}
|
|
257
255
|
|
|
258
256
|
const link = getReplayableBridgeLink(message.details);
|
|
259
|
-
if (link
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
257
|
+
if (link !== undefined) {
|
|
258
|
+
result = applyReplayableBridgeLink(result, link);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
263
261
|
|
|
264
|
-
if (message.toolName === TSQ_CLAIM_REPLAY_TOOL_NAME) {
|
|
265
262
|
const todo = getReplayableClaimTodo(message.details);
|
|
266
263
|
if (todo === undefined) continue;
|
|
267
264
|
result = applyReplayableClaimTodo(result, todo);
|
|
@@ -21,7 +21,7 @@ import type { TaskState } from "./state/state.js";
|
|
|
21
21
|
import { getState } from "./state/store.js";
|
|
22
22
|
import { formatOverlayTaskLine, formatStatusLabel } from "./view/format.js";
|
|
23
23
|
|
|
24
|
-
const WIDGET_KEY = "
|
|
24
|
+
const WIDGET_KEY = "pi-tasque-todos";
|
|
25
25
|
const MAX_WIDGET_LINES = 12;
|
|
26
26
|
const OVERLAY_HEADING = "Todos";
|
|
27
27
|
const OVERLAY_MORE = "more";
|
|
@@ -2,6 +2,11 @@ import type {
|
|
|
2
2
|
ExtensionAPI,
|
|
3
3
|
ExtensionContext,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { isRecord } from "../shared/error-utils.js";
|
|
6
|
+
import {
|
|
7
|
+
TODO_PROMPT_GUIDELINES,
|
|
8
|
+
TODO_PROMPT_SNIPPET,
|
|
9
|
+
} from "../guidelines/todo.js";
|
|
5
10
|
import {
|
|
6
11
|
selectTasksByStatus,
|
|
7
12
|
selectTodoCounts,
|
|
@@ -32,17 +37,12 @@ import {
|
|
|
32
37
|
const SECTION_PENDING = "── Pending ──";
|
|
33
38
|
const SECTION_IN_PROGRESS = "── In Progress ──";
|
|
34
39
|
const SECTION_COMPLETED = "── Completed ──";
|
|
35
|
-
const TODO_AFFECTING_TOOLS = new Set(["todo", "
|
|
36
|
-
|
|
37
|
-
export const TODO_PROMPT_SNIPPET =
|
|
38
|
-
"Manage current-session tactical todos for multi-step execution.";
|
|
40
|
+
const TODO_AFFECTING_TOOLS = new Set(["todo", "task"]);
|
|
39
41
|
|
|
40
|
-
export
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
"Use blockedBy for session-local dependencies; list hides deleted tombstones unless includeDeleted is true.",
|
|
45
|
-
];
|
|
42
|
+
export {
|
|
43
|
+
TODO_PROMPT_GUIDELINES,
|
|
44
|
+
TODO_PROMPT_SNIPPET,
|
|
45
|
+
} from "../guidelines/todo.js";
|
|
46
46
|
|
|
47
47
|
export { isTransitionValid } from "./state/invariants.js";
|
|
48
48
|
export { applyTaskMutation } from "./state/state-reducer.js";
|
|
@@ -75,7 +75,7 @@ export function registerTodoTool(pi: ExtensionAPI): void {
|
|
|
75
75
|
name: TOOL_NAME,
|
|
76
76
|
label: TOOL_LABEL,
|
|
77
77
|
description:
|
|
78
|
-
"Manage current-session todos for tactical execution. Actions: create, update, list, get, delete, clear. Use for this session's
|
|
78
|
+
"Manage current-session todos for tactical execution. Actions: create, update, list, get, delete, clear. Use for this session's checklist; use task for durable project work.",
|
|
79
79
|
promptSnippet: TODO_PROMPT_SNIPPET,
|
|
80
80
|
promptGuidelines: TODO_PROMPT_GUIDELINES,
|
|
81
81
|
parameters: TodoParamsSchema,
|
|
@@ -233,7 +233,3 @@ function isSuccessfulToolExecutionResult(event: {
|
|
|
233
233
|
if (details.ok === false) return false;
|
|
234
234
|
return details.error === undefined;
|
|
235
235
|
}
|
|
236
|
-
|
|
237
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
238
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
239
|
-
}
|
|
@@ -79,7 +79,7 @@ export interface TaskMutationParams {
|
|
|
79
79
|
|
|
80
80
|
// ---------------------------------------------------------------------------
|
|
81
81
|
// TypeBox parameter schema — every `description` doubles as LLM-facing prompt
|
|
82
|
-
// copy.
|
|
82
|
+
// copy. Keep field order and wording stable for replay and agent ergonomics.
|
|
83
83
|
// ---------------------------------------------------------------------------
|
|
84
84
|
|
|
85
85
|
export const TodoParamsSchema = Type.Object({
|
|
@@ -92,10 +92,10 @@ export const TodoParamsSchema = Type.Object({
|
|
|
92
92
|
"clear",
|
|
93
93
|
] as const),
|
|
94
94
|
subject: Type.Optional(
|
|
95
|
-
Type.String({ description: "
|
|
95
|
+
Type.String({ description: "Todo subject line (required for create)" }),
|
|
96
96
|
),
|
|
97
97
|
description: Type.Optional(
|
|
98
|
-
Type.String({ description: "Long-form
|
|
98
|
+
Type.String({ description: "Long-form todo description" }),
|
|
99
99
|
),
|
|
100
100
|
activeForm: Type.Optional(
|
|
101
101
|
Type.String({
|
|
@@ -115,17 +115,17 @@ export const TodoParamsSchema = Type.Object({
|
|
|
115
115
|
),
|
|
116
116
|
addBlockedBy: Type.Optional(
|
|
117
117
|
Type.Array(Type.Number(), {
|
|
118
|
-
description: "
|
|
118
|
+
description: "Todo ids to add to blockedBy (update only, additive merge)",
|
|
119
119
|
}),
|
|
120
120
|
),
|
|
121
121
|
removeBlockedBy: Type.Optional(
|
|
122
122
|
Type.Array(Type.Number(), {
|
|
123
123
|
description:
|
|
124
|
-
"
|
|
124
|
+
"Todo ids to remove from blockedBy (update only, additive merge)",
|
|
125
125
|
}),
|
|
126
126
|
),
|
|
127
127
|
owner: Type.Optional(
|
|
128
|
-
Type.String({ description: "Agent/owner assigned to this
|
|
128
|
+
Type.String({ description: "Agent/owner assigned to this todo" }),
|
|
129
129
|
),
|
|
130
130
|
metadata: Type.Optional(
|
|
131
131
|
Type.Record(Type.String(), Type.Unknown(), {
|
|
@@ -135,7 +135,7 @@ export const TodoParamsSchema = Type.Object({
|
|
|
135
135
|
),
|
|
136
136
|
id: Type.Optional(
|
|
137
137
|
Type.Number({
|
|
138
|
-
description: "
|
|
138
|
+
description: "Todo id (required for update, get, delete)",
|
|
139
139
|
}),
|
|
140
140
|
),
|
|
141
141
|
includeDeleted: Type.Optional(
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
2
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
3
|
+
return undefined;
|
|
4
|
+
}
|
|
5
|
+
return value as Record<string, unknown>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
9
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function copyKnownErrorFields(error: Error): Record<string, unknown> {
|
|
13
|
+
const record = error as unknown as Record<string, unknown>;
|
|
14
|
+
const output: Record<string, unknown> = {};
|
|
15
|
+
for (const key of [
|
|
16
|
+
"code",
|
|
17
|
+
"command",
|
|
18
|
+
"details",
|
|
19
|
+
"stderr",
|
|
20
|
+
"stdout",
|
|
21
|
+
"killed",
|
|
22
|
+
"args",
|
|
23
|
+
] as const) {
|
|
24
|
+
if (record[key] !== undefined) {
|
|
25
|
+
output[key] = record[key];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return output;
|
|
29
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function definedParams<T>(params: Record<string, unknown>): T {
|
|
2
|
+
const output: Record<string, unknown> = {};
|
|
3
|
+
for (const [key, value] of Object.entries(params)) {
|
|
4
|
+
if (value !== undefined) {
|
|
5
|
+
output[key] = value;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
return output as T;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function fieldRequired(field: string): {
|
|
12
|
+
readonly ok: false;
|
|
13
|
+
readonly message: string;
|
|
14
|
+
} {
|
|
15
|
+
return { ok: false, message: `${field} is required` };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function requireStringField(
|
|
19
|
+
value: string | undefined,
|
|
20
|
+
field: string,
|
|
21
|
+
): { readonly ok: true } | { readonly ok: false; readonly message: string } {
|
|
22
|
+
return typeof value === "string" && value.trim().length > 0
|
|
23
|
+
? { ok: true }
|
|
24
|
+
: fieldRequired(field);
|
|
25
|
+
}
|