feat: support for letter padded tasks
This commit is contained in:
27
README.md
27
README.md
@@ -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 |
|
||||||
|
|||||||
@@ -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 (Arrange–Act–Assert)
|
- Unit: which functions/modules to cover (Arrange–Act–Assert)
|
||||||
- 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
|
||||||
|
|||||||
@@ -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 " ":
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user