From 09acec6dce92ca1a904e42edb9598b94e49c545e Mon Sep 17 00:00:00 2001 From: Tibsfox Date: Mon, 6 Apr 2026 19:32:17 -0700 Subject: [PATCH 1/2] fix(gsd): normalize list inputs in complete-task + fix roadmap dep parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes: 1. gsd_complete_task now normalizes keyFiles/keyDecisions from strings (newline-delimited bullet lists) into arrays at the tool boundary, preventing type mismatch rejections on first call (#3361) 2. Legacy roadmap table parser now detects the dependency column from the header and only parses deps from that column or cells with explicit depends/deps keywords — prevents false deps from slice titles that mention other S-IDs (#3383, #3336) Closes #3361 Closes #3383 Closes #3336 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../extensions/gsd/roadmap-slices.ts | 26 +++++++++++++++---- .../extensions/gsd/tools/complete-task.ts | 16 ++++++++++-- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/resources/extensions/gsd/roadmap-slices.ts b/src/resources/extensions/gsd/roadmap-slices.ts index 781105ff8..33ec34b83 100644 --- a/src/resources/extensions/gsd/roadmap-slices.ts +++ b/src/resources/extensions/gsd/roadmap-slices.ts @@ -66,6 +66,17 @@ function parseTableSlices(section: string): RoadmapSliceEntry[] { const lines = section.split("\n"); const slices: RoadmapSliceEntry[] = []; + // Detect dependency column index from the header row (#3383, #3336). + // Only parse deps from this column (or cells with explicit "depends"/"deps" keywords). + let depColumnIndex = -1; + for (const line of lines) { + if (!line.includes("|")) continue; + if (/S\d+/.test(line)) break; // reached data rows + const headerCells = line.split("|").map(c => c.trim()).filter(Boolean); + depColumnIndex = headerCells.findIndex(c => /^(depends|deps|depend)/i.test(c)); + if (depColumnIndex >= 0) break; + } + for (const line of lines) { // Skip non-table lines, separator lines (|---|---|), and header rows if (!line.includes("|")) continue; @@ -95,12 +106,17 @@ function parseTableSlices(section: string): RoadmapSliceEntry[] { if (/\bmedium\b/.test(cellLower) || /\bmed\b/.test(cellLower)) { risk = "medium"; break; } } - // Extract dependencies from cells containing S-prefixed IDs (excluding the slice's own ID) + // Extract dependencies only from the dependency column or cells with + // explicit "depends"/"deps" keywords — never from title cells (#3383). let depends: string[] = []; - for (const cell of cells) { - if (/depends|deps/i.test(cell) || (cell.match(/S\d+/g)?.length ?? 0) > 0) { - const depIds = (cell.match(/S\d+/g) ?? []).filter(d => d !== id); - if (depIds.length > 0 || /none|—|-/i.test(cell)) { + if (depColumnIndex >= 0 && cells[depColumnIndex]) { + const depCell = cells[depColumnIndex]!; + const depIds = (depCell.match(/S\d+/g) ?? []).filter(d => d !== id); + depends = expandDependencies(depIds); + } else { + for (const cell of cells) { + if (/depends|deps/i.test(cell)) { + const depIds = (cell.match(/S\d+/g) ?? []).filter(d => d !== id); depends = expandDependencies(depIds); break; } diff --git a/src/resources/extensions/gsd/tools/complete-task.ts b/src/resources/extensions/gsd/tools/complete-task.ts index a862ae92b..e3c422aa9 100644 --- a/src/resources/extensions/gsd/tools/complete-task.ts +++ b/src/resources/extensions/gsd/tools/complete-task.ts @@ -44,6 +44,18 @@ export interface CompleteTaskResult { import type { TaskRow } from "../gsd-db.js"; +/** + * Normalize a list parameter that may arrive as a string (newline-delimited + * bullet list from the LLM) into a string array (#3361). + */ +function normalizeListParam(value: unknown): string[] { + if (Array.isArray(value)) return value.map(String); + if (typeof value === "string" && value.trim()) { + return value.split(/\n/).map(s => s.replace(/^[\s\-*•]+/, "").trim()).filter(Boolean); + } + return []; +} + /** * Build a TaskRow-shaped object from CompleteTaskParams so the unified * renderSummaryContent() can be used at completion time (#2720). @@ -63,8 +75,8 @@ function paramsToTaskRow(params: CompleteTaskParams, completedAt: string): TaskR blocker_discovered: params.blockerDiscovered ?? false, deviations: params.deviations ?? "", known_issues: params.knownIssues ?? "", - key_files: params.keyFiles ?? [], - key_decisions: params.keyDecisions ?? [], + key_files: normalizeListParam(params.keyFiles), + key_decisions: normalizeListParam(params.keyDecisions), full_summary_md: "", description: "", estimate: "", From 1d428c5ffdfcee84a2d071112085971691079a2b Mon Sep 17 00:00:00 2001 From: Tibsfox Date: Mon, 6 Apr 2026 22:25:10 -0700 Subject: [PATCH 2/2] test: add regression test for complete-task normalizeListParam Co-Authored-By: Claude Opus 4.6 (1M context) --- .../complete-task-normalize-lists.test.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/resources/extensions/gsd/tests/complete-task-normalize-lists.test.ts diff --git a/src/resources/extensions/gsd/tests/complete-task-normalize-lists.test.ts b/src/resources/extensions/gsd/tests/complete-task-normalize-lists.test.ts new file mode 100644 index 000000000..5f8044b68 --- /dev/null +++ b/src/resources/extensions/gsd/tests/complete-task-normalize-lists.test.ts @@ -0,0 +1,54 @@ +/** + * Regression test for #3692 — normalizeListParam in complete-task + * + * Agents sometimes pass keyFiles/keyDecisions as comma-separated strings + * instead of arrays. normalizeListParam coerces both forms to string[]. + * + * Also verifies roadmap-slices.ts detects dependency column from header. + */ + +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const completeTaskSrc = readFileSync( + join(__dirname, '..', 'tools', 'complete-task.ts'), + 'utf-8', +); +const roadmapSlicesSrc = readFileSync( + join(__dirname, '..', 'roadmap-slices.ts'), + 'utf-8', +); + +describe('complete-task normalizeListParam (#3692)', () => { + test('normalizeListParam function is defined', () => { + assert.match(completeTaskSrc, /function normalizeListParam\(/, + 'normalizeListParam function should be defined in complete-task.ts'); + }); + + test('normalizeListParam is applied to keyFiles', () => { + assert.match(completeTaskSrc, /normalizeListParam\(params\.keyFiles\)/, + 'normalizeListParam should be applied to keyFiles'); + }); + + test('normalizeListParam is applied to keyDecisions', () => { + assert.match(completeTaskSrc, /normalizeListParam\(params\.keyDecisions\)/, + 'normalizeListParam should be applied to keyDecisions'); + }); +}); + +describe('roadmap-slices depColumnIndex detection (#3692)', () => { + test('depColumnIndex is detected from header row', () => { + assert.match(roadmapSlicesSrc, /depColumnIndex/, + 'depColumnIndex variable should exist in roadmap-slices.ts'); + assert.match(roadmapSlicesSrc, /headerCells/, + 'headerCells should be parsed from the header row'); + assert.match(roadmapSlicesSrc, /depends|deps|depend/i, + 'header detection should match depends/deps/depend'); + }); +});