Skip to content

Commit d388522

Browse files
authored
Merge pull request #175 from kaanozhan/fix/spec-sync-initial-status
fix(specs): don't walk merged specs back from done when importing their tasks
2 parents 5610114 + af8285a commit d388522

2 files changed

Lines changed: 95 additions & 2 deletions

File tree

‎src/main/specManager.js‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -904,6 +904,30 @@ function getSpecFootprint(projectPath, slug) {
904904
return parseFootprintMarkdown(plan);
905905
}
906906

907+
// A task imported for the first time starts from what the spec itself
908+
// records, not from 'pending'. Specs are implemented in worktrees whose task
909+
// state lives in a tasks.json nobody commits, so a finished spec can arrive
910+
// by merge with its tasks.md but none of its tasks — and importing those as
911+
// pending walked it from `done` back to `tasks_generated`. The spec's own
912+
// files travel with it: a `done` phase means every task finished, and the
913+
// implement loop writes one `## T<n> — …` outcome entry per finished task.
914+
const OUTCOME_TASK_RE = /^##\s+(T\d{1,3})\b/gm;
915+
916+
function completedTaskIdsFromOutcome(content) {
917+
const ids = new Set();
918+
if (!content) return ids;
919+
for (const m of content.matchAll(OUTCOME_TASK_RE)) ids.add(m[1]);
920+
return ids;
921+
}
922+
923+
function initialTaskState(status, outcomeIds, taskId, now) {
924+
if (status && status.phase === 'done') {
925+
return { status: 'completed', completedAt: status.last_phase_at || status.updated_at || now };
926+
}
927+
if (outcomeIds.has(taskId)) return { status: 'completed', completedAt: now };
928+
return { status: 'pending', completedAt: null };
929+
}
930+
907931
function syncTasksFromMarkdown(projectPath, slug) {
908932
const tasksMdPath = path.join(getSpecDir(projectPath, slug), TASKS_FILE);
909933
const md = readFileSafe(tasksMdPath);
@@ -923,6 +947,10 @@ function syncTasksFromMarkdown(projectPath, slug) {
923947
const now = new Date().toISOString();
924948
const generatedIds = [];
925949
let added = 0, updated = 0, unchanged = 0;
950+
const specStatus = readStatus(projectPath, slug);
951+
const outcomeIds = completedTaskIdsFromOutcome(
952+
readFileSafe(path.join(getSpecDir(projectPath, slug), OUTCOME_FILE))
953+
);
926954

927955
for (const item of parsed) {
928956
const sourceMarker = `spec:${slug}:${item.taskId}`;
@@ -938,18 +966,19 @@ function syncTasksFromMarkdown(projectPath, slug) {
938966
unchanged++;
939967
}
940968
} else {
969+
const initial = initialTaskState(specStatus, outcomeIds, item.taskId, now);
941970
tasksData.tasks.push({
942971
id,
943972
title: item.description,
944973
description: '',
945974
source: sourceMarker,
946-
status: 'pending',
975+
status: initial.status,
947976
priority: 'medium',
948977
category: 'feature',
949978
context: `From spec: ${slug}`,
950979
createdAt: now,
951980
updatedAt: now,
952-
completedAt: null
981+
completedAt: initial.completedAt
953982
});
954983
tasksData.metadata = tasksData.metadata || {};
955984
tasksData.metadata.totalCreated = (tasksData.metadata.totalCreated || 0) + 1;

‎test/specTasksSync.test.js‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,3 +278,67 @@ test('two Frames arguing over one spec stop being answered', () => {
278278
assert.ok(corrected <= 3, `and then stops answering (corrected ${corrected} times)`);
279279
assert.equal(phaseNow(), 'done', 'the last write is the other Frame\'s, not ours');
280280
});
281+
282+
// ─── first import starts from what the spec records ─────────
283+
284+
function setStatus(fields) {
285+
const statusPath = path.join(specDir(), 'status.json');
286+
const s = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
287+
fs.writeFileSync(statusPath, JSON.stringify({ ...s, ...fields }, null, 2), 'utf8');
288+
}
289+
290+
test('a finished spec arriving by merge is imported completed, not walked back', () => {
291+
// Implemented in a worktree: its tasks were completed in a tasks.json nobody
292+
// commits, so main gets tasks.md and a `done` status.json but no tasks.
293+
// Importing them as pending used to rewind the spec to tasks_generated.
294+
writeStatus();
295+
writeTasksMd(NON_ASCENDING);
296+
setStatus({ phase: 'done', last_phase_at: '2026-10-08T13:29:41.859Z' });
297+
298+
specManager.syncTasksFromMarkdown(projectDir, SLUG);
299+
300+
const tasks = readSpecTasks();
301+
assert.equal(tasks.length, 6);
302+
assert.ok(tasks.every(t => t.status === 'completed'), 'every task imported completed');
303+
assert.ok(tasks.every(t => t.completedAt === '2026-10-08T13:29:41.859Z'), 'completedAt is when the spec finished');
304+
305+
specManager.reconcilePhase(projectDir, SLUG, tasksManager.loadTasks(projectDir));
306+
assert.equal(JSON.parse(fs.readFileSync(path.join(specDir(), 'status.json'), 'utf8')).phase, 'done');
307+
});
308+
309+
test('an unfinished spec imports the tasks its outcome.md records as completed', () => {
310+
writeStatus();
311+
writeTasksMd(NON_ASCENDING);
312+
setStatus({ phase: 'implementing' });
313+
fs.writeFileSync(path.join(specDir(), 'outcome.md'), [
314+
'## T01 — Expose the report path', '', 'Did it.', '', '---', '',
315+
'## T05 — Handle a missing report', '', 'Did it. Mentions T03 in passing.', '', '---', ''
316+
].join('\n'), 'utf8');
317+
318+
specManager.syncTasksFromMarkdown(projectDir, SLUG);
319+
320+
const status = (id) => readSpecTasks().find(t => t.source === `spec:${SLUG}:${id}`).status;
321+
assert.equal(status('T01'), 'completed');
322+
assert.equal(status('T05'), 'completed');
323+
assert.equal(status('T03'), 'pending', 'a task only mentioned in prose is not completed');
324+
assert.equal(status('T02'), 'pending');
325+
assert.equal(
326+
specManager.derivePhase(projectDir, SLUG, 'implementing', tasksManager.loadTasks(projectDir)),
327+
'implementing'
328+
);
329+
});
330+
331+
test('the initial state applies to first import only — user-set status still wins', () => {
332+
writeStatus();
333+
writeTasksMd(NON_ASCENDING);
334+
setStatus({ phase: 'done' });
335+
specManager.syncTasksFromMarkdown(projectDir, SLUG);
336+
337+
// The user reopens a task.
338+
const data = tasksManager.loadTasks(projectDir);
339+
data.tasks.find(t => t.source === `spec:${SLUG}:T03`).status = 'pending';
340+
tasksManager.saveTasks(projectDir, data);
341+
342+
specManager.syncTasksFromMarkdown(projectDir, SLUG);
343+
assert.equal(readSpecTasks().find(t => t.source === `spec:${SLUG}:T03`).status, 'pending');
344+
});

0 commit comments

Comments
 (0)