feat: support for letter padded tasks

This commit is contained in:
2026-06-21 13:29:24 -04:00
parent 3ba5fcb098
commit fe28718911
4 changed files with 407 additions and 40 deletions

View File

@@ -54,30 +54,46 @@ tasks:
depends_on: ["01"] 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 ## Dependencies
### Arrow Notation (recommended): ### Arrow Notation (recommended)
1 -> 2,3,4 1 -> 2,3,4
5 -> 6 5 -> 6
This means: "Task 1 must complete before tasks 2, 3, and 4 can start." 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 13 depends on 17, 18, 19, 20
14 depends on 13, 15, 16 14 depends on 13, 15, 16
This means: "Task 13 depends on tasks 17, 18, 19, and 20." 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 1, 2, 3, 4 can be done in parallel
5, 6, 7, 8 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. Note: These lines are ignored by the parser. Use explicit dependencies to control execution order.
## Configuration ## Configuration
### Task-Level Timeout ### 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) Supported formats: `10m` (minutes), `600s` (seconds), `3600000` (milliseconds)
### Config files ### Config files
| Scope | Path | | Scope | Path |

View File

@@ -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. 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 ## Core Responsibilities
- Break complex features into atomic tasks - Break complex features into atomic tasks
- Create structured directories with task files and indexes - Create structured directories with task files and indexes
- Generate clear acceptance criteria and dependency mapping - 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 ## Mandatory Two-Phase Workflow
### Phase 1: Planning (Approval Required) ### Phase 1: Planning (Approval Required)
When given a complex feature request: When given a complex feature request:
1. **Analyze the feature** to identify: 1. **Analyze the feature** to identify:
@@ -36,21 +38,27 @@ When given a complex feature request:
- Exit criteria for feature completion - Exit criteria for feature completion
3. **Present plan using this exact format:**``` 3. **Present plan using this exact format:**```
## Subtask Plan ## Subtask Plan
feature: {kebab-case-feature-name} feature: {kebab-case-feature-name}
objective: {one-line description} objective: {one-line description}
tasks: tasks:
- seq: {2-digit}, filename: {seq}-{task-description}.md, title: {clear title} - seq: {2-digit}, filename: {seq}-{task-description}.md, title: {clear title}
- seq: {2-digit}, filename: {seq}-{task-description}.md, title: {clear title} - seq: {2-digit}, filename: {seq}-{task-description}.md, title: {clear title}
dependencies: dependencies:
- {seq} -> {seq} (task dependencies) - {seq} -> {seq} (task dependencies)
exit_criteria: exit_criteria:
- {specific, measurable completion criteria} - {specific, measurable completion criteria}
Approval needed before file creation. Approval needed before file creation.
``` ```
4. **Wait for explicit approval** before proceeding to Phase 2. 4. **Wait for explicit approval** before proceeding to Phase 2.
@@ -67,6 +75,7 @@ Once approved:
**Feature Index Template** (`tasks/{feature}/README.md`): **Feature Index Template** (`tasks/{feature}/README.md`):
``` ```
# {Feature Title} # {Feature Title}
Objective: {one-liner} Objective: {one-liner}
@@ -74,17 +83,22 @@ Objective: {one-liner}
Status legend: [ ] todo, [~] in-progress, [x] done Status legend: [ ] todo, [~] in-progress, [x] done
Tasks Tasks
- [ ] {seq} — {task-description} → `{seq}-{task-description}.md` - [ ] {seq} — {task-description} → `{seq}-{task-description}.md`
Dependencies Dependencies
- {seq} depends on {seq} - {seq} depends on {seq}
Exit criteria Exit criteria
- The feature is complete when {specific criteria} - The feature is complete when {specific criteria}
``` ```
**Task File Template** (`{seq}-{task-description}.md`): **Task File Template** (`{seq}-{task-description}.md`):
``` ```
# {seq}. {Title} # {seq}. {Title}
meta: meta:
@@ -95,40 +109,54 @@ meta:
tags: [implementation, tests-required] tags: [implementation, tests-required]
objective: objective:
- Clear, single outcome for this task - Clear, single outcome for this task
deliverables: deliverables:
- What gets added/changed (files, modules, endpoints) - What gets added/changed (files, modules, endpoints)
steps: steps:
- Step-by-step actions to complete the task - Step-by-step actions to complete the task
tests: tests:
- Unit: which functions/modules to cover (ArrangeActAssert) - Unit: which functions/modules to cover (ArrangeActAssert)
- Integration/e2e: how to validate behavior - Integration/e2e: how to validate behavior
acceptance_criteria: acceptance_criteria:
- Observable, binary pass/fail conditions - Observable, binary pass/fail conditions
validation: validation:
- Commands or scripts to run and how to verify - Commands or scripts to run and how to verify
notes: notes:
- Assumptions, links to relevant docs or design - Assumptions, links to relevant docs or design
``` ```
3. **Provide creation summary:** 3. **Provide creation summary:**
``` ```
## Subtasks Created ## Subtasks Created
- tasks/{feature}/README.md - tasks/{feature}/README.md
- tasks/{feature}/{seq}-{task-description}.md - tasks/{feature}/{seq}-{task-description}.md
Next suggested task: {seq} — {title} Next suggested task: {seq} — {title}
``` ```
## Strict Conventions ## Strict Conventions
- **Naming:** Always use kebab-case for features and task descriptions - **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` - **File pattern:** `{seq}-{task-description}.md`
- **Dependencies:** Always map task relationships (if applicable) - **Dependencies:** Always map task relationships (if applicable)
- **Tests:** Every task must include test requirements - **Tests:** Every task must include test requirements

View File

@@ -142,15 +142,17 @@ function parseFioFormat(
} }
if (inTasks) { 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 = 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; let match: RegExpExecArray | null;
while ((match = taskPattern.exec(line)) !== null) { while ((match = taskPattern.exec(line)) !== null) {
const [, status, id, title, file] = match; const [, status, id, title, file] = match;
const timeoutMs = parseTimeoutFromLine(line); const timeoutMs = parseTimeoutFromLine(line);
tasks.push({ tasks.push({
id: id.padStart(2, "0"), id: normalizeTaskId(id),
title: title.trim(), title: title.trim(),
description: undefined, description: undefined,
file: file || undefined, file: file || undefined,
@@ -189,15 +191,15 @@ function parseFioFormat(
const fromIds = segments[i] const fromIds = segments[i]
.split(",") .split(",")
.map((t) => t.trim()) .map((t) => t.trim())
.filter((t) => /^\d+$/.test(t)) .filter((t) => /^\d+[a-z]?$/.test(t))
.map((t) => t.padStart(2, "0")); .map((t) => normalizeTaskId(t));
// Right segment: target(s) (comma-separated) // Right segment: target(s) (comma-separated)
const toIds = segments[i + 1] const toIds = segments[i + 1]
.split(",") .split(",")
.map((t) => t.trim()) .map((t) => t.trim())
.filter((t) => /^\d+$/.test(t)) .filter((t) => /^\d+[a-z]?$/.test(t))
.map((t) => t.padStart(2, "0")); .map((t) => normalizeTaskId(t));
for (const toId of toIds) { for (const toId of toIds) {
if (!dependencies[toId]) dependencies[toId] = []; if (!dependencies[toId]) dependencies[toId] = [];
@@ -214,17 +216,20 @@ function parseFioFormat(
// Format 1: Natural language "X depends on A, B, C" // Format 1: Natural language "X depends on A, B, C"
// Supports optional markdown list prefix: "- 13 depends on 17, 18, 19" // Supports optional markdown list prefix: "- 13 depends on 17, 18, 19"
// Also handles "also depends on": "- 08 also depends on 05, 06" // 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( 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) { if (dependsMatch) {
const [, taskId, depsList] = dependsMatch; const [, taskId, depsList] = dependsMatch;
const taskIdPadded = taskId.padStart(2, "0"); const taskIdPadded = normalizeTaskId(taskId);
const depIds = depsList const depIds = depsList
.split(",") .split(",")
.map((t) => t.trim()) .map((t) => t.trim())
.filter((t) => t) .filter((t) => /^\d+[a-z]?$/.test(t))
.map((t) => t.padStart(2, "0")); .map((t) => normalizeTaskId(t));
if (!dependencies[taskIdPadded]) dependencies[taskIdPadded] = []; if (!dependencies[taskIdPadded]) dependencies[taskIdPadded] = [];
for (const depId of depIds) { for (const depId of depIds) {
@@ -236,11 +241,11 @@ function parseFioFormat(
// Parse meta blocks for task configuration (timeout, etc.) // Parse meta blocks for task configuration (timeout, etc.)
const metaMatch = line.match( 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) { if (metaMatch) {
const [, taskId, value, unit] = 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) { if (task) {
task.timeoutMs = parseTimeoutValue(Number(value), unit); task.timeoutMs = parseTimeoutValue(Number(value), unit);
} }
@@ -249,15 +254,15 @@ function parseFioFormat(
// Format 2: "X, Y, Z can be done in parallel (label)" // Format 2: "X, Y, Z can be done in parallel (label)"
// "- 01, 02, 03, 04 can be done in parallel (Play Store prep)" // "- 01, 02, 03, 04 can be done in parallel (Play Store prep)"
const parallelMatch = line.match( 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) { if (parallelMatch) {
const [, idsStr, label] = parallelMatch; const [, idsStr, label] = parallelMatch;
const taskIds = idsStr const taskIds = idsStr
.split(",") .split(",")
.map((t) => t.trim()) .map((t) => t.trim())
.filter((t) => /^\d+$/.test(t)) .filter((t) => /^\d+[a-z]?$/.test(t))
.map((t) => t.padStart(2, "0")); .map((t) => normalizeTaskId(t));
if (taskIds.length > 0) { if (taskIds.length > 0) {
parallelGroups.push({ parallelGroups.push({
@@ -272,20 +277,20 @@ function parseFioFormat(
// "- 21 must be done before 22, 23, 24 (backend integration foundation)" // "- 21 must be done before 22, 23, 24 (backend integration foundation)"
// "- 02, 03 must be done before 04" // "- 02, 03 must be done before 04"
const mustBeforeMatch = line.match( 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) { if (mustBeforeMatch) {
const [, fromIdsStr, toIdsStr] = mustBeforeMatch; const [, fromIdsStr, toIdsStr] = mustBeforeMatch;
const fromIds = fromIdsStr const fromIds = fromIdsStr
.split(",") .split(",")
.map((t) => t.trim()) .map((t) => t.trim())
.filter((t) => /^\d+$/.test(t)) .filter((t) => /^\d+[a-z]?$/.test(t))
.map((t) => t.padStart(2, "0")); .map((t) => normalizeTaskId(t));
const toIds = toIdsStr const toIds = toIdsStr
.split(",") .split(",")
.map((t) => t.trim()) .map((t) => t.trim())
.filter((t) => /^\d+$/.test(t)) .filter((t) => /^\d+[a-z]?$/.test(t))
.map((t) => t.padStart(2, "0")); .map((t) => normalizeTaskId(t));
// Each "to" task depends on ALL "from" tasks // Each "to" task depends on ALL "from" tasks
for (const toId of toIds) { for (const toId of toIds) {
@@ -305,20 +310,20 @@ function parseFioFormat(
// Strip optional "also" before matching // Strip optional "also" before matching
const cleanedLine = line.replace(/\balso\b/i, ""); const cleanedLine = line.replace(/\balso\b/i, "");
const dependOnMatch = cleanedLine.match( 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) { if (dependOnMatch) {
const [, fromIdsStr, toIdsStr] = dependOnMatch; const [, fromIdsStr, toIdsStr] = dependOnMatch;
const fromIds = fromIdsStr const fromIds = fromIdsStr
.split(",") .split(",")
.map((t) => t.trim()) .map((t) => t.trim())
.filter((t) => /^\d+$/.test(t)) .filter((t) => /^\d+[a-z]?$/.test(t))
.map((t) => t.padStart(2, "0")); .map((t) => normalizeTaskId(t));
const toIds = toIdsStr const toIds = toIdsStr
.split(",") .split(",")
.map((t) => t.trim()) .map((t) => t.trim())
.filter((t) => /^\d+$/.test(t)) .filter((t) => /^\d+[a-z]?$/.test(t))
.map((t) => t.padStart(2, "0")); .map((t) => normalizeTaskId(t));
// Each "from" task depends on ALL "to" tasks // Each "from" task depends on ALL "to" tasks
for (const fromId of fromIds) { for (const fromId of fromIds) {
@@ -519,12 +524,13 @@ export function updateTaskInFile(
let content = fs.readFileSync(filePath, "utf-8"); let content = fs.readFileSync(filePath, "utf-8");
const char = statusToChar(status); const char = statusToChar(status);
// Strategy 1: Fio numbered format — match by explicit task ID in the file // Strategy 1: Fio numbered format — match by explicit task ID in the file.
// Try both padded (01) and raw (1) variations. // For pure-digit IDs, also try the parsed numeric form (parity with the
// When the task ID is already zero-padded (e.g., "01"), skip the raw ID // pre-lettered behavior). Lettered IDs ("02b", "02c") only have one valid
// to avoid partial matches ("1" matching the second digit of "01"). // 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)]); const idPatterns = new Set([escapeRegex(taskId)]);
if (!taskId.startsWith("0")) { if (!taskId.startsWith("0") && /^\d+$/.test(taskId)) {
const rawId = parseInt(taskId, 10).toString(); const rawId = parseInt(taskId, 10).toString();
idPatterns.add(escapeRegex(rawId)); idPatterns.add(escapeRegex(rawId));
} }
@@ -579,7 +585,12 @@ function updateTaskInYaml(
const tasks = doc.get("tasks"); const tasks = doc.get("tasks");
if (!tasks || !YAML.isSeq(tasks)) return; 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 // Strategy 1: Match by explicit id field
for (const item of tasks.items) { for (const item of tasks.items) {
@@ -587,7 +598,7 @@ function updateTaskInYaml(
const idVal = item.get("id"); const idVal = item.get("id");
if (idVal === undefined || idVal === null) continue; if (idVal === undefined || idVal === null) continue;
const idStr = String(idVal); const idStr = String(idVal);
if (idStr === taskId || idStr === rawId) { if (idVariants.includes(idStr)) {
item.set("status", status); item.set("status", status);
fs.writeFileSync(filePath, String(doc), "utf-8"); fs.writeFileSync(filePath, String(doc), "utf-8");
return; return;
@@ -703,6 +714,28 @@ function parseTimeoutFromMeta(
return undefined; 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"] { function charToStatus(char: string): Task["status"] {
switch (char) { switch (char) {
case " ": case " ":

View File

@@ -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();
}
});
});