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"]
```
## 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 |

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.
## 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 (ArrangeActAssert)
- 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

View File

@@ -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 " ":

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