@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.
@@ -1,9 +1,19 @@
1
- import { dirname, join } from "node:path";
2
- import { readFileSync, writeFileSync } from "node:fs";
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
- "Markdown plan content with tasks numbered as '**Task {N}**'.\n\n- [ ] **Task 1**: Description\n - File: path/to/file\n - Verification: command",
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
- // Create a "plan" document and get its path
94
- const docResult = await tndmJson<{ path: string }>([
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
- "--remove-tags",
130
- "flow:brainstorm,flow:planned,flow:applying,flow:done",
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: `Plan stored in ${params.ticket_id} (${docPath}). Tags updated to flow:planned.`,
123
+ text: `Overview stored in content.md for ticket ${params.ticket_id}. Tags updated to flow:planned.`,
145
124
  },
146
125
  ],
147
- details: { action: "flow_plan", ticketId: params.ticket_id, tags: "flow:planned", path: docPath },
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
- // ─── supi_flow_complete_task ───────────────────────────────────
135
+ // ─── supi_flow_apply ───────────────────────────────────────────
152
136
 
153
- export const supiFlowCompleteTaskParams = Type.Object({
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 FlowCompleteTaskParams = Static<typeof supiFlowCompleteTaskParams>;
141
+ export type FlowApplyParams = Static<typeof supiFlowApplyParams>;
161
142
 
162
- type CheckTaskResult =
163
- | { kind: "unchecked"; updatedContent: string }
164
- | { kind: "already_checked" }
165
- | { kind: "not_found" };
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
- function checkTask(content: string, taskNumber: number): CheckTaskResult {
168
- // Match a task line like "- [ ] **Task N:**" or " - [ ] **Task N:**"
169
- const lines = content.split("\n");
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
- const uncheckedMatch = trimmed.match(
175
- new RegExp(`^- \\[ \\] \\*\\*Task ${taskNumber}\\*\\*:`),
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
- if (uncheckedMatch) {
178
- // Replace the [ ] with [x] in the trimmed version
179
- const indent = line.slice(0, line.length - trimmed.length);
180
- lines[i] = indent + trimmed.replace("- [ ]", "- [x]");
181
- return { kind: "unchecked", updatedContent: lines.join("\n") };
182
- }
183
-
184
- const checkedMatch = trimmed.match(
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
- export async function executeFlowCompleteTask(params: FlowCompleteTaskParams) {
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
- const contentPath = showResult.content_path;
206
- if (!contentPath) {
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
- const planDocument = showResult.documents?.find((document) => document.name === "plan");
219
- if (!planDocument) {
220
- return {
221
- content: [
222
- {
223
- type: "text" as const,
224
- text: `No plan file is registered in ticket ${params.ticket_id}.`,
225
- },
226
- ],
227
- details: {
228
- action: "flow_complete_task",
229
- ticketId: params.ticket_id,
230
- error: "No plan file",
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
- const planPath = join(dirname(contentPath), planDocument.path);
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
- let content: string;
238
- try {
239
- content = readFileSync(planPath, "utf-8");
240
- } catch {
241
- return {
242
- content: [
243
- {
244
- type: "text" as const,
245
- text: `No plan file found at ${planPath}. No tasks to complete.`,
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
- details: { action: "flow_complete_task", ticketId: params.ticket_id, error: "No plan file" },
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
- const result = checkTask(content, params.task_number);
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} checked off in ${params.ticket_id}.`,
421
+ text: `Task ${params.task_number} updated in ${params.ticket_id}.`,
263
422
  },
264
423
  ],
265
424
  details: {
266
- action: "flow_complete_task",
425
+ action: "flow_task",
426
+ operation: "edit",
267
427
  ticketId: params.ticket_id,
268
428
  taskNumber: params.task_number,
269
- completed: true,
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} is already checked off in ${params.ticket_id}.`,
451
+ text: `Task ${params.task_number} removed from ${params.ticket_id}.`,
279
452
  },
280
453
  ],
281
454
  details: {
282
- action: "flow_complete_task",
455
+ action: "flow_task",
456
+ operation: "remove",
283
457
  ticketId: params.ticket_id,
284
458
  taskNumber: params.task_number,
285
- completed: true,
286
- skipped: true,
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
- case "not_found":
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.Optional(
303
- Type.String({
304
- description:
305
- "Verification results / evidence from the agent. Appended to the ticket content under ## Verification Results.",
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
- let archivePath = "";
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
- if (params.verification_results) {
316
- // Create/register archive.md via document registry, then write results
317
- const docResult = await tndmJson<{ path: string }>([
318
- "ticket",
319
- "doc",
320
- "create",
321
- params.ticket_id,
322
- "archive",
323
- ]);
324
- archivePath = docResult.path;
325
- writeFileSync(archivePath, `# Archive\n\n${params.verification_results}\n`, "utf-8");
326
- await tndm(["ticket", "sync", params.ticket_id]);
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
- // Replace any flow-state tag with flow:done — remove all possible flow-state tags
330
- // first, then set status and add flow:done, to work correctly regardless of the
331
- // ticket's current flow state.
332
- await tndm([
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
- "update",
570
+ "doc",
571
+ "create",
335
572
  params.ticket_id,
336
- "--remove-tags",
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
+ }