From fe2871891156c525e73451beb01aa94fbf7dbf23 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sun, 21 Jun 2026 13:29:24 -0400 Subject: [PATCH] feat: support for letter padded tasks --- README.md | 27 +++- prompts/task-manager.md | 30 +++- src/parser.ts | 99 ++++++++---- tests/parser-formats.test.ts | 291 +++++++++++++++++++++++++++++++++++ 4 files changed, 407 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 853079b..f5e3688 100644 --- a/README.md +++ b/README.md @@ -54,30 +54,46 @@ tasks: depends_on: ["01"] ``` +## Task IDs + +Task IDs are zero-padded 2-digit strings (`01`, `02`, ...) with an optional +single lowercase letter suffix for sub-tasks inserted between two numbered +steps (e.g. `02b`, `02c`). The parser normalizes `2b` → `02b`. + +``` +- [ ] 01 — Setup +- [ ] 02 — Fix bugs +- [ ] 02b — Sub-step of 02 (inserted after the fact) +- [ ] 02c — Another sub-step of 02 +- [ ] 03 — Continue +``` + +Use lettered sub-tasks when you discover mid-stream that a step needs to be +split. They let you preserve sibling numbering (`01`, `02`, `03`, ...) while +adding granularity between two existing steps. + ## Dependencies -### Arrow Notation (recommended): +### Arrow Notation (recommended) 1 -> 2,3,4 5 -> 6 This means: "Task 1 must complete before tasks 2, 3, and 4 can start." -### Natural Language: +### Natural Language 13 depends on 17, 18, 19, 20 14 depends on 13, 15, 16 This means: "Task 13 depends on tasks 17, 18, 19, and 20." -### Parallel Groups (informational only): +### Parallel Groups (informational only) 1, 2, 3, 4 can be done in parallel 5, 6, 7, 8 can be done in parallel Note: These lines are ignored by the parser. Use explicit dependencies to control execution order. - - ## Configuration ### Task-Level Timeout @@ -91,7 +107,6 @@ You can set a timeout for individual tasks using a meta block in the task file: Supported formats: `10m` (minutes), `600s` (seconds), `3600000` (milliseconds) - ### Config files | Scope | Path | diff --git a/prompts/task-manager.md b/prompts/task-manager.md index 88c4187..84c4190 100644 --- a/prompts/task-manager.md +++ b/prompts/task-manager.md @@ -14,6 +14,7 @@ Purpose: You are a Task Manager (@task-manager), an expert at breaking down complex software features into small, verifiable subtasks. Your role is to create structured task plans that enable efficient, atomic implementation work. ## Core Responsibilities + - Break complex features into atomic tasks - Create structured directories with task files and indexes - Generate clear acceptance criteria and dependency mapping @@ -22,6 +23,7 @@ You are a Task Manager (@task-manager), an expert at breaking down complex softw ## Mandatory Two-Phase Workflow ### Phase 1: Planning (Approval Required) + When given a complex feature request: 1. **Analyze the feature** to identify: @@ -36,21 +38,27 @@ When given a complex feature request: - Exit criteria for feature completion 3. **Present plan using this exact format:**``` + ## Subtask Plan + feature: {kebab-case-feature-name} objective: {one-line description} tasks: + - seq: {2-digit}, filename: {seq}-{task-description}.md, title: {clear title} - seq: {2-digit}, filename: {seq}-{task-description}.md, title: {clear title} dependencies: + - {seq} -> {seq} (task dependencies) exit_criteria: + - {specific, measurable completion criteria} Approval needed before file creation. + ``` 4. **Wait for explicit approval** before proceeding to Phase 2. @@ -67,6 +75,7 @@ Once approved: **Feature Index Template** (`tasks/{feature}/README.md`): ``` + # {Feature Title} Objective: {one-liner} @@ -74,17 +83,22 @@ Objective: {one-liner} Status legend: [ ] todo, [~] in-progress, [x] done Tasks + - [ ] {seq} — {task-description} → `{seq}-{task-description}.md` Dependencies + - {seq} depends on {seq} Exit criteria + - The feature is complete when {specific criteria} + ``` **Task File Template** (`{seq}-{task-description}.md`): ``` + # {seq}. {Title} meta: @@ -95,40 +109,54 @@ meta: tags: [implementation, tests-required] objective: + - Clear, single outcome for this task deliverables: + - What gets added/changed (files, modules, endpoints) steps: + - Step-by-step actions to complete the task tests: + - Unit: which functions/modules to cover (Arrange–Act–Assert) - Integration/e2e: how to validate behavior acceptance_criteria: + - Observable, binary pass/fail conditions validation: + - Commands or scripts to run and how to verify notes: + - Assumptions, links to relevant docs or design + ``` 3. **Provide creation summary:** ``` + ## Subtasks Created + - tasks/{feature}/README.md - tasks/{feature}/{seq}-{task-description}.md Next suggested task: {seq} — {title} + ``` ## Strict Conventions - **Naming:** Always use kebab-case for features and task descriptions -- **Sequencing:** 2-digits (01, 02, 03...) +- **Sequencing:** 2-digits (01, 02, 03...) — optionally a single lowercase letter + suffix may be appended to insert a sub-task between two numbered steps without + renumbering siblings (e.g. `02b`, `02c` for sub-tasks of `02`). The parser + normalizes `2b` → `02b`. - **File pattern:** `{seq}-{task-description}.md` - **Dependencies:** Always map task relationships (if applicable) - **Tests:** Every task must include test requirements diff --git a/src/parser.ts b/src/parser.ts index 27a0fe0..83e7e7b 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -142,15 +142,17 @@ function parseFioFormat( } if (inTasks) { - // Match all tasks on a line (supports compact single-line formats) + // Match all tasks on a line (supports compact single-line formats). + // ID is digits optionally followed by a single lowercase letter + // (e.g. "01", "02b", "10c") — see normalizeTaskId for the shape. const taskPattern = - /-+\s+\[(.)\]\s+(\d+)\s+[—–:-]\s+(.+?)(?:\s+(?=-+\s+\[)|\s*→\s*`([^`]+)`|$)/g; + /-+\s+\[(.)\]\s+(\d+[a-z]?)\s+[—–:-]\s+(.+?)(?:\s+(?=-+\s+\[)|\s*→\s*`([^`]+)`|$)/g; let match: RegExpExecArray | null; while ((match = taskPattern.exec(line)) !== null) { const [, status, id, title, file] = match; const timeoutMs = parseTimeoutFromLine(line); tasks.push({ - id: id.padStart(2, "0"), + id: normalizeTaskId(id), title: title.trim(), description: undefined, file: file || undefined, @@ -189,15 +191,15 @@ function parseFioFormat( const fromIds = segments[i] .split(",") .map((t) => t.trim()) - .filter((t) => /^\d+$/.test(t)) - .map((t) => t.padStart(2, "0")); + .filter((t) => /^\d+[a-z]?$/.test(t)) + .map((t) => normalizeTaskId(t)); // Right segment: target(s) (comma-separated) const toIds = segments[i + 1] .split(",") .map((t) => t.trim()) - .filter((t) => /^\d+$/.test(t)) - .map((t) => t.padStart(2, "0")); + .filter((t) => /^\d+[a-z]?$/.test(t)) + .map((t) => normalizeTaskId(t)); for (const toId of toIds) { if (!dependencies[toId]) dependencies[toId] = []; @@ -214,17 +216,20 @@ function parseFioFormat( // Format 1: Natural language "X depends on A, B, C" // Supports optional markdown list prefix: "- 13 depends on 17, 18, 19" // Also handles "also depends on": "- 08 also depends on 05, 06" + // The dep list char class includes lowercase letters so lettered IDs + // (e.g. "02b") don't truncate the capture. Per-id validation is + // done by the filter below, so trailing prose can't leak in. const dependsMatch = line.match( - /^(?:\s*[-*]\s+)?(\d+)\s+(?:also\s+)?depends\s+on\s+([\d,\s]+)/i, + /^(?:\s*[-*]\s+)?(\d+[a-z]?)\s+(?:also\s+)?depends\s+on\s+([\d,\s a-z]+)/i, ); if (dependsMatch) { const [, taskId, depsList] = dependsMatch; - const taskIdPadded = taskId.padStart(2, "0"); + const taskIdPadded = normalizeTaskId(taskId); const depIds = depsList .split(",") .map((t) => t.trim()) - .filter((t) => t) - .map((t) => t.padStart(2, "0")); + .filter((t) => /^\d+[a-z]?$/.test(t)) + .map((t) => normalizeTaskId(t)); if (!dependencies[taskIdPadded]) dependencies[taskIdPadded] = []; for (const depId of depIds) { @@ -236,11 +241,11 @@ function parseFioFormat( // Parse meta blocks for task configuration (timeout, etc.) const metaMatch = line.match( - /^0?(\d+)\s+\[timeout\]\s*=?\s*(\d+)(?:m|min|s|ms)?/i, + /^0?(\d+[a-z]?)\s+\[timeout\]\s*=?\s*(\d+)(?:m|min|s|ms)?/i, ); if (metaMatch) { const [, taskId, value, unit] = metaMatch; - const task = tasks.find((t) => t.id === taskId.padStart(2, "0")); + const task = tasks.find((t) => t.id === normalizeTaskId(taskId)); if (task) { task.timeoutMs = parseTimeoutValue(Number(value), unit); } @@ -249,15 +254,15 @@ function parseFioFormat( // Format 2: "X, Y, Z can be done in parallel (label)" // "- 01, 02, 03, 04 can be done in parallel (Play Store prep)" const parallelMatch = line.match( - /^(?:\s*[-*]\s+)?((?:0?\d+\s*,\s*)*0?\d+)\s+can\s+be\s+done\s+in\s+parallel(?:\s+\(([^)]+)\))?$/i, + /^(?:\s*[-*]\s+)?((?:0?\d+[a-z]?\s*,\s*)*0?\d+[a-z]?)\s+can\s+be\s+done\s+in\s+parallel(?:\s+\(([^)]+)\))?$/i, ); if (parallelMatch) { const [, idsStr, label] = parallelMatch; const taskIds = idsStr .split(",") .map((t) => t.trim()) - .filter((t) => /^\d+$/.test(t)) - .map((t) => t.padStart(2, "0")); + .filter((t) => /^\d+[a-z]?$/.test(t)) + .map((t) => normalizeTaskId(t)); if (taskIds.length > 0) { parallelGroups.push({ @@ -272,20 +277,20 @@ function parseFioFormat( // "- 21 must be done before 22, 23, 24 (backend integration foundation)" // "- 02, 03 must be done before 04" const mustBeforeMatch = line.match( - /^(?:\s*[-*]\s+)?((?:0?\d+\s*,\s*)*0?\d+)\s+must\s+be\s+done\s+before\s+((?:0?\d+\s*,\s*)*0?\d+)(?:\s+\(([^)]+)\))?$/i, + /^(?:\s*[-*]\s+)?((?:0?\d+[a-z]?\s*,\s*)*0?\d+[a-z]?)\s+must\s+be\s+done\s+before\s+((?:0?\d+[a-z]?\s*,\s*)*0?\d+[a-z]?)(?:\s+\(([^)]+)\))?$/i, ); if (mustBeforeMatch) { const [, fromIdsStr, toIdsStr] = mustBeforeMatch; const fromIds = fromIdsStr .split(",") .map((t) => t.trim()) - .filter((t) => /^\d+$/.test(t)) - .map((t) => t.padStart(2, "0")); + .filter((t) => /^\d+[a-z]?$/.test(t)) + .map((t) => normalizeTaskId(t)); const toIds = toIdsStr .split(",") .map((t) => t.trim()) - .filter((t) => /^\d+$/.test(t)) - .map((t) => t.padStart(2, "0")); + .filter((t) => /^\d+[a-z]?$/.test(t)) + .map((t) => normalizeTaskId(t)); // Each "to" task depends on ALL "from" tasks for (const toId of toIds) { @@ -305,20 +310,20 @@ function parseFioFormat( // Strip optional "also" before matching const cleanedLine = line.replace(/\balso\b/i, ""); const dependOnMatch = cleanedLine.match( - /^(?:\s*[-*]\s+)?((?:0?\d+\s*,\s*)*0?\d+)\s+depend(?:s)?\s+on\s+((?:0?\d+\s*,\s*)*0?\d+)(?:\s+\(([^)]+)\))?$/i, + /^(?:\s*[-*]\s+)?((?:0?\d+[a-z]?\s*,\s*)*0?\d+[a-z]?)\s+depend(?:s)?\s+on\s+((?:0?\d+[a-z]?\s*,\s*)*0?\d+[a-z]?)(?:\s+\(([^)]+)\))?$/i, ); if (dependOnMatch) { const [, fromIdsStr, toIdsStr] = dependOnMatch; const fromIds = fromIdsStr .split(",") .map((t) => t.trim()) - .filter((t) => /^\d+$/.test(t)) - .map((t) => t.padStart(2, "0")); + .filter((t) => /^\d+[a-z]?$/.test(t)) + .map((t) => normalizeTaskId(t)); const toIds = toIdsStr .split(",") .map((t) => t.trim()) - .filter((t) => /^\d+$/.test(t)) - .map((t) => t.padStart(2, "0")); + .filter((t) => /^\d+[a-z]?$/.test(t)) + .map((t) => normalizeTaskId(t)); // Each "from" task depends on ALL "to" tasks for (const fromId of fromIds) { @@ -519,12 +524,13 @@ export function updateTaskInFile( let content = fs.readFileSync(filePath, "utf-8"); const char = statusToChar(status); - // Strategy 1: Fio numbered format — match by explicit task ID in the file - // Try both padded (01) and raw (1) variations. - // When the task ID is already zero-padded (e.g., "01"), skip the raw ID - // to avoid partial matches ("1" matching the second digit of "01"). + // Strategy 1: Fio numbered format — match by explicit task ID in the file. + // For pure-digit IDs, also try the parsed numeric form (parity with the + // pre-lettered behavior). Lettered IDs ("02b", "02c") only have one valid + // form — the parseInt fallback would silently drop the letter suffix and + // create false-positive partial matches, so we skip it for them. const idPatterns = new Set([escapeRegex(taskId)]); - if (!taskId.startsWith("0")) { + if (!taskId.startsWith("0") && /^\d+$/.test(taskId)) { const rawId = parseInt(taskId, 10).toString(); idPatterns.add(escapeRegex(rawId)); } @@ -579,7 +585,12 @@ function updateTaskInYaml( const tasks = doc.get("tasks"); if (!tasks || !YAML.isSeq(tasks)) return; - const rawId = parseInt(taskId, 10).toString(); + // Build alternate ID forms for matching. For lettered IDs ("02b"), the + // verbatim form is the only valid pattern — parseInt would drop the suffix. + const idVariants: string[] = [taskId]; + if (/^\d+$/.test(taskId)) { + idVariants.push(parseInt(taskId, 10).toString()); + } // Strategy 1: Match by explicit id field for (const item of tasks.items) { @@ -587,7 +598,7 @@ function updateTaskInYaml( const idVal = item.get("id"); if (idVal === undefined || idVal === null) continue; const idStr = String(idVal); - if (idStr === taskId || idStr === rawId) { + if (idVariants.includes(idStr)) { item.set("status", status); fs.writeFileSync(filePath, String(doc), "utf-8"); return; @@ -703,6 +714,28 @@ function parseTimeoutFromMeta( return undefined; } +/** + * Normalize a task ID: zero-pad the digit portion to 2 chars, preserve any + * single lowercase letter suffix. Idempotent on already-normalized IDs. + * + * "1" → "01" + * "2" → "02" + * "2b" → "02b" + * "02b" → "02b" + * "10" → "10" + * "10b" → "10b" + * + * Pass-through for IDs that don't match the expected shape (defensive — the + * upstream regexes restrict matches, but a stray value should not be silently + * re-shaped). + */ +function normalizeTaskId(id: string): string { + const match = id.match(/^(\d+)([a-z])?$/); + if (!match) return id; + const [, digits, letter] = match; + return digits.padStart(2, "0") + (letter ?? ""); +} + function charToStatus(char: string): Task["status"] { switch (char) { case " ": diff --git a/tests/parser-formats.test.ts b/tests/parser-formats.test.ts index 1c8b6c3..ad05634 100644 --- a/tests/parser-formats.test.ts +++ b/tests/parser-formats.test.ts @@ -1028,3 +1028,294 @@ describe("Plain section headings (no ##)", () => { } }); }); + +// ─── Lettered Step IDs (e.g. 02b, 02c) ──────────────────────────────────── + +describe("Lettered step IDs (e.g. 02b, 02c)", () => { + it("parses task lines with a single lowercase letter suffix", () => { + const content = `${FIO_HEADER} +- [~] 01 — Revert terminology +- [ ] 02 — Fix lesson generation +- [ ] 02b — Create lesson sequence adapter +- [ ] 02c — Add tap to show translation +- [ ] 03 — Restore web curriculum overview + +${FIO_FOOTER} +- 02 -> 02b +- 02 -> 02c +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks).toHaveLength(5); + expect(project.tasks.map((t) => t.id)).toEqual([ + "01", + "02", + "02b", + "02c", + "03", + ]); + } finally { + cleanup(); + } + }); + + it("normalizes unpadded lettered IDs (2b -> 02b)", () => { + const content = `${FIO_HEADER} +- [ ] 2 — First +- [ ] 2b — Sub-step +- [ ] 2c — Another sub-step +- [ ] 3 — Third + +${FIO_FOOTER} +`; + const { project, cleanup } = parse(content); + try { + const ids = project.tasks.map((t) => t.id); + expect(ids).toEqual(["02", "02b", "02c", "03"]); + } finally { + cleanup(); + } + }); + + it("preserves lettered IDs in natural-language depends-on", () => { + const content = `${FIO_HEADER} +- [ ] 02 — Fix bugs +- [ ] 02b — Adapter +- [ ] 02c — Translation +- [ ] 04 — Restore unit detail + +${FIO_FOOTER} +- 02b depends on 02 +- 02c depends on 02 +- 04 depends on 01, 02b, 03 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks.find((t) => t.id === "02b")!.dependencies).toEqual([ + "02", + ]); + expect(project.tasks.find((t) => t.id === "02c")!.dependencies).toEqual([ + "02", + ]); + expect( + project.tasks.find((t) => t.id === "04")!.dependencies.sort(), + ).toEqual(["01", "02b", "03"]); + } finally { + cleanup(); + } + }); + + it("handles 'also depends on' with lettered IDs", () => { + const content = `${FIO_HEADER} +- [ ] 02 — First +- [ ] 02b — Sub + +${FIO_FOOTER} +- 02b also depends on 02 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks.find((t) => t.id === "02b")!.dependencies).toEqual([ + "02", + ]); + } finally { + cleanup(); + } + }); + + it("handles arrow notation with lettered targets", () => { + const content = `${FIO_HEADER} +- [ ] 02 — Source +- [ ] 02b — Target b +- [ ] 02c — Target c +- [ ] 03 — End + +${FIO_FOOTER} +- 02 -> 02b, 02c +- 02b, 02c -> 03 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks.find((t) => t.id === "02b")!.dependencies).toEqual([ + "02", + ]); + expect(project.tasks.find((t) => t.id === "02c")!.dependencies).toEqual([ + "02", + ]); + expect( + project.tasks.find((t) => t.id === "03")!.dependencies.sort(), + ).toEqual(["02b", "02c"]); + } finally { + cleanup(); + } + }); + + it("handles 'must be done before' with lettered IDs", () => { + const content = `${FIO_HEADER} +- [ ] 02 — Before +- [ ] 02b — Sub b +- [ ] 02c — Sub c +- [ ] 03 — After + +${FIO_FOOTER} +- 02 must be done before 02b, 02c +- 02b must be done before 03 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks.find((t) => t.id === "02b")!.dependencies).toEqual([ + "02", + ]); + expect(project.tasks.find((t) => t.id === "02c")!.dependencies).toEqual([ + "02", + ]); + expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([ + "02b", + ]); + } finally { + cleanup(); + } + }); + + it("handles 'depend on' with lettered IDs", () => { + const content = `${FIO_HEADER} +- [ ] 02 — Foundation +- [ ] 03 — Foundation +- [ ] 02b — Needs both + +${FIO_FOOTER} +- 02b depends on 02, 03 +`; + const { project, cleanup } = parse(content); + try { + expect( + project.tasks.find((t) => t.id === "02b")!.dependencies.sort(), + ).toEqual(["02", "03"]); + } finally { + cleanup(); + } + }); + + it("handles 'can be done in parallel' with lettered IDs", () => { + const content = `${FIO_HEADER} +- [ ] 02b — Adapter +- [ ] 02c — Translation +- [ ] 05 — Final + +${FIO_FOOTER} +- 02b, 02c can be done in parallel (post-fix polish) +`; + const { project, cleanup } = parse(content); + try { + expect(project.parallelGroups).toBeDefined(); + const group = project.parallelGroups![0]; + expect(group.taskIds.sort()).toEqual(["02b", "02c"]); + expect(group.label).toBe("post-fix polish"); + } finally { + cleanup(); + } + }); + + it("parses timeout meta block for a lettered ID", () => { + // Timeout meta blocks live in the Dependencies section, not inline + // with the task. Format: "02b [timeout] = 15m" (no list prefix). + const content = `${FIO_HEADER} +- [ ] 02b — Adapter + +${FIO_FOOTER} +02b [timeout] = 15m +`; + const { project, cleanup } = parse(content); + try { + const task = project.tasks.find((t) => t.id === "02b")!; + expect(task.timeoutMs).toBe(15 * 60 * 1000); + } finally { + cleanup(); + } + }); + + it("parses the full Linear UI Reintegration example", () => { + const content = `# Linear UI Reintegration + +Objective: Reintegrate the original linear unit progression UI while retaining the pool-based v2 backend, hiding all pool internals from the user. Also fix critical lesson generation bugs (word repetition, introduction dedup, learning-pool cap) and add tap-to-show-translation. + +Status legend: [ ] todo, [~] in-progress, [x] done + +Tasks + +- [~] 01 — revert-terminology-and-url-scheme +- [ ] 02 — fix-lesson-generation-bugs +- [ ] 02b — create-lesson-sequence-adapter +- [ ] 02c — add-tap-to-show-translation +- [ ] 03 — restore-web-curriculum-overview +- [ ] 04 — restore-web-linear-path-unit-detail +- [ ] 05 — wire-web-lesson-player-to-v2 +- [ ] 06 — remove-pool-exposing-web-ui +- [ ] 07 — restore-ios-curriculum-linear-view +- [ ] 08 — bridge-ios-lesson-flow-to-v2 +- [ ] 09 — remove-pool-exposing-ios-ui +- [ ] 10 — e2e-testing-and-validation + +Dependencies + +- 02b depends on 02 +- 04 depends on 01, 02b, 03 +- 05 depends on 02b, 02c, 04 +- 06 depends on 03, 04, 05 +- 08 depends on 02b, 02c, 07 +- 09 depends on 07, 08 +- 10 depends on 05, 06, 08, 09 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks).toHaveLength(12); + expect(project.objective).toBe("Linear UI Reintegration"); + + // Lettered IDs land in the right slot + const t2b = project.tasks.find((t) => t.id === "02b")!; + const t2c = project.tasks.find((t) => t.id === "02c")!; + expect(t2b.title).toBe("create-lesson-sequence-adapter"); + expect(t2c.title).toBe("add-tap-to-show-translation"); + + // 02b depends on 02 + expect(t2b.dependencies).toEqual(["02"]); + expect(t2c.dependencies).toEqual([]); + + // 04 depends on 01, 02b, 03 + expect( + project.tasks.find((t) => t.id === "04")!.dependencies.sort(), + ).toEqual(["01", "02b", "03"]); + + // 05 depends on 02b, 02c, 04 + expect( + project.tasks.find((t) => t.id === "05")!.dependencies.sort(), + ).toEqual(["02b", "02c", "04"]); + + // 10 depends on 05, 06, 08, 09 + expect( + project.tasks.find((t) => t.id === "10")!.dependencies.sort(), + ).toEqual(["05", "06", "08", "09"]); + } finally { + cleanup(); + } + }); + + it("preserves 02b in task title normalization (id 02b vs 02)", () => { + const content = `${FIO_HEADER} +- [ ] 02 — Step two +- [ ] 02b — Step two-b +- [ ] 02c — Step two-c + +${FIO_FOOTER} +`; + const { project, cleanup } = parse(content); + try { + const ids = project.tasks.map((t) => t.id); + expect(ids).toEqual(["02", "02b", "02c"]); + // All three are distinct + expect(new Set(ids).size).toBe(3); + } finally { + cleanup(); + } + }); +});