Add in-progress state for todos instead of marking done immediately (#543)

feat: Add in-progress state for todos
This commit is contained in:
Emre Erdoğan
2026-02-14 23:18:06 +03:00
committed by GitHub
parent 430a7e4f17
commit 6f98b4f70e
4 changed files with 211 additions and 29 deletions

View File

@@ -51,7 +51,8 @@
* progress [json|table|bar] Render progress in various formats
*
* Todos:
* todo complete <filename> Move todo from pending to completed
* todo start <filename> Move todo from pending to in-progress
* todo complete <filename> Move todo from in-progress (or pending) to done
*
* Scaffolding:
* scaffold context --phase <N> Create CONTEXT.md template
@@ -517,9 +518,12 @@ function cmdCurrentTimestamp(format, raw) {
function cmdListTodos(cwd, area, raw) {
const pendingDir = path.join(cwd, '.planning', 'todos', 'pending');
const inProgressDir = path.join(cwd, '.planning', 'todos', 'in-progress');
let count = 0;
const todos = [];
let inProgressCount = 0;
const inProgressTodos = [];
try {
const files = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md'));
@@ -548,7 +552,35 @@ function cmdListTodos(cwd, area, raw) {
}
} catch {}
const result = { count, todos };
try {
const files = fs.readdirSync(inProgressDir).filter(f => f.endsWith('.md'));
for (const file of files) {
try {
const content = fs.readFileSync(path.join(inProgressDir, file), 'utf-8');
const createdMatch = content.match(/^created:\s*(.+)$/m);
const titleMatch = content.match(/^title:\s*(.+)$/m);
const areaMatch = content.match(/^area:\s*(.+)$/m);
const startedMatch = content.match(/^started:\s*(.+)$/m);
const todoArea = areaMatch ? areaMatch[1].trim() : 'general';
if (area && todoArea !== area) continue;
inProgressCount++;
inProgressTodos.push({
file,
created: createdMatch ? createdMatch[1].trim() : 'unknown',
started: startedMatch ? startedMatch[1].trim() : 'unknown',
title: titleMatch ? titleMatch[1].trim() : 'Untitled',
area: todoArea,
path: path.join('.planning', 'todos', 'in-progress', file),
});
} catch {}
}
} catch {}
const result = { count, todos, in_progress_count: inProgressCount, in_progress_todos: inProgressTodos };
output(result, raw, count.toString());
}
@@ -3386,6 +3418,35 @@ function cmdProgressRender(cwd, format, raw) {
}
}
// ─── Todo Start ──────────────────────────────────────────────────────────────
function cmdTodoStart(cwd, filename, raw) {
if (!filename) {
error('filename required for todo start');
}
const pendingDir = path.join(cwd, '.planning', 'todos', 'pending');
const inProgressDir = path.join(cwd, '.planning', 'todos', 'in-progress');
const sourcePath = path.join(pendingDir, filename);
if (!fs.existsSync(sourcePath)) {
error(`Todo not found: ${filename}`);
}
// Ensure in-progress directory exists
fs.mkdirSync(inProgressDir, { recursive: true });
// Read, add started timestamp, move
let content = fs.readFileSync(sourcePath, 'utf-8');
const today = new Date().toISOString().split('T')[0];
content = `started: ${today}\n` + content;
fs.writeFileSync(path.join(inProgressDir, filename), content, 'utf-8');
fs.unlinkSync(sourcePath);
output({ started: true, file: filename, date: today }, raw, 'started');
}
// ─── Todo Complete ────────────────────────────────────────────────────────────
function cmdTodoComplete(cwd, filename, raw) {
@@ -3393,23 +3454,28 @@ function cmdTodoComplete(cwd, filename, raw) {
error('filename required for todo complete');
}
const inProgressDir = path.join(cwd, '.planning', 'todos', 'in-progress');
const pendingDir = path.join(cwd, '.planning', 'todos', 'pending');
const completedDir = path.join(cwd, '.planning', 'todos', 'completed');
const sourcePath = path.join(pendingDir, filename);
const doneDir = path.join(cwd, '.planning', 'todos', 'done');
// Check in-progress first, then fall back to pending
let sourcePath = path.join(inProgressDir, filename);
if (!fs.existsSync(sourcePath)) {
error(`Todo not found: ${filename}`);
sourcePath = path.join(pendingDir, filename);
if (!fs.existsSync(sourcePath)) {
error(`Todo not found: ${filename}`);
}
}
// Ensure completed directory exists
fs.mkdirSync(completedDir, { recursive: true });
// Ensure done directory exists
fs.mkdirSync(doneDir, { recursive: true });
// Read, add completion timestamp, move
let content = fs.readFileSync(sourcePath, 'utf-8');
const today = new Date().toISOString().split('T')[0];
content = `completed: ${today}\n` + content;
fs.writeFileSync(path.join(completedDir, filename), content, 'utf-8');
fs.writeFileSync(path.join(doneDir, filename), content, 'utf-8');
fs.unlinkSync(sourcePath);
output({ completed: true, file: filename, date: today }, raw, 'completed');
@@ -3980,7 +4046,7 @@ function cmdInitTodos(cwd, area, raw) {
const config = loadConfig(cwd);
const now = new Date();
// List todos (reuse existing logic)
// List pending todos
const pendingDir = path.join(cwd, '.planning', 'todos', 'pending');
let count = 0;
const todos = [];
@@ -4009,6 +4075,37 @@ function cmdInitTodos(cwd, area, raw) {
}
} catch {}
// List in-progress todos
const inProgressDir = path.join(cwd, '.planning', 'todos', 'in-progress');
let inProgressCount = 0;
const inProgressTodos = [];
try {
const files = fs.readdirSync(inProgressDir).filter(f => f.endsWith('.md'));
for (const file of files) {
try {
const content = fs.readFileSync(path.join(inProgressDir, file), 'utf-8');
const createdMatch = content.match(/^created:\s*(.+)$/m);
const titleMatch = content.match(/^title:\s*(.+)$/m);
const areaMatch = content.match(/^area:\s*(.+)$/m);
const startedMatch = content.match(/^started:\s*(.+)$/m);
const todoArea = areaMatch ? areaMatch[1].trim() : 'general';
if (area && todoArea !== area) continue;
inProgressCount++;
inProgressTodos.push({
file,
created: createdMatch ? createdMatch[1].trim() : 'unknown',
started: startedMatch ? startedMatch[1].trim() : 'unknown',
title: titleMatch ? titleMatch[1].trim() : 'Untitled',
area: todoArea,
path: path.join('.planning', 'todos', 'in-progress', file),
});
} catch {}
}
} catch {}
const result = {
// Config
commit_docs: config.commit_docs,
@@ -4020,16 +4117,20 @@ function cmdInitTodos(cwd, area, raw) {
// Todo inventory
todo_count: count,
todos,
in_progress_count: inProgressCount,
in_progress_todos: inProgressTodos,
area_filter: area || null,
// Paths
pending_dir: '.planning/todos/pending',
completed_dir: '.planning/todos/completed',
in_progress_dir: '.planning/todos/in-progress',
done_dir: '.planning/todos/done',
// File existence
planning_exists: pathExistsInternal(cwd, '.planning'),
todos_dir_exists: pathExistsInternal(cwd, '.planning/todos'),
pending_dir_exists: pathExistsInternal(cwd, '.planning/todos/pending'),
in_progress_dir_exists: pathExistsInternal(cwd, '.planning/todos/in-progress'),
};
output(result, raw);
@@ -4522,10 +4623,12 @@ async function main() {
case 'todo': {
const subcommand = args[1];
if (subcommand === 'complete') {
if (subcommand === 'start') {
cmdTodoStart(cwd, args[2], raw);
} else if (subcommand === 'complete') {
cmdTodoComplete(cwd, args[2], raw);
} else {
error('Unknown todo subcommand. Available: complete');
error('Unknown todo subcommand. Available: start, complete');
}
break;
}

View File

@@ -1885,6 +1885,60 @@ describe('progress command', () => {
});
});
// ─────────────────────────────────────────────────────────────────────────────
// todo start command
// ─────────────────────────────────────────────────────────────────────────────
describe('todo start command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('moves todo from pending to in-progress', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(
path.join(pendingDir, 'add-dark-mode.md'),
`title: Add dark mode\narea: ui\ncreated: 2025-01-01\n`
);
const result = runGsdTools('todo start add-dark-mode.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.started, true);
// Verify moved
assert.ok(
!fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'pending', 'add-dark-mode.md')),
'should be removed from pending'
);
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'in-progress', 'add-dark-mode.md')),
'should be in in-progress'
);
// Verify started timestamp added
const content = fs.readFileSync(
path.join(tmpDir, '.planning', 'todos', 'in-progress', 'add-dark-mode.md'),
'utf-8'
);
assert.ok(content.startsWith('started:'), 'should have started timestamp');
});
test('fails for nonexistent todo', () => {
const result = runGsdTools('todo start nonexistent.md', tmpDir);
assert.ok(!result.success, 'should fail');
assert.ok(result.error.includes('not found'), 'error mentions not found');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// todo complete command
// ─────────────────────────────────────────────────────────────────────────────
@@ -1900,12 +1954,12 @@ describe('todo complete command', () => {
cleanup(tmpDir);
});
test('moves todo from pending to completed', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
test('completes todo from in-progress', () => {
const inProgressDir = path.join(tmpDir, '.planning', 'todos', 'in-progress');
fs.mkdirSync(inProgressDir, { recursive: true });
fs.writeFileSync(
path.join(pendingDir, 'add-dark-mode.md'),
`title: Add dark mode\narea: ui\ncreated: 2025-01-01\n`
path.join(inProgressDir, 'add-dark-mode.md'),
`started: 2025-01-01\ntitle: Add dark mode\narea: ui\ncreated: 2025-01-01\n`
);
const result = runGsdTools('todo complete add-dark-mode.md', tmpDir);
@@ -1916,22 +1970,47 @@ describe('todo complete command', () => {
// Verify moved
assert.ok(
!fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'pending', 'add-dark-mode.md')),
'should be removed from pending'
!fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'in-progress', 'add-dark-mode.md')),
'should be removed from in-progress'
);
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'completed', 'add-dark-mode.md')),
'should be in completed'
fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'done', 'add-dark-mode.md')),
'should be in done'
);
// Verify completion timestamp added
const content = fs.readFileSync(
path.join(tmpDir, '.planning', 'todos', 'completed', 'add-dark-mode.md'),
path.join(tmpDir, '.planning', 'todos', 'done', 'add-dark-mode.md'),
'utf-8'
);
assert.ok(content.startsWith('completed:'), 'should have completed timestamp');
});
test('completes todo from pending directly', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(
path.join(pendingDir, 'fix-bug.md'),
`title: Fix bug\narea: api\ncreated: 2025-01-01\n`
);
const result = runGsdTools('todo complete fix-bug.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.completed, true);
// Verify moved to done (not completed)
assert.ok(
!fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'pending', 'fix-bug.md')),
'should be removed from pending'
);
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'done', 'fix-bug.md')),
'should be in done'
);
});
test('fails for nonexistent todo', () => {
const result = runGsdTools('todo complete nonexistent.md', tmpDir);
assert.ok(!result.success, 'should fail');

View File

@@ -19,7 +19,7 @@ Extract from init JSON: `commit_docs`, `date`, `timestamp`, `todo_count`, `todos
Ensure directories exist:
```bash
mkdir -p .planning/todos/pending .planning/todos/done
mkdir -p .planning/todos/pending .planning/todos/in-progress .planning/todos/done
```
Note existing areas from the todos array for consistency in infer_area step.

View File

@@ -105,7 +105,7 @@ Use AskUserQuestion:
- header: "Action"
- question: "This todo relates to Phase [N]: [name]. What would you like to do?"
- options:
- "Work on it now" — move to done, start working
- "Work on it now" — move to in-progress, start working
- "Add to phase plan" — include when planning Phase [N]
- "Brainstorm approach" — think through before deciding
- "Put it back" — return to list
@@ -116,7 +116,7 @@ Use AskUserQuestion:
- header: "Action"
- question: "What would you like to do with this todo?"
- options:
- "Work on it now" — move to done, start working
- "Work on it now" — move to in-progress, start working
- "Create a phase" — /gsd:add-phase with this scope
- "Brainstorm approach" — think through before deciding
- "Put it back" — return to list
@@ -125,7 +125,7 @@ Use AskUserQuestion:
<step name="execute_action">
**Work on it now:**
```bash
mv ".planning/todos/pending/[filename]" ".planning/todos/done/"
mv ".planning/todos/pending/[filename]" ".planning/todos/in-progress/"
```
Update STATE.md todo count. Present problem/solution context. Begin work or ask how to proceed.
@@ -150,11 +150,11 @@ Re-run `init todos` to get updated count, then update STATE.md "### Pending Todo
</step>
<step name="git_commit">
If todo was moved to done/, commit the change:
If todo was moved to in-progress/, commit the change:
```bash
git rm --cached .planning/todos/pending/[filename] 2>/dev/null || true
node ~/.claude/get-shit-done/bin/gsd-tools.js commit "docs: start work on todo - [title]" --files .planning/todos/done/[filename] .planning/STATE.md
node ~/.claude/get-shit-done/bin/gsd-tools.js commit "docs: start work on todo - [title]" --files .planning/todos/in-progress/[filename] .planning/STATE.md
```
Tool respects `commit_docs` config and gitignore automatically.
@@ -172,5 +172,5 @@ Confirm: "Committed: docs: start work on todo - [title]"
- [ ] Appropriate actions offered
- [ ] Selected action executed
- [ ] STATE.md updated if todo count changed
- [ ] Changes committed to git (if todo moved to done/)
- [ ] Changes committed to git (if todo moved to in-progress/)
</success_criteria>