fix(doctor): fix roadmap checkbox and UAT stub immediately instead of deferring (#1819)

Remove all_tasks_done_missing_slice_uat and all_tasks_done_roadmap_not_checked
from COMPLETION_TRANSITION_CODES so they are fixed at task fixLevel. Only
all_tasks_done_missing_slice_summary remains deferred (requires LLM content).
This closes the fragile handoff window where a session crash between last task
and complete-slice left the project inconsistent.

Fixes #1808

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-21 14:38:13 -04:00 committed by GitHub
parent 800cff4bc0
commit b2fb12813f
3 changed files with 153 additions and 7 deletions

View file

@ -76,11 +76,14 @@ export type DoctorIssueCode =
* they are resolved by the complete-slice/complete-milestone dispatch units.
* Consumers (e.g. auto-post-unit health tracking) should exclude these from
* error counts when running at task fixLevel to avoid false escalation.
*
* Only the slice summary is deferred here because it requires LLM-generated
* content. Roadmap checkbox and UAT stub are mechanical bookkeeping and are
* fixed immediately to avoid inconsistent state if the session stops before
* complete-slice runs (#1808).
*/
export const COMPLETION_TRANSITION_CODES = new Set<DoctorIssueCode>([
"all_tasks_done_missing_slice_summary",
"all_tasks_done_missing_slice_uat",
"all_tasks_done_roadmap_not_checked",
]);
/**

View file

@ -0,0 +1,143 @@
/**
* Regression test for #1808: Completion-transition doctor fix deferral
* creates fragile handoff window.
*
* Only slice summary should be deferred (needs LLM content).
* Roadmap checkbox and UAT stub are mechanical bookkeeping and must be
* fixed immediately at task fixLevel to prevent inconsistent state if the
* session stops between last task and complete-slice.
*/
import { mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import test from "node:test";
import assert from "node:assert/strict";
import { runGSDDoctor } from "../doctor.ts";
import { COMPLETION_TRANSITION_CODES } from "../doctor-types.ts";
function makeTmp(name: string): string {
const dir = join(tmpdir(), `doctor-deferral-${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(dir, { recursive: true });
return dir;
}
/**
* Build a minimal .gsd structure: milestone with one slice, one task
* marked done with a summary but no slice summary, no UAT, and
* roadmap unchecked. This is the state after the last task completes.
*/
function buildScaffold(base: string) {
const gsd = join(base, ".gsd");
const m = join(gsd, "milestones", "M001");
const s = join(m, "slices", "S01", "tasks");
mkdirSync(s, { recursive: true });
writeFileSync(join(m, "M001-ROADMAP.md"), `# M001: Test
## Slices
- [ ] **S01: Test Slice** \`risk:low\` \`depends:[]\`
> Demo text
`);
writeFileSync(join(m, "slices", "S01", "S01-PLAN.md"), `# S01: Test Slice
**Goal:** test
## Tasks
- [x] **T01: Do stuff** \`est:5m\`
`);
writeFileSync(join(s, "T01-SUMMARY.md"), `---
id: T01
parent: S01
milestone: M001
duration: 5m
verification_result: passed
completed_at: 2026-01-01
---
# T01: Do stuff
Done.
`);
}
test("COMPLETION_TRANSITION_CODES only contains slice summary code", () => {
assert.ok(
COMPLETION_TRANSITION_CODES.has("all_tasks_done_missing_slice_summary"),
"summary code should still be deferred"
);
assert.ok(
!COMPLETION_TRANSITION_CODES.has("all_tasks_done_missing_slice_uat"),
"UAT code should NOT be deferred"
);
assert.ok(
!COMPLETION_TRANSITION_CODES.has("all_tasks_done_roadmap_not_checked"),
"roadmap code should NOT be deferred"
);
});
test("fixLevel:task — fixes roadmap checkbox and UAT stub immediately, defers only summary (#1808)", async () => {
const tmp = makeTmp("partial-deferral");
try {
buildScaffold(tmp);
const report = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" });
// Should detect all three issues
const codes = report.issues.map(i => i.code);
assert.ok(codes.includes("all_tasks_done_missing_slice_summary"), "should detect missing summary");
assert.ok(codes.includes("all_tasks_done_missing_slice_uat"), "should detect missing UAT");
assert.ok(codes.includes("all_tasks_done_roadmap_not_checked"), "should detect unchecked roadmap");
// Summary should NOT be created (still deferred — needs LLM content)
const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md");
assert.ok(!existsSync(sliceSummaryPath), "should NOT have created summary stub (deferred)");
// UAT stub SHOULD be created (mechanical bookkeeping, no longer deferred)
const sliceUatPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-UAT.md");
assert.ok(existsSync(sliceUatPath), "should have created UAT stub immediately");
// Roadmap checkbox SHOULD be marked done (mechanical bookkeeping, no longer deferred)
const roadmapContent = readFileSync(join(tmp, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "utf8");
assert.ok(roadmapContent.includes("- [x] **S01"), "roadmap should show S01 as checked");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("fixLevel:task — session crash after last task leaves roadmap and UAT consistent (#1808)", async () => {
const tmp = makeTmp("crash-consistency");
try {
buildScaffold(tmp);
// Simulate: doctor runs at task level (as auto-mode does after last task)
await runGSDDoctor(tmp, { fix: true, fixLevel: "task" });
// Now simulate a session crash — no complete-slice ever runs.
// A new session starts and runs doctor again at task level.
const report2 = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" });
// The only remaining issue should be the deferred summary.
// Roadmap and UAT should already be fixed from the first run.
const remainingCodes = report2.issues.map(i => i.code);
assert.ok(
!remainingCodes.includes("all_tasks_done_roadmap_not_checked"),
"roadmap should already be fixed from first doctor run"
);
assert.ok(
!remainingCodes.includes("all_tasks_done_missing_slice_uat"),
"UAT should already be fixed from first doctor run"
);
// Summary is still missing (deferred), that is expected
assert.ok(
remainingCodes.includes("all_tasks_done_missing_slice_summary"),
"summary should still be detected as missing (deferred)"
);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});

View file

@ -63,7 +63,7 @@ Done.
`);
}
test("fixLevel:task — detects completion issues but does NOT create summary stub or mark roadmap", async () => {
test("fixLevel:task — defers only summary stub, fixes roadmap and UAT immediately (#1808)", async () => {
const tmp = makeTmp("task-level");
try {
buildScaffold(tmp);
@ -75,17 +75,17 @@ test("fixLevel:task — detects completion issues but does NOT create summary st
assert.ok(codes.includes("all_tasks_done_missing_slice_summary"), "should detect missing summary");
assert.ok(codes.includes("all_tasks_done_roadmap_not_checked"), "should detect unchecked roadmap");
// Should NOT have fixed them
// Summary should NOT be created (still deferred — needs LLM content)
const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md");
assert.ok(!existsSync(sliceSummaryPath), "should NOT have created summary stub");
// Roadmap SHOULD be marked done (mechanical bookkeeping, no longer deferred)
const roadmapContent = readFileSync(join(tmp, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "utf8");
assert.ok(roadmapContent.includes("- [ ] **S01"), "roadmap should still show S01 as unchecked");
assert.ok(roadmapContent.includes("- [x] **S01"), "roadmap should show S01 as checked");
// Fixes applied should NOT include completion artifacts
// Fixes applied should NOT include summary but SHOULD include roadmap
for (const f of report.fixesApplied) {
assert.ok(!f.includes("SUMMARY"), `should not have fixed summary: ${f}`);
assert.ok(!f.includes("roadmap"), `should not have fixed roadmap: ${f}`);
}
} finally {
rmSync(tmp, { recursive: true, force: true });