Add in-progress state for todos instead of marking done immediately (#543)
feat: Add in-progress state for todos
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user