@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,95 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentToolResult,
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { errorToolDetails, textToolResult } from "../shared/tool-result.js";
|
|
7
|
+
import {
|
|
8
|
+
collectHandoffStatus,
|
|
9
|
+
type HandoffCheckResult,
|
|
10
|
+
} from "./handoff-guard.js";
|
|
11
|
+
|
|
12
|
+
export async function executeHandoffCheck(
|
|
13
|
+
pi: ExtensionAPI,
|
|
14
|
+
signal: AbortSignal | undefined,
|
|
15
|
+
ctx: ExtensionContext,
|
|
16
|
+
): Promise<AgentToolResult<unknown>> {
|
|
17
|
+
const result = await collectHandoffStatus({
|
|
18
|
+
pi,
|
|
19
|
+
cwd: ctx.cwd,
|
|
20
|
+
...(signal != null ? { signal } : {}),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (!result.ok) {
|
|
24
|
+
return textToolResult(
|
|
25
|
+
`Error: ${result.message}`,
|
|
26
|
+
errorToolDetails({ code: result.code, message: result.message }),
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return textToolResult(formatHandoffText(result), {
|
|
31
|
+
ok: true,
|
|
32
|
+
ready: result.ready,
|
|
33
|
+
...formatHandoffDetails(result),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function formatHandoffText(result: HandoffCheckResult & { ok: true }): string {
|
|
38
|
+
const lines: string[] = [
|
|
39
|
+
result.ready
|
|
40
|
+
? "Handoff ready: all session todos complete and linked tasks resolved."
|
|
41
|
+
: "Handoff not ready.",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
if ("todoBlockers" in result && result.todoBlockers?.length) {
|
|
45
|
+
lines.push("", "Todo blockers:");
|
|
46
|
+
for (const b of result.todoBlockers) {
|
|
47
|
+
lines.push(`- #${b.todoId} "${b.subject}" — ${b.reason}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if ("linkedBlockers" in result && result.linkedBlockers?.length) {
|
|
52
|
+
lines.push("", "Linked task blockers:");
|
|
53
|
+
for (const b of result.linkedBlockers) {
|
|
54
|
+
lines.push(`- ${b.tsqId} (todo #${b.todoId}) — ${b.status}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if ("linkedWarnings" in result && result.linkedWarnings?.length) {
|
|
59
|
+
lines.push("", "Warnings:");
|
|
60
|
+
for (const w of result.linkedWarnings) {
|
|
61
|
+
lines.push(`- ${w.tsqId} (todo #${w.todoId}) — ${w.status}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if ("readErrors" in result && result.readErrors?.length) {
|
|
66
|
+
lines.push("", "Read errors:");
|
|
67
|
+
for (const e of result.readErrors) {
|
|
68
|
+
lines.push(`- ${e.tsqId} — ${e.code}: ${e.message}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return lines.join("\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function formatHandoffDetails(
|
|
76
|
+
result: HandoffCheckResult & { ok: true },
|
|
77
|
+
): Record<string, unknown> {
|
|
78
|
+
const details: Record<string, unknown> = {};
|
|
79
|
+
if (result.projectRoot !== undefined) {
|
|
80
|
+
details.projectRoot = result.projectRoot;
|
|
81
|
+
}
|
|
82
|
+
if ("todoBlockers" in result && result.todoBlockers?.length) {
|
|
83
|
+
details.todoBlockers = result.todoBlockers;
|
|
84
|
+
}
|
|
85
|
+
if ("linkedBlockers" in result && result.linkedBlockers?.length) {
|
|
86
|
+
details.linkedBlockers = result.linkedBlockers;
|
|
87
|
+
}
|
|
88
|
+
if ("linkedWarnings" in result && result.linkedWarnings?.length) {
|
|
89
|
+
details.linkedWarnings = result.linkedWarnings;
|
|
90
|
+
}
|
|
91
|
+
if ("readErrors" in result && result.readErrors?.length) {
|
|
92
|
+
details.readErrors = result.readErrors;
|
|
93
|
+
}
|
|
94
|
+
return details;
|
|
95
|
+
}
|
|
@@ -5,6 +5,11 @@ import type {
|
|
|
5
5
|
ExtensionContext,
|
|
6
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { type Static, Type } from "typebox";
|
|
8
|
+
import {
|
|
9
|
+
READ_TASKS_PROMPT_GUIDELINES,
|
|
10
|
+
READ_TASKS_PROMPT_SNIPPET,
|
|
11
|
+
} from "../guidelines/internal-tools.js";
|
|
12
|
+
import { isRecord } from "../shared/error-utils.js";
|
|
8
13
|
import { truncatedTextToolResult } from "../shared/tool-result.js";
|
|
9
14
|
import type { TruncatedText } from "../shared/truncation.js";
|
|
10
15
|
import { runTsqJson } from "./runner.js";
|
|
@@ -40,12 +45,12 @@ export type TsqQueryAction = (typeof TSQ_QUERY_ACTIONS)[number];
|
|
|
40
45
|
export const TsqQueryParamsSchema = Type.Object(
|
|
41
46
|
{
|
|
42
47
|
action: StringEnum(TSQ_QUERY_ACTIONS, {
|
|
43
|
-
description: "Read-only
|
|
48
|
+
description: "Read-only durable task query to run.",
|
|
44
49
|
}),
|
|
45
50
|
id: Type.Optional(
|
|
46
51
|
Type.String({
|
|
47
52
|
description:
|
|
48
|
-
"
|
|
53
|
+
"Durable task id. Required for show, spec details, deps, and notes.",
|
|
49
54
|
}),
|
|
50
55
|
),
|
|
51
56
|
lane: Type.Optional(
|
|
@@ -98,15 +103,10 @@ const DEFAULT_QUERY_TIMEOUT_MS = 10_000;
|
|
|
98
103
|
export function registerTsqQueryTool(pi: ExtensionAPI): void {
|
|
99
104
|
pi.registerTool({
|
|
100
105
|
name: TSQ_QUERY_TOOL_NAME,
|
|
101
|
-
label: "
|
|
102
|
-
description:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
"Use tsq_query for fresh read-only Tasque task state such as doctor, ready/open lists, show, deps, notes, tree, and similar lookups.",
|
|
106
|
-
promptGuidelines: [
|
|
107
|
-
"tsq_query is read-only; use mutation tools for lifecycle or notes.",
|
|
108
|
-
"Use show_with_spec only when spec content is needed; regular show is more concise.",
|
|
109
|
-
],
|
|
106
|
+
label: "Task Query",
|
|
107
|
+
description: "Read durable task state. Does not mutate tasks.",
|
|
108
|
+
promptSnippet: READ_TASKS_PROMPT_SNIPPET,
|
|
109
|
+
promptGuidelines: READ_TASKS_PROMPT_GUIDELINES,
|
|
110
110
|
parameters: TsqQueryParamsSchema,
|
|
111
111
|
executionMode: "parallel",
|
|
112
112
|
async execute(
|
|
@@ -132,10 +132,11 @@ export async function executeTsqQuery(
|
|
|
132
132
|
AgentToolResult<TsqQueryDetails & { readonly truncation: TruncatedText }>
|
|
133
133
|
> {
|
|
134
134
|
const argv = buildTsqQueryArgv(params);
|
|
135
|
-
const
|
|
135
|
+
const rawData = await runTsqJson<TsqQueryData>(pi, { cwd: ctx.cwd }, argv, {
|
|
136
136
|
timeout: DEFAULT_QUERY_TIMEOUT_MS,
|
|
137
137
|
...(signal === undefined ? {} : { signal }),
|
|
138
138
|
});
|
|
139
|
+
const data = normalizeQueryData(params, rawData);
|
|
139
140
|
const text = formatTsqQueryResult(params, data);
|
|
140
141
|
|
|
141
142
|
const details: TsqQueryDetails = {
|
|
@@ -197,6 +198,24 @@ export function buildTsqQueryArgv(params: TsqQueryParams): string[] {
|
|
|
197
198
|
}
|
|
198
199
|
}
|
|
199
200
|
|
|
201
|
+
function normalizeQueryData(
|
|
202
|
+
params: TsqQueryParams,
|
|
203
|
+
data: TsqQueryData,
|
|
204
|
+
): TsqQueryData {
|
|
205
|
+
if (params.action !== "find_tree") {
|
|
206
|
+
return data;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const id = params.id?.trim();
|
|
210
|
+
if (id === undefined || id.length === 0) {
|
|
211
|
+
return data;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const found = findTaskTreeNode(getTreeRoots(data), id);
|
|
215
|
+
const filtered = found === undefined ? [] : [found];
|
|
216
|
+
return Array.isArray(data) ? filtered : { tree: filtered };
|
|
217
|
+
}
|
|
218
|
+
|
|
200
219
|
function formatTsqQueryResult(
|
|
201
220
|
params: TsqQueryParams,
|
|
202
221
|
data: TsqQueryData,
|
|
@@ -229,7 +248,7 @@ function formatDoctor(data: TsqQueryData): string {
|
|
|
229
248
|
? doctor.issues.length
|
|
230
249
|
: undefined;
|
|
231
250
|
const parts = [
|
|
232
|
-
"
|
|
251
|
+
"Task doctor",
|
|
233
252
|
formatMetric(doctor.tasks, "tasks"),
|
|
234
253
|
formatMetric(doctor.events, "events"),
|
|
235
254
|
issueCount === undefined ? undefined : `${issueCount} issues`,
|
|
@@ -242,7 +261,7 @@ function formatTaskList(
|
|
|
242
261
|
tasks: readonly TsqTask[],
|
|
243
262
|
): string {
|
|
244
263
|
const lines = [
|
|
245
|
-
`
|
|
264
|
+
`Task ${action}: ${formatCount(tasks.length, "task")}${formatLimitNotice(tasks.length)}`,
|
|
246
265
|
...tasks.slice(0, MAX_RENDERED_ITEMS).map(formatTaskLine),
|
|
247
266
|
];
|
|
248
267
|
return lines.join("\n");
|
|
@@ -268,21 +287,19 @@ function formatShow(data: TsqQueryData): string {
|
|
|
268
287
|
]
|
|
269
288
|
.filter((part): part is string => part !== undefined)
|
|
270
289
|
.join(" · ");
|
|
271
|
-
return [`
|
|
272
|
-
.filter(Boolean)
|
|
273
|
-
.join("\n");
|
|
290
|
+
return [`Task: ${parts}: ${task.title}`, extra].filter(Boolean).join("\n");
|
|
274
291
|
}
|
|
275
|
-
return "
|
|
292
|
+
return "Task: no task data returned";
|
|
276
293
|
}
|
|
277
294
|
|
|
278
295
|
function formatDeps(data: TsqQueryData): string {
|
|
279
296
|
const root = (data as Partial<{ root: TsqDepTreeNode }>).root;
|
|
280
297
|
if (root === undefined) {
|
|
281
|
-
return "
|
|
298
|
+
return "Task deps: no dependency data returned";
|
|
282
299
|
}
|
|
283
300
|
const lines = flattenDepTree(root).slice(0, MAX_RENDERED_ITEMS);
|
|
284
301
|
return [
|
|
285
|
-
`
|
|
302
|
+
`Task deps: ${root.id}${formatLimitNotice(countDepTree(root))}`,
|
|
286
303
|
...lines.map(
|
|
287
304
|
({ node, indent }) =>
|
|
288
305
|
`${" ".repeat(indent)}${formatTaskLine(node.task)}`,
|
|
@@ -295,7 +312,7 @@ function formatNotes(data: TsqQueryData): string {
|
|
|
295
312
|
const notes = notesData.notes ?? [];
|
|
296
313
|
const taskId = notesData.task_id ?? "task";
|
|
297
314
|
return [
|
|
298
|
-
`
|
|
315
|
+
`Task notes for ${taskId}: ${formatCount(notes.length, "note")}${formatLimitNotice(notes.length)}`,
|
|
299
316
|
...notes.slice(0, MAX_RENDERED_ITEMS).map((note) => {
|
|
300
317
|
const firstLine = note.text.split(/\r?\n/u)[0] ?? "";
|
|
301
318
|
return `${note.ts} ${note.actor}: ${truncateInline(firstLine, 120)}`;
|
|
@@ -307,7 +324,7 @@ function formatTree(data: TsqQueryData, label = "tree"): string {
|
|
|
307
324
|
const roots = getTreeRoots(data);
|
|
308
325
|
const flattened = roots.flatMap((root) => flattenTaskTree(root));
|
|
309
326
|
return [
|
|
310
|
-
`
|
|
327
|
+
`Task ${label}: ${formatCount(flattened.length, "task")}${formatLimitNotice(flattened.length)}`,
|
|
311
328
|
...flattened
|
|
312
329
|
.slice(0, MAX_RENDERED_ITEMS)
|
|
313
330
|
.map(
|
|
@@ -321,7 +338,7 @@ function formatSimilar(data: TsqQueryData): string {
|
|
|
321
338
|
const similar = data as Partial<TsqSimilarData>;
|
|
322
339
|
const candidates = similar.candidates ?? [];
|
|
323
340
|
return [
|
|
324
|
-
`
|
|
341
|
+
`Task similar: ${formatCount(candidates.length, "candidate")}${formatLimitNotice(candidates.length)}`,
|
|
325
342
|
...candidates.slice(0, MAX_RENDERED_ITEMS).map(formatCandidateLine),
|
|
326
343
|
].join("\n");
|
|
327
344
|
}
|
|
@@ -342,6 +359,22 @@ function getTreeRoots(data: TsqQueryData): readonly TsqTaskTreeNode[] {
|
|
|
342
359
|
return Array.isArray(tree) ? tree.filter(isTaskTreeNode) : [];
|
|
343
360
|
}
|
|
344
361
|
|
|
362
|
+
function findTaskTreeNode(
|
|
363
|
+
roots: readonly TsqTaskTreeNode[],
|
|
364
|
+
id: string,
|
|
365
|
+
): TsqTaskTreeNode | undefined {
|
|
366
|
+
for (const root of roots) {
|
|
367
|
+
if (root.task.id === id) {
|
|
368
|
+
return root;
|
|
369
|
+
}
|
|
370
|
+
const child = findTaskTreeNode(root.children, id);
|
|
371
|
+
if (child !== undefined) {
|
|
372
|
+
return child;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return undefined;
|
|
376
|
+
}
|
|
377
|
+
|
|
345
378
|
function hasTreeData(data: TsqQueryData): boolean {
|
|
346
379
|
return Array.isArray(data)
|
|
347
380
|
? data.some(isTaskTreeNode)
|
|
@@ -423,7 +456,7 @@ function validateDepth(depth: unknown): number | undefined {
|
|
|
423
456
|
return undefined;
|
|
424
457
|
}
|
|
425
458
|
if (typeof depth !== "number" || !Number.isInteger(depth) || depth < 1) {
|
|
426
|
-
throw new Error("
|
|
459
|
+
throw new Error("task dependency depth must be an integer >= 1");
|
|
427
460
|
}
|
|
428
461
|
return depth;
|
|
429
462
|
}
|
|
@@ -435,7 +468,7 @@ function requireString(
|
|
|
435
468
|
): string {
|
|
436
469
|
const trimmed = value?.trim();
|
|
437
470
|
if (trimmed === undefined || trimmed.length === 0) {
|
|
438
|
-
throw new Error(`
|
|
471
|
+
throw new Error(`task action ${action} requires ${field}`);
|
|
439
472
|
}
|
|
440
473
|
return trimmed;
|
|
441
474
|
}
|
|
@@ -490,7 +523,3 @@ function isTaskTreeNode(value: unknown): value is TsqTaskTreeNode {
|
|
|
490
523
|
isRecord(value) && isTsqTask(value.task) && Array.isArray(value.children)
|
|
491
524
|
);
|
|
492
525
|
}
|
|
493
|
-
|
|
494
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
495
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
496
|
-
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentToolResult,
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { errorToolDetails, textToolResult } from "../shared/tool-result.js";
|
|
7
|
+
import { runQueuedMutation } from "./mutation-queue.js";
|
|
8
|
+
import { runTsqJson } from "./runner.js";
|
|
9
|
+
|
|
10
|
+
export type SpecMode = "show" | "check" | "set" | "update";
|
|
11
|
+
|
|
12
|
+
export const SPEC_READ_MODES: readonly SpecMode[] = ["show", "check"];
|
|
13
|
+
export const SPEC_WRITE_MODES: readonly SpecMode[] = ["set", "update"];
|
|
14
|
+
|
|
15
|
+
export interface SpecParams {
|
|
16
|
+
readonly id: string | undefined;
|
|
17
|
+
readonly mode: SpecMode;
|
|
18
|
+
readonly text?: string | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SpecSuccessDetails {
|
|
22
|
+
readonly ok: true;
|
|
23
|
+
readonly action: "spec";
|
|
24
|
+
readonly mode: SpecMode;
|
|
25
|
+
readonly argv: readonly string[];
|
|
26
|
+
readonly data: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SpecCheckFailedDetails {
|
|
30
|
+
readonly ok: false;
|
|
31
|
+
readonly error: {
|
|
32
|
+
readonly code: "spec_check_failed";
|
|
33
|
+
readonly message: string;
|
|
34
|
+
readonly details: unknown;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type SpecDetails = SpecSuccessDetails | SpecCheckFailedDetails;
|
|
39
|
+
|
|
40
|
+
const DEFAULT_SPEC_TIMEOUT_MS = 10_000;
|
|
41
|
+
|
|
42
|
+
export async function executeTsqSpec(
|
|
43
|
+
pi: ExtensionAPI,
|
|
44
|
+
params: SpecParams,
|
|
45
|
+
signal: AbortSignal | undefined,
|
|
46
|
+
ctx: Pick<ExtensionContext, "cwd">,
|
|
47
|
+
): Promise<AgentToolResult<SpecDetails>> {
|
|
48
|
+
const validated = validateSpecParams(params);
|
|
49
|
+
if (!validated.ok) {
|
|
50
|
+
return textToolResult(
|
|
51
|
+
`Error: ${validated.message}`,
|
|
52
|
+
errorToolDetails({
|
|
53
|
+
code: "validation_error",
|
|
54
|
+
message: validated.message,
|
|
55
|
+
}) as unknown as SpecDetails,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const { argv, mode } = validated;
|
|
60
|
+
const isWrite = (SPEC_WRITE_MODES as readonly string[]).includes(mode);
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const data = isWrite
|
|
64
|
+
? await runQueuedMutation(ctx.cwd, () =>
|
|
65
|
+
runTsqJson(pi, { cwd: ctx.cwd }, argv, {
|
|
66
|
+
timeout: DEFAULT_SPEC_TIMEOUT_MS,
|
|
67
|
+
...(signal === undefined ? {} : { signal }),
|
|
68
|
+
}),
|
|
69
|
+
)
|
|
70
|
+
: await runTsqJson(pi, { cwd: ctx.cwd }, argv, {
|
|
71
|
+
timeout: DEFAULT_SPEC_TIMEOUT_MS,
|
|
72
|
+
...(signal === undefined ? {} : { signal }),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (mode === "check" && isSpecCheckFailed(data)) {
|
|
76
|
+
return buildCheckFailedResult(data, argv);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return textToolResult(formatSpecSuccess(mode, data), {
|
|
80
|
+
ok: true,
|
|
81
|
+
action: "spec",
|
|
82
|
+
mode,
|
|
83
|
+
argv,
|
|
84
|
+
data,
|
|
85
|
+
} as SpecSuccessDetails);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
88
|
+
const code = getErrorCode(error);
|
|
89
|
+
return textToolResult(
|
|
90
|
+
`Error: ${message}`,
|
|
91
|
+
errorToolDetails({
|
|
92
|
+
code,
|
|
93
|
+
message,
|
|
94
|
+
details: { action: "spec", mode, argv },
|
|
95
|
+
}) as unknown as SpecDetails,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type ValidationSuccess = {
|
|
101
|
+
readonly ok: true;
|
|
102
|
+
readonly mode: SpecMode;
|
|
103
|
+
readonly argv: string[];
|
|
104
|
+
};
|
|
105
|
+
type ValidationFailure = { readonly ok: false; readonly message: string };
|
|
106
|
+
type ValidationResult = ValidationSuccess | ValidationFailure;
|
|
107
|
+
|
|
108
|
+
export function validateSpecParams(params: SpecParams): ValidationResult {
|
|
109
|
+
const id = params.id?.trim();
|
|
110
|
+
if (id === undefined || id.length === 0) {
|
|
111
|
+
return { ok: false, message: "spec action requires id" };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const { mode } = params;
|
|
115
|
+
const isRead = (SPEC_READ_MODES as readonly string[]).includes(mode);
|
|
116
|
+
const isWrite = (SPEC_WRITE_MODES as readonly string[]).includes(mode);
|
|
117
|
+
|
|
118
|
+
if (!isRead && !isWrite) {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
message: `spec mode must be show, check, set, or update`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (isRead && params.text !== undefined) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
message: `spec ${mode} does not accept text`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (isWrite) {
|
|
133
|
+
const text = params.text?.trim();
|
|
134
|
+
if (text === undefined || text.length === 0) {
|
|
135
|
+
return { ok: false, message: `spec ${mode} requires text` };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { ok: true, mode, argv: buildSpecArgv(id, mode, params.text) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function buildSpecArgv(
|
|
143
|
+
id: string,
|
|
144
|
+
mode: SpecMode,
|
|
145
|
+
text: string | undefined,
|
|
146
|
+
): string[] {
|
|
147
|
+
switch (mode) {
|
|
148
|
+
case "show":
|
|
149
|
+
return ["spec", id, "--show"];
|
|
150
|
+
case "check":
|
|
151
|
+
return ["spec", id, "--check"];
|
|
152
|
+
case "set":
|
|
153
|
+
return ["spec", id, "--force", `--text=${text!}`];
|
|
154
|
+
case "update":
|
|
155
|
+
return ["spec", id, "--update", `--text=${text!}`];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isSpecCheckFailed(data: unknown): boolean {
|
|
160
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
return (data as Record<string, unknown>).ok === false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function buildCheckFailedResult(
|
|
167
|
+
data: unknown,
|
|
168
|
+
argv: readonly string[],
|
|
169
|
+
): AgentToolResult<SpecDetails> {
|
|
170
|
+
const record = data as Record<string, unknown>;
|
|
171
|
+
const diagnostics = record.diagnostics ?? record.issues ?? record;
|
|
172
|
+
const message =
|
|
173
|
+
typeof record.message === "string" ? record.message : "spec check failed";
|
|
174
|
+
|
|
175
|
+
return textToolResult(`Spec check failed: ${message}`, {
|
|
176
|
+
ok: false,
|
|
177
|
+
error: {
|
|
178
|
+
code: "spec_check_failed",
|
|
179
|
+
message,
|
|
180
|
+
details: { argv, diagnostics },
|
|
181
|
+
},
|
|
182
|
+
} as SpecCheckFailedDetails);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function formatSpecSuccess(mode: SpecMode, data: unknown): string {
|
|
186
|
+
switch (mode) {
|
|
187
|
+
case "show": {
|
|
188
|
+
const record = data as Record<string, unknown> | null;
|
|
189
|
+
const spec =
|
|
190
|
+
typeof record?.spec === "object" &&
|
|
191
|
+
record.spec !== null &&
|
|
192
|
+
!Array.isArray(record.spec)
|
|
193
|
+
? (record.spec as Record<string, unknown>)
|
|
194
|
+
: undefined;
|
|
195
|
+
const content =
|
|
196
|
+
typeof spec?.content === "string"
|
|
197
|
+
? spec.content
|
|
198
|
+
: typeof record?.content === "string"
|
|
199
|
+
? record.content
|
|
200
|
+
: undefined;
|
|
201
|
+
const path =
|
|
202
|
+
typeof spec?.path === "string"
|
|
203
|
+
? spec.path
|
|
204
|
+
: typeof record?.path === "string"
|
|
205
|
+
? record.path
|
|
206
|
+
: undefined;
|
|
207
|
+
if (content !== undefined) {
|
|
208
|
+
const header = path !== undefined ? `Spec (${path}):\n` : "Spec:\n";
|
|
209
|
+
return `${header}${content}`;
|
|
210
|
+
}
|
|
211
|
+
return "Spec: no content returned";
|
|
212
|
+
}
|
|
213
|
+
case "check":
|
|
214
|
+
return "Spec check passed";
|
|
215
|
+
case "set":
|
|
216
|
+
return "Spec attached";
|
|
217
|
+
case "update":
|
|
218
|
+
return "Spec updated";
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function getErrorCode(error: unknown): string {
|
|
223
|
+
if (typeof error === "object" && error !== null && !Array.isArray(error)) {
|
|
224
|
+
const code = (error as Record<string, unknown>).code;
|
|
225
|
+
if (typeof code === "string") {
|
|
226
|
+
return code;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return "tsq_error";
|
|
230
|
+
}
|