fix: reconcile stale task status in filesystem-based state derivation (#2514)

`_deriveStateImpl` (used when no gsd.db exists) lacked the SUMMARY-based
reconciliation added to `deriveStateFromDb` in 0e7a01f4. Heading-style
tasks (`### T01:`) are always parsed as `done=false` by `parsePlan`
because the heading syntax has no checkbox. When the agent writes a
SUMMARY file but the plan heading has no checkbox, the task appears
incomplete forever, causing infinite re-dispatch.

Now checks each non-done task for a SUMMARY file on disk after
`parsePlan()`, mirroring the DB reconciliation logic.

Root cause: `parsePlan()` recognizes two task formats:
1. `- [x] **T01: Title**` → done from checkbox state
2. `### T01: Title` → always done=false (no checkbox to read)

The DB path (deriveStateFromDb) was already fixed in 0e7a01f4 to
reconcile via SUMMARY files. This commit applies the same fix to
the filesystem path used by projects without gsd.db.
This commit is contained in:
ahwlsqja 2026-03-26 14:14:34 +09:00
parent c6328a229f
commit 43de82912e

View file

@ -1248,6 +1248,24 @@ export async function _deriveStateImpl(basePath: string): Promise<GSDState> {
}
const slicePlan = parsePlan(slicePlanContent);
// ── Reconcile stale task status for filesystem-based projects (#2514) ──
// Heading-style tasks (### T01:) are always parsed as done=false by
// parsePlan because the heading syntax has no checkbox. When the agent
// writes a SUMMARY file but the plan's heading isn't converted to a
// checkbox, the task appears incomplete forever — causing infinite
// re-dispatch. Reconcile by checking SUMMARY files on disk.
for (const t of slicePlan.tasks) {
if (t.done) continue;
const summaryPath = resolveTaskFile(basePath, activeMilestone.id, activeSlice.id, t.id, "SUMMARY");
if (summaryPath && existsSync(summaryPath)) {
t.done = true;
process.stderr.write(
`gsd-reconcile: task ${activeMilestone.id}/${activeSlice.id}/${t.id} has SUMMARY on disk but plan shows incomplete — marking done (#2514)\n`,
);
}
}
const taskProgress = {
done: slicePlan.tasks.filter(t => t.done).length,
total: slicePlan.tasks.length,