@mrclrchtr/supi-flow 0.10.1 → 0.11.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/README.md +227 -125
- package/extensions/index.ts +61 -10
- package/extensions/tools/flow-tools.ts +554 -165
- package/extensions/tools/tndm-cli.ts +249 -4
- package/package.json +5 -5
- package/skills/supi-flow-apply/SKILL.md +8 -6
- package/skills/supi-flow-archive/SKILL.md +2 -2
- package/skills/supi-flow-brainstorm/SKILL.md +3 -3
- package/skills/supi-flow-plan/SKILL.md +17 -14
|
@@ -1,9 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
3
|
import { type Static, Type } from "typebox";
|
|
4
4
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
5
5
|
import { tndm, tndmJson } from "../cli.js";
|
|
6
6
|
|
|
7
|
+
type FlowTaskListEntry = {
|
|
8
|
+
number?: number;
|
|
9
|
+
title?: string;
|
|
10
|
+
status?: string;
|
|
11
|
+
files?: string[];
|
|
12
|
+
verification?: string;
|
|
13
|
+
notes?: string;
|
|
14
|
+
detail_path?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
7
17
|
// ─── supi_flow_start ───────────────────────────────────────────
|
|
8
18
|
|
|
9
19
|
export const supiFlowStartParams = Type.Object({
|
|
@@ -77,62 +87,31 @@ export const supiFlowPlanParams = Type.Object({
|
|
|
77
87
|
ticket_id: Type.String({ description: "Ticket ID (e.g. TNDM-A1B2C3)" }),
|
|
78
88
|
plan_content: Type.String({
|
|
79
89
|
description:
|
|
80
|
-
"
|
|
90
|
+
"Approved overview / plan markdown to store in the ticket's canonical content.md. This may contain zero tasks; task authoring happens separately in state.toml.",
|
|
81
91
|
}),
|
|
82
|
-
append: Type.Optional(
|
|
83
|
-
Type.Boolean({
|
|
84
|
-
description:
|
|
85
|
-
"If true, append to existing content. If false (default), replace content entirely.",
|
|
86
|
-
}),
|
|
87
|
-
),
|
|
88
92
|
});
|
|
89
93
|
|
|
90
94
|
export type FlowPlanParams = Static<typeof supiFlowPlanParams>;
|
|
91
95
|
|
|
92
96
|
export async function executeFlowPlan(params: FlowPlanParams) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
"ticket",
|
|
96
|
-
"doc",
|
|
97
|
-
"create",
|
|
98
|
-
params.ticket_id,
|
|
99
|
-
"plan",
|
|
100
|
-
]);
|
|
101
|
-
const docPath = docResult.path;
|
|
102
|
-
|
|
103
|
-
let content = params.plan_content;
|
|
104
|
-
|
|
105
|
-
if (params.append) {
|
|
106
|
-
try {
|
|
107
|
-
const existingContent = readFileSync(docPath, "utf-8");
|
|
108
|
-
if (existingContent) {
|
|
109
|
-
content = existingContent + "\n\n" + content;
|
|
110
|
-
}
|
|
111
|
-
} catch {
|
|
112
|
-
// If reading fails, just use the new content
|
|
113
|
-
}
|
|
97
|
+
if (!params.plan_content.trim()) {
|
|
98
|
+
throw new Error("supi_flow_plan: plan_content must not be blank");
|
|
114
99
|
}
|
|
115
100
|
|
|
116
|
-
// Write the plan content to the document file
|
|
117
|
-
writeFileSync(docPath, content, "utf-8");
|
|
118
|
-
|
|
119
|
-
// Sync fingerprints and update tags
|
|
120
|
-
await tndm(["ticket", "sync", params.ticket_id]);
|
|
121
|
-
|
|
122
|
-
// Replace any flow-state tag with flow:planned — remove all possible flow-state tags
|
|
123
|
-
// first, then add flow:planned, to work correctly regardless of the ticket's current
|
|
124
|
-
// flow state (brainstorm, planned, applying, or done).
|
|
125
101
|
await tndm([
|
|
126
102
|
"ticket",
|
|
127
103
|
"update",
|
|
128
104
|
params.ticket_id,
|
|
129
|
-
"--
|
|
130
|
-
|
|
105
|
+
"--content",
|
|
106
|
+
params.plan_content,
|
|
131
107
|
]);
|
|
108
|
+
|
|
132
109
|
await tndm([
|
|
133
110
|
"ticket",
|
|
134
111
|
"update",
|
|
135
112
|
params.ticket_id,
|
|
113
|
+
"--remove-tags",
|
|
114
|
+
"flow:brainstorm,flow:planned,flow:applying,flow:done",
|
|
136
115
|
"--add-tags",
|
|
137
116
|
"flow:planned",
|
|
138
117
|
]);
|
|
@@ -141,157 +120,396 @@ export async function executeFlowPlan(params: FlowPlanParams) {
|
|
|
141
120
|
content: [
|
|
142
121
|
{
|
|
143
122
|
type: "text" as const,
|
|
144
|
-
text: `
|
|
123
|
+
text: `Overview stored in content.md for ticket ${params.ticket_id}. Tags updated to flow:planned.`,
|
|
145
124
|
},
|
|
146
125
|
],
|
|
147
|
-
details: {
|
|
126
|
+
details: {
|
|
127
|
+
action: "flow_plan",
|
|
128
|
+
ticketId: params.ticket_id,
|
|
129
|
+
tags: "flow:planned",
|
|
130
|
+
contentStored: true,
|
|
131
|
+
},
|
|
148
132
|
};
|
|
149
133
|
}
|
|
150
134
|
|
|
151
|
-
// ───
|
|
135
|
+
// ─── supi_flow_apply ───────────────────────────────────────────
|
|
152
136
|
|
|
153
|
-
export const
|
|
137
|
+
export const supiFlowApplyParams = Type.Object({
|
|
154
138
|
ticket_id: Type.String({ description: "Ticket ID (e.g. TNDM-A1B2C3)" }),
|
|
155
|
-
task_number: Type.Number({
|
|
156
|
-
description: "1-based task number to mark as complete (e.g. 1, 2, 3)",
|
|
157
|
-
}),
|
|
158
139
|
});
|
|
159
140
|
|
|
160
|
-
export type
|
|
141
|
+
export type FlowApplyParams = Static<typeof supiFlowApplyParams>;
|
|
161
142
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
143
|
+
export async function executeFlowApply(params: FlowApplyParams) {
|
|
144
|
+
const ticket = await loadTicket(params.ticket_id);
|
|
145
|
+
const status = extractTicketStatus(ticket);
|
|
146
|
+
const tags = extractTicketTags(ticket);
|
|
166
147
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
for (let i = 0; i < lines.length; i++) {
|
|
171
|
-
const line = lines[i];
|
|
172
|
-
const trimmed = line.trimStart();
|
|
148
|
+
if (status === "done" || tags.includes("flow:done")) {
|
|
149
|
+
throw new Error(`supi_flow_apply: ticket ${params.ticket_id} is already closed`);
|
|
150
|
+
}
|
|
173
151
|
|
|
174
|
-
|
|
175
|
-
|
|
152
|
+
if (!tags.includes("flow:planned") && !tags.includes("flow:applying")) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`supi_flow_apply: ticket ${params.ticket_id} must be in flow:planned or flow:applying`,
|
|
176
155
|
);
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
new RegExp(`^- \\[x\\] \\*\\*Task ${taskNumber}\\*\\*:`),
|
|
156
|
+
}
|
|
157
|
+
if (
|
|
158
|
+
tags.includes("flow:applying") &&
|
|
159
|
+
status !== "in_progress" &&
|
|
160
|
+
status !== "blocked"
|
|
161
|
+
) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`supi_flow_apply: ticket ${params.ticket_id} must have status in_progress or blocked when tagged flow:applying`,
|
|
186
164
|
);
|
|
187
|
-
if (checkedMatch) {
|
|
188
|
-
return { kind: "already_checked" };
|
|
189
|
-
}
|
|
190
165
|
}
|
|
191
|
-
return { kind: "not_found" };
|
|
192
|
-
}
|
|
193
166
|
|
|
194
|
-
|
|
195
|
-
const showResult = await tndmJson<{
|
|
196
|
-
id: string;
|
|
197
|
-
content_path?: string;
|
|
198
|
-
documents?: Array<{ name: string; path: string }>;
|
|
199
|
-
}>([
|
|
200
|
-
"ticket",
|
|
201
|
-
"show",
|
|
167
|
+
const overview = readRequiredTicketContent(
|
|
202
168
|
params.ticket_id,
|
|
203
|
-
|
|
169
|
+
extractContentPath(ticket),
|
|
170
|
+
"supi_flow_apply",
|
|
171
|
+
);
|
|
172
|
+
const tasks = await loadTaskList(params.ticket_id);
|
|
204
173
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
return {
|
|
208
|
-
content: [
|
|
209
|
-
{
|
|
210
|
-
type: "text" as const,
|
|
211
|
-
text: `No content path found in ticket ${params.ticket_id}.`,
|
|
212
|
-
},
|
|
213
|
-
],
|
|
214
|
-
details: { action: "flow_complete_task", ticketId: params.ticket_id, error: "No content path" },
|
|
215
|
-
};
|
|
174
|
+
if (tasks.length === 0) {
|
|
175
|
+
throw new Error(`supi_flow_apply: ticket ${params.ticket_id} has no structured tasks`);
|
|
216
176
|
}
|
|
217
177
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
178
|
+
let transitioned = false;
|
|
179
|
+
let applyStatus = status ?? "in_progress";
|
|
180
|
+
|
|
181
|
+
if (tags.includes("flow:planned")) {
|
|
182
|
+
await tndm([
|
|
183
|
+
"ticket",
|
|
184
|
+
"update",
|
|
185
|
+
params.ticket_id,
|
|
186
|
+
"--status",
|
|
187
|
+
"in_progress",
|
|
188
|
+
"--remove-tags",
|
|
189
|
+
"flow:planned",
|
|
190
|
+
"--add-tags",
|
|
191
|
+
"flow:applying",
|
|
192
|
+
]);
|
|
193
|
+
transitioned = true;
|
|
194
|
+
applyStatus = "in_progress";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const contentPath = extractContentPath(ticket) ?? "";
|
|
198
|
+
const taskCount = tasks.length;
|
|
199
|
+
const transitionText = transitioned
|
|
200
|
+
? `Ticket ${params.ticket_id} moved to flow:applying.`
|
|
201
|
+
: applyStatus === "blocked"
|
|
202
|
+
? `Ticket ${params.ticket_id} is already in flow:applying and currently blocked.`
|
|
203
|
+
: `Ticket ${params.ticket_id} is already in flow:applying.`;
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
content: [
|
|
207
|
+
{
|
|
208
|
+
type: "text" as const,
|
|
209
|
+
text: `${transitionText} Loaded approved overview and ${taskCount} task${taskCount === 1 ? "" : "s"}.`,
|
|
231
210
|
},
|
|
232
|
-
|
|
211
|
+
],
|
|
212
|
+
details: {
|
|
213
|
+
action: "flow_apply",
|
|
214
|
+
ticketId: params.ticket_id,
|
|
215
|
+
transitioned,
|
|
216
|
+
status: applyStatus,
|
|
217
|
+
tags: "flow:applying",
|
|
218
|
+
contentPath,
|
|
219
|
+
overview,
|
|
220
|
+
tasks,
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ─── supi_flow_task ────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
export const supiFlowTaskParams = Type.Object({
|
|
228
|
+
ticket_id: Type.String({ description: "Ticket ID (e.g. TNDM-A1B2C3)" }),
|
|
229
|
+
operation: StringEnum(["add", "edit", "remove"] as const, {
|
|
230
|
+
description: "Single-task mutation to apply",
|
|
231
|
+
}),
|
|
232
|
+
task_number: Type.Optional(
|
|
233
|
+
Type.Number({ description: "Task number for edit/remove operations" }),
|
|
234
|
+
),
|
|
235
|
+
title: Type.Optional(
|
|
236
|
+
Type.String({ description: "Task title (required for add)" }),
|
|
237
|
+
),
|
|
238
|
+
files: Type.Optional(
|
|
239
|
+
Type.Array(Type.String(), { description: "File paths for the task" }),
|
|
240
|
+
),
|
|
241
|
+
clear_files: Type.Optional(
|
|
242
|
+
Type.Boolean({ description: "Clear all file paths during edit" }),
|
|
243
|
+
),
|
|
244
|
+
verification: Type.Optional(
|
|
245
|
+
Type.String({ description: "Verification command for the task" }),
|
|
246
|
+
),
|
|
247
|
+
clear_verification: Type.Optional(
|
|
248
|
+
Type.Boolean({ description: "Clear the verification command during edit" }),
|
|
249
|
+
),
|
|
250
|
+
notes: Type.Optional(
|
|
251
|
+
Type.String({ description: "Extra notes for the task" }),
|
|
252
|
+
),
|
|
253
|
+
clear_notes: Type.Optional(
|
|
254
|
+
Type.Boolean({ description: "Clear task notes during edit" }),
|
|
255
|
+
),
|
|
256
|
+
detail: Type.Optional(
|
|
257
|
+
Type.String({ description: "Optional markdown body for the canonical task detail doc" }),
|
|
258
|
+
),
|
|
259
|
+
clear_detail: Type.Optional(
|
|
260
|
+
Type.Boolean({ description: "Clear the linked canonical task detail doc reference during edit" }),
|
|
261
|
+
),
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
export type FlowTaskParams = Static<typeof supiFlowTaskParams>;
|
|
265
|
+
|
|
266
|
+
export async function executeFlowTask(params: FlowTaskParams) {
|
|
267
|
+
if (params.files !== undefined && params.clear_files) {
|
|
268
|
+
throw new Error("supi_flow_task: files and clear_files cannot be used together");
|
|
269
|
+
}
|
|
270
|
+
if (params.verification !== undefined && params.clear_verification) {
|
|
271
|
+
throw new Error("supi_flow_task: verification and clear_verification cannot be used together");
|
|
233
272
|
}
|
|
273
|
+
if (params.notes !== undefined && params.clear_notes) {
|
|
274
|
+
throw new Error("supi_flow_task: notes and clear_notes cannot be used together");
|
|
275
|
+
}
|
|
276
|
+
if (params.detail !== undefined && params.clear_detail) {
|
|
277
|
+
throw new Error("supi_flow_task: detail and clear_detail cannot be used together");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
switch (params.operation) {
|
|
281
|
+
case "add": {
|
|
282
|
+
if (params.task_number !== undefined) {
|
|
283
|
+
throw new Error("supi_flow_task: task_number is not used for add");
|
|
284
|
+
}
|
|
285
|
+
if (!params.title || !params.title.trim()) {
|
|
286
|
+
throw new Error("supi_flow_task: title is required for add");
|
|
287
|
+
}
|
|
234
288
|
|
|
235
|
-
|
|
289
|
+
const args: string[] = [
|
|
290
|
+
"ticket",
|
|
291
|
+
"task",
|
|
292
|
+
"add",
|
|
293
|
+
params.ticket_id,
|
|
294
|
+
"--title",
|
|
295
|
+
params.title,
|
|
296
|
+
];
|
|
297
|
+
for (const file of params.files ?? []) {
|
|
298
|
+
args.push("--file", file);
|
|
299
|
+
}
|
|
300
|
+
if (params.verification && params.verification.trim()) {
|
|
301
|
+
args.push("--verification", params.verification);
|
|
302
|
+
}
|
|
303
|
+
if (params.notes && params.notes.trim()) {
|
|
304
|
+
args.push("--notes", params.notes);
|
|
305
|
+
}
|
|
236
306
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
307
|
+
const result = await tndmJson<Record<string, unknown>>(args);
|
|
308
|
+
const taskNumber = extractLatestTaskNumber(result);
|
|
309
|
+
let finalResult = result;
|
|
310
|
+
|
|
311
|
+
if (params.detail !== undefined) {
|
|
312
|
+
const detailResult = await ensureTaskDetailDoc(params.ticket_id, taskNumber);
|
|
313
|
+
writeTaskDetailDoc(detailResult.path, taskNumber, params.title, params.detail);
|
|
314
|
+
await tndm(["ticket", "sync", params.ticket_id]);
|
|
315
|
+
finalResult = await loadTicket(params.ticket_id);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
content: [
|
|
320
|
+
{
|
|
321
|
+
type: "text" as const,
|
|
322
|
+
text: `Task ${taskNumber} added to ${params.ticket_id}.`,
|
|
323
|
+
},
|
|
324
|
+
],
|
|
325
|
+
details: {
|
|
326
|
+
action: "flow_task",
|
|
327
|
+
operation: "add",
|
|
328
|
+
ticketId: params.ticket_id,
|
|
329
|
+
taskNumber,
|
|
330
|
+
result: finalResult,
|
|
246
331
|
},
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
case "edit": {
|
|
336
|
+
if (params.task_number === undefined) {
|
|
337
|
+
throw new Error("supi_flow_task: task_number is required for edit");
|
|
338
|
+
}
|
|
339
|
+
if (params.title !== undefined && !params.title.trim()) {
|
|
340
|
+
throw new Error("supi_flow_task: title must not be blank when provided");
|
|
341
|
+
}
|
|
342
|
+
const hasRequestedChange =
|
|
343
|
+
params.title !== undefined ||
|
|
344
|
+
params.files !== undefined ||
|
|
345
|
+
Boolean(params.clear_files) ||
|
|
346
|
+
params.verification !== undefined ||
|
|
347
|
+
Boolean(params.clear_verification) ||
|
|
348
|
+
params.notes !== undefined ||
|
|
349
|
+
Boolean(params.clear_notes) ||
|
|
350
|
+
params.detail !== undefined ||
|
|
351
|
+
Boolean(params.clear_detail);
|
|
352
|
+
if (!hasRequestedChange) {
|
|
353
|
+
throw new Error("supi_flow_task: edit requires at least one field change");
|
|
354
|
+
}
|
|
251
355
|
|
|
252
|
-
|
|
356
|
+
const args: string[] = [
|
|
357
|
+
"ticket",
|
|
358
|
+
"task",
|
|
359
|
+
"edit",
|
|
360
|
+
params.ticket_id,
|
|
361
|
+
String(params.task_number),
|
|
362
|
+
];
|
|
363
|
+
if (params.title !== undefined) args.push("--title", params.title);
|
|
364
|
+
if (params.files !== undefined) {
|
|
365
|
+
if (params.files.length === 0) {
|
|
366
|
+
args.push("--clear-files");
|
|
367
|
+
} else {
|
|
368
|
+
for (const file of params.files) args.push("--file", file);
|
|
369
|
+
}
|
|
370
|
+
} else if (params.clear_files) {
|
|
371
|
+
args.push("--clear-files");
|
|
372
|
+
}
|
|
373
|
+
if (params.verification !== undefined) {
|
|
374
|
+
args.push("--verification", params.verification);
|
|
375
|
+
} else if (params.clear_verification) {
|
|
376
|
+
args.push("--verification", "");
|
|
377
|
+
}
|
|
378
|
+
if (params.notes !== undefined) {
|
|
379
|
+
args.push("--notes", params.notes);
|
|
380
|
+
} else if (params.clear_notes) {
|
|
381
|
+
args.push("--notes", "");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const hasManifestFieldChanges = args.length > 5;
|
|
385
|
+
const result = hasManifestFieldChanges
|
|
386
|
+
? await tndmJson<Record<string, unknown>>(args)
|
|
387
|
+
: undefined;
|
|
388
|
+
let finalResult = result;
|
|
389
|
+
|
|
390
|
+
if (params.detail !== undefined) {
|
|
391
|
+
const detailResult = await ensureTaskDetailDoc(params.ticket_id, params.task_number);
|
|
392
|
+
const taskSnapshot = result ?? await loadTicket(params.ticket_id);
|
|
393
|
+
const taskTitle =
|
|
394
|
+
params.title ??
|
|
395
|
+
extractTaskTitle(taskSnapshot, params.task_number) ??
|
|
396
|
+
`Task ${params.task_number}`;
|
|
397
|
+
writeTaskDetailDoc(
|
|
398
|
+
detailResult.path,
|
|
399
|
+
params.task_number,
|
|
400
|
+
taskTitle,
|
|
401
|
+
params.detail,
|
|
402
|
+
);
|
|
403
|
+
await tndm(["ticket", "sync", params.ticket_id]);
|
|
404
|
+
finalResult = await loadTicket(params.ticket_id);
|
|
405
|
+
} else if (params.clear_detail) {
|
|
406
|
+
await tndmJson([
|
|
407
|
+
"ticket",
|
|
408
|
+
"task",
|
|
409
|
+
"detail",
|
|
410
|
+
"clear",
|
|
411
|
+
params.ticket_id,
|
|
412
|
+
String(params.task_number),
|
|
413
|
+
]);
|
|
414
|
+
finalResult = await loadTicket(params.ticket_id);
|
|
415
|
+
}
|
|
253
416
|
|
|
254
|
-
switch (result.kind) {
|
|
255
|
-
case "unchecked":
|
|
256
|
-
writeFileSync(planPath, result.updatedContent, "utf-8");
|
|
257
|
-
await tndm(["ticket", "sync", params.ticket_id]);
|
|
258
417
|
return {
|
|
259
418
|
content: [
|
|
260
419
|
{
|
|
261
420
|
type: "text" as const,
|
|
262
|
-
text: `Task ${params.task_number}
|
|
421
|
+
text: `Task ${params.task_number} updated in ${params.ticket_id}.`,
|
|
263
422
|
},
|
|
264
423
|
],
|
|
265
424
|
details: {
|
|
266
|
-
action: "
|
|
425
|
+
action: "flow_task",
|
|
426
|
+
operation: "edit",
|
|
267
427
|
ticketId: params.ticket_id,
|
|
268
428
|
taskNumber: params.task_number,
|
|
269
|
-
|
|
429
|
+
result: finalResult,
|
|
270
430
|
},
|
|
271
431
|
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
case "remove": {
|
|
435
|
+
if (params.task_number === undefined) {
|
|
436
|
+
throw new Error("supi_flow_task: task_number is required for remove");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const result = await tndmJson<Record<string, unknown>>([
|
|
440
|
+
"ticket",
|
|
441
|
+
"task",
|
|
442
|
+
"remove",
|
|
443
|
+
params.ticket_id,
|
|
444
|
+
String(params.task_number),
|
|
445
|
+
]);
|
|
272
446
|
|
|
273
|
-
case "already_checked":
|
|
274
447
|
return {
|
|
275
448
|
content: [
|
|
276
449
|
{
|
|
277
450
|
type: "text" as const,
|
|
278
|
-
text: `Task ${params.task_number}
|
|
451
|
+
text: `Task ${params.task_number} removed from ${params.ticket_id}.`,
|
|
279
452
|
},
|
|
280
453
|
],
|
|
281
454
|
details: {
|
|
282
|
-
action: "
|
|
455
|
+
action: "flow_task",
|
|
456
|
+
operation: "remove",
|
|
283
457
|
ticketId: params.ticket_id,
|
|
284
458
|
taskNumber: params.task_number,
|
|
285
|
-
|
|
286
|
-
|
|
459
|
+
removed: true,
|
|
460
|
+
result,
|
|
287
461
|
},
|
|
288
462
|
};
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ─── supi_flow_complete_task ───────────────────────────────────
|
|
468
|
+
|
|
469
|
+
export const supiFlowCompleteTaskParams = Type.Object({
|
|
470
|
+
ticket_id: Type.String({ description: "Ticket ID (e.g. TNDM-A1B2C3)" }),
|
|
471
|
+
task_number: Type.Number({
|
|
472
|
+
description: "1-based task number to mark as complete (e.g. 1, 2, 3)",
|
|
473
|
+
}),
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
export type FlowCompleteTaskParams = Static<typeof supiFlowCompleteTaskParams>;
|
|
477
|
+
|
|
478
|
+
export async function executeFlowCompleteTask(params: FlowCompleteTaskParams) {
|
|
479
|
+
try {
|
|
480
|
+
const result = await tndmJson<Record<string, unknown>>([
|
|
481
|
+
"ticket",
|
|
482
|
+
"task",
|
|
483
|
+
"complete",
|
|
484
|
+
params.ticket_id,
|
|
485
|
+
String(params.task_number),
|
|
486
|
+
]);
|
|
289
487
|
|
|
290
|
-
|
|
488
|
+
return {
|
|
489
|
+
content: [
|
|
490
|
+
{
|
|
491
|
+
type: "text" as const,
|
|
492
|
+
text: `Task ${params.task_number} completed in ${params.ticket_id}.`,
|
|
493
|
+
},
|
|
494
|
+
],
|
|
495
|
+
details: {
|
|
496
|
+
action: "flow_complete_task",
|
|
497
|
+
ticketId: params.ticket_id,
|
|
498
|
+
taskNumber: params.task_number,
|
|
499
|
+
completed: true,
|
|
500
|
+
result,
|
|
501
|
+
},
|
|
502
|
+
};
|
|
503
|
+
} catch (error) {
|
|
504
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
505
|
+
// Task already done returns a regular success from the CLI; only hard failure
|
|
506
|
+
// happens when the task doesn't exist
|
|
507
|
+
if (message.includes("not found")) {
|
|
291
508
|
throw new Error(
|
|
292
|
-
`Task ${params.task_number} not found in ticket ${params.ticket_id}
|
|
293
|
-
` Task must exist as '- [ ] **Task N:**' or '- [x] **Task N:**'.`,
|
|
509
|
+
`Task ${params.task_number} not found in ticket ${params.ticket_id}.`,
|
|
294
510
|
);
|
|
511
|
+
}
|
|
512
|
+
throw error;
|
|
295
513
|
}
|
|
296
514
|
}
|
|
297
515
|
|
|
@@ -299,47 +517,73 @@ export async function executeFlowCompleteTask(params: FlowCompleteTaskParams) {
|
|
|
299
517
|
|
|
300
518
|
export const supiFlowCloseParams = Type.Object({
|
|
301
519
|
ticket_id: Type.String({ description: "Ticket ID (e.g. TNDM-A1B2C3)" }),
|
|
302
|
-
verification_results: Type.
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}),
|
|
307
|
-
),
|
|
520
|
+
verification_results: Type.String({
|
|
521
|
+
description:
|
|
522
|
+
"Verification results / evidence from the agent to write into archive.md before closing the ticket.",
|
|
523
|
+
}),
|
|
308
524
|
});
|
|
309
525
|
|
|
310
526
|
export type FlowCloseParams = Static<typeof supiFlowCloseParams>;
|
|
311
527
|
|
|
312
528
|
export async function executeFlowClose(params: FlowCloseParams) {
|
|
313
|
-
|
|
529
|
+
const verificationResults = params.verification_results?.trim() ?? "";
|
|
530
|
+
if (!verificationResults) {
|
|
531
|
+
throw new Error("supi_flow_close: verification_results is required");
|
|
532
|
+
}
|
|
314
533
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
534
|
+
const ticket = await loadTicket(params.ticket_id);
|
|
535
|
+
const status = extractTicketStatus(ticket);
|
|
536
|
+
const tags = extractTicketTags(ticket);
|
|
537
|
+
|
|
538
|
+
if (status === "done" || tags.includes("flow:done")) {
|
|
539
|
+
throw new Error(`supi_flow_close: ticket ${params.ticket_id} is already closed`);
|
|
540
|
+
}
|
|
541
|
+
if (!tags.includes("flow:applying")) {
|
|
542
|
+
throw new Error(
|
|
543
|
+
`supi_flow_close: ticket ${params.ticket_id} must be in flow:applying before close`,
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
if (status !== "in_progress" && status !== "blocked") {
|
|
547
|
+
throw new Error(
|
|
548
|
+
`supi_flow_close: ticket ${params.ticket_id} must have status in_progress or blocked before close`,
|
|
549
|
+
);
|
|
327
550
|
}
|
|
328
551
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
552
|
+
const tasks = await loadTaskList(params.ticket_id);
|
|
553
|
+
if (tasks.length === 0) {
|
|
554
|
+
throw new Error(`supi_flow_close: ticket ${params.ticket_id} has no structured tasks`);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const incompleteTasks = tasks.filter((task) => task.status !== "done");
|
|
558
|
+
if (incompleteTasks.length > 0) {
|
|
559
|
+
const taskList = incompleteTasks
|
|
560
|
+
.map((task) => `#${task.number ?? "?"}${task.title ? ` ${task.title}` : ""}`)
|
|
561
|
+
.join(", ");
|
|
562
|
+
throw new Error(
|
|
563
|
+
`supi_flow_close: ticket ${params.ticket_id} has incomplete tasks: ${taskList}`,
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Create/register archive.md via document registry, then write results
|
|
568
|
+
const docResult = await tndmJson<{ path: string }>([
|
|
333
569
|
"ticket",
|
|
334
|
-
"
|
|
570
|
+
"doc",
|
|
571
|
+
"create",
|
|
335
572
|
params.ticket_id,
|
|
336
|
-
"
|
|
337
|
-
"flow:brainstorm,flow:planned,flow:applying,flow:done",
|
|
573
|
+
"archive",
|
|
338
574
|
]);
|
|
575
|
+
writeFileSync(docResult.path, `# Archive\n\n${verificationResults}\n`, "utf-8");
|
|
576
|
+
await tndm(["ticket", "sync", params.ticket_id]);
|
|
577
|
+
|
|
578
|
+
// Replace any flow-state tag with flow:done — remove all possible flow-state tags,
|
|
579
|
+
// set status, and add flow:done in one atomic call, to work correctly regardless of
|
|
580
|
+
// the ticket's current flow state.
|
|
339
581
|
await tndm([
|
|
340
582
|
"ticket",
|
|
341
583
|
"update",
|
|
342
584
|
params.ticket_id,
|
|
585
|
+
"--remove-tags",
|
|
586
|
+
"flow:brainstorm,flow:planned,flow:applying,flow:done",
|
|
343
587
|
"--status",
|
|
344
588
|
"done",
|
|
345
589
|
"--add-tags",
|
|
@@ -361,3 +605,148 @@ export async function executeFlowClose(params: FlowCloseParams) {
|
|
|
361
605
|
},
|
|
362
606
|
};
|
|
363
607
|
}
|
|
608
|
+
|
|
609
|
+
function extractLatestTaskNumber(result: Record<string, unknown>): number {
|
|
610
|
+
const tasks = extractTasks(result);
|
|
611
|
+
const numbers = tasks
|
|
612
|
+
.map((task) => task.number)
|
|
613
|
+
.filter((value): value is number => typeof value === "number");
|
|
614
|
+
|
|
615
|
+
if (numbers.length === 0) {
|
|
616
|
+
throw new Error("supi_flow_task: task_add did not return a task list");
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return Math.max(...numbers);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function extractTaskTitle(result: Record<string, unknown>, taskNumber: number): string | undefined {
|
|
623
|
+
return extractTasks(result).find((task) => task.number === taskNumber)?.title;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function extractTasks(result: Record<string, unknown>): FlowTaskListEntry[] {
|
|
627
|
+
const ticket = unwrapTicket(result);
|
|
628
|
+
|
|
629
|
+
if (Array.isArray(ticket.tasks)) {
|
|
630
|
+
return filterFlowTasks(ticket.tasks);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const state = ticket.state;
|
|
634
|
+
if (
|
|
635
|
+
typeof state === "object" &&
|
|
636
|
+
state !== null &&
|
|
637
|
+
Array.isArray((state as { tasks?: unknown }).tasks)
|
|
638
|
+
) {
|
|
639
|
+
return filterFlowTasks((state as { tasks: unknown[] }).tasks);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
return [];
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function filterFlowTasks(tasks: unknown[]): FlowTaskListEntry[] {
|
|
646
|
+
return tasks.filter(
|
|
647
|
+
(task): task is FlowTaskListEntry => typeof task === "object" && task !== null,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function unwrapTicket(result: Record<string, unknown>): Record<string, unknown> {
|
|
652
|
+
const ticket = result.ticket;
|
|
653
|
+
if (typeof ticket === "object" && ticket !== null) {
|
|
654
|
+
return ticket as Record<string, unknown>;
|
|
655
|
+
}
|
|
656
|
+
return result;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function extractTicketTags(result: Record<string, unknown>): string[] {
|
|
660
|
+
const ticket = unwrapTicket(result);
|
|
661
|
+
if (!Array.isArray(ticket.tags)) {
|
|
662
|
+
return [];
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return ticket.tags.filter((tag): tag is string => typeof tag === "string");
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function extractTicketStatus(result: Record<string, unknown>): string | undefined {
|
|
669
|
+
const ticket = unwrapTicket(result);
|
|
670
|
+
return typeof ticket.status === "string" ? ticket.status : undefined;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function extractContentPath(result: Record<string, unknown>): string | undefined {
|
|
674
|
+
const ticket = unwrapTicket(result);
|
|
675
|
+
return typeof ticket.content_path === "string" ? ticket.content_path : undefined;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function readRequiredTicketContent(
|
|
679
|
+
ticketId: string,
|
|
680
|
+
contentPath: string | undefined,
|
|
681
|
+
toolName: string,
|
|
682
|
+
): string {
|
|
683
|
+
if (!contentPath) {
|
|
684
|
+
throw new Error(`${toolName}: ticket ${ticketId} is missing content_path`);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
let overview: string;
|
|
688
|
+
try {
|
|
689
|
+
overview = readFileSync(resolveTicketPath(contentPath), "utf-8");
|
|
690
|
+
} catch (error) {
|
|
691
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
692
|
+
throw new Error(`${toolName}: failed to read content.md for ticket ${ticketId}: ${message}`);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (!overview.trim()) {
|
|
696
|
+
throw new Error(`${toolName}: approved overview in content.md must not be blank`);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
return overview;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function resolveTicketPath(ticketPath: string): string {
|
|
703
|
+
if (isAbsolute(ticketPath)) {
|
|
704
|
+
return ticketPath;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
return resolve(findRepoRoot(), ticketPath);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function findRepoRoot(startDir = process.cwd()): string {
|
|
711
|
+
let current = resolve(startDir);
|
|
712
|
+
|
|
713
|
+
while (true) {
|
|
714
|
+
if (existsSync(join(current, ".git")) || existsSync(join(current, ".tndm"))) {
|
|
715
|
+
return current;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const parent = dirname(current);
|
|
719
|
+
if (parent === current) {
|
|
720
|
+
throw new Error(`failed to locate repository root from ${startDir}`);
|
|
721
|
+
}
|
|
722
|
+
current = parent;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
async function loadTicket(id: string): Promise<Record<string, unknown>> {
|
|
727
|
+
return tndmJson<Record<string, unknown>>(["ticket", "show", id]);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async function loadTaskList(id: string): Promise<FlowTaskListEntry[]> {
|
|
731
|
+
const tasks = await tndmJson<unknown>(["ticket", "task", "list", id]);
|
|
732
|
+
if (!Array.isArray(tasks)) {
|
|
733
|
+
throw new Error(`supi_flow: task list for ticket ${id} did not return an array`);
|
|
734
|
+
}
|
|
735
|
+
return filterFlowTasks(tasks);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
async function ensureTaskDetailDoc(id: string, taskNumber: number): Promise<{ path: string }> {
|
|
739
|
+
return tndmJson<{ path: string }>([
|
|
740
|
+
"ticket",
|
|
741
|
+
"task",
|
|
742
|
+
"detail",
|
|
743
|
+
"ensure",
|
|
744
|
+
id,
|
|
745
|
+
String(taskNumber),
|
|
746
|
+
]);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function writeTaskDetailDoc(path: string, taskNumber: number, title: string, detail: string): void {
|
|
750
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
751
|
+
writeFileSync(path, `# Task ${taskNumber}: ${title}\n\n${detail}\n`, "utf-8");
|
|
752
|
+
}
|