test(state-machine): add regression suite — 86 tests across 6 files (#3161) (#3162)

Comprehensive validation of the GSD state machine identified 7 HIGH, 14 MEDIUM,
and 16 LOW findings. This adds regression and integration tests covering:

Unit tests (49):
- Event replay idempotency (M4 lossy blocker replay, M5 duplicate evidence)
- Reconciliation edge cases (fork detection, entity keys, conflict detection)
- Completion hierarchy guards (vacuous truth, phantom parents, rollback fidelity)
- State derivation parity (ghost milestones, phase transitions, DB/FS consistency)
- Stuck detection coverage (all 3 rules + documented gap for 3-unit cycles)

Integration tests (37):
- Full happy-path lifecycle (pre-planning → complete)
- 12 completion guard edge cases with real handlers
- 7 reopen operations including H5 (no reopen-milestone exists)
- Phantom parent auto-creation (H6)
- State derivation consistency with live DB
- Event log integrity across operations
- M12: stale SUMMARY.md causes reconciler to override reopen

Closes #3161
This commit is contained in:
Jeremy McSpadden 2026-03-31 12:54:30 -05:00 committed by GitHub
parent fbb67f15f8
commit 1e89090136
6 changed files with 1884 additions and 0 deletions

View file

@ -0,0 +1,192 @@
// GSD State Machine Regression Tests — Completion Hierarchy & State Derivation (#3161)
import { describe, test, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import {
openDatabase,
closeDatabase,
insertMilestone,
insertSlice,
insertTask,
getTask,
getSlice,
getMilestone,
getSliceTasks,
updateTaskStatus,
updateSliceStatus,
} from "../gsd-db.ts";
import { isClosedStatus } from "../status-guards.ts";
// ─── Setup / Teardown ──────────────────────────────────────────────────────
beforeEach(() => {
openDatabase(":memory:");
});
afterEach(() => {
try { closeDatabase(); } catch { /* swallow */ }
});
// ═══════════════════════════════════════════════════════════════════════════
// Tests
// ═══════════════════════════════════════════════════════════════════════════
describe("completion-hierarchy-guards", () => {
// ─── Test 1: isClosedStatus ─────────────────────────────────────────────
test("isClosedStatus returns true for 'complete' and 'done'", () => {
assert.ok(isClosedStatus("complete"), "'complete' should be closed");
assert.ok(isClosedStatus("done"), "'done' should be closed");
assert.ok(!isClosedStatus("pending"), "'pending' should not be closed");
assert.ok(!isClosedStatus("in-progress"), "'in-progress' should not be closed");
assert.ok(!isClosedStatus("blocked"), "'blocked' should not be closed");
assert.ok(!isClosedStatus(""), "empty string should not be closed");
assert.ok(!isClosedStatus("active"), "'active' should not be closed");
});
// ─── Test 2: vacuous truth guard — slice with zero tasks ───────────────
test("cannot complete slice with zero tasks — vacuous truth guard", () => {
insertMilestone({ id: "M001" });
insertSlice({ id: "S01", milestoneId: "M001" });
const tasks = getSliceTasks("M001", "S01");
assert.equal(tasks.length, 0, "newly inserted slice has zero tasks");
// The guard: a slice with no tasks is not completable.
// isSliceComplete from state.ts: plan.tasks.length > 0 && every done.
// Here we replicate the DB-side equivalent: zero tasks means guard fires.
const isCompletable = tasks.length > 0 && tasks.every(t => isClosedStatus(t.status));
assert.equal(isCompletable, false, "vacuous truth guard: zero tasks → not completable");
});
// ─── Test 3: cannot complete slice with incomplete tasks ─────────────────
test("cannot complete slice with incomplete tasks", () => {
insertMilestone({ id: "M001" });
insertSlice({ id: "S01", milestoneId: "M001" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "done" });
insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", status: "pending" });
const tasks = getSliceTasks("M001", "S01");
assert.equal(tasks.length, 2, "slice has 2 tasks");
const incompleteTasks = tasks.filter(t => !isClosedStatus(t.status));
assert.equal(incompleteTasks.length, 1, "exactly one task is not closed");
assert.equal(incompleteTasks[0]?.id, "T02", "the incomplete task is T02");
assert.equal(incompleteTasks[0]?.status, "pending", "incomplete task status is 'pending'");
});
// ─── Test 4: phantom parent milestone and slice (H6) ────────────────────
test("task completion auto-creates phantom parent milestone and slice (H6)", () => {
// H6 finding: insertMilestone/insertSlice accept empty titles — phantom
// parents can be created without substantive content.
insertMilestone({ id: "M001" });
insertSlice({ id: "S01", milestoneId: "M001" });
const milestone = getMilestone("M001");
assert.ok(milestone !== null, "phantom milestone M001 should exist in DB");
assert.equal(milestone!.title, "", "phantom milestone has empty title by default");
const slice = getSlice("M001", "S01");
assert.ok(slice !== null, "phantom slice S01 should exist in DB");
assert.equal(slice!.title, "", "phantom slice has empty title by default");
// This documents the H6 finding: the DB allows phantom parents with
// no meaningful content, which can silently accept task completion calls.
});
// ─── Test 5: double task completion is detectable via isClosedStatus ────
test("double task completion is detectable via isClosedStatus", () => {
insertMilestone({ id: "M001" });
insertSlice({ id: "S01", milestoneId: "M001" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "done" });
const task = getTask("M001", "S01", "T01");
assert.ok(task !== null, "task T01 should exist");
assert.ok(
isClosedStatus(task!.status),
"isClosedStatus detects already-closed task — prevents double completion",
);
// The guard that prevents double completion: check isClosedStatus before
// calling updateTaskStatus again.
const wouldDoubleComplete = isClosedStatus(task!.status);
assert.ok(wouldDoubleComplete, "guard fires: task is already closed");
});
// ─── Test 6: updateSliceStatus rollback loses original status (M11) ─────
test("updateSliceStatus rollback goes to 'pending' not original status (M11)", () => {
insertMilestone({ id: "M001" });
// Insert with an explicit non-pending status to simulate an in-progress slice
insertSlice({ id: "S01", milestoneId: "M001", status: "pending" });
// Manually advance to "in_progress" equivalent via updateSliceStatus
updateSliceStatus("M001", "S01", "in_progress");
const afterProgress = getSlice("M001", "S01");
assert.equal(afterProgress!.status, "in_progress", "slice is in_progress after update");
// Simulate completion
updateSliceStatus("M001", "S01", "complete", new Date().toISOString());
const afterComplete = getSlice("M001", "S01");
assert.equal(afterComplete!.status, "complete", "slice is complete after completion");
// Simulate rollback — the DB only stores current status, not history.
// Rolling back means setting to "pending" — the original "in_progress" is lost.
updateSliceStatus("M001", "S01", "pending");
const afterRollback = getSlice("M001", "S01");
assert.equal(
afterRollback!.status,
"pending",
"M11: rollback sets status to 'pending', original 'in_progress' is lost",
);
// Document: there is no completed_at or status history to recover from.
// The rollback silently discards the in_progress state.
});
// ─── Test 7: milestone completion requires all slices closed ─────────────
test("milestone completion requires all slices closed", () => {
insertMilestone({ id: "M001" });
insertSlice({ id: "S01", milestoneId: "M001", status: "done" });
insertSlice({ id: "S02", milestoneId: "M001", status: "pending" });
const s01 = getSlice("M001", "S01");
const s02 = getSlice("M001", "S02");
assert.ok(s01 !== null, "S01 exists");
assert.ok(s02 !== null, "S02 exists");
const slices = [s01!, s02!];
const incompleteSlices = slices.filter(s => !isClosedStatus(s.status));
assert.ok(
incompleteSlices.length > 0,
"milestone is not completable — has incomplete slices",
);
assert.equal(incompleteSlices[0]?.id, "S02", "S02 is the incomplete slice");
assert.equal(incompleteSlices[0]?.status, "pending", "S02 status is 'pending'");
});
// ─── Test 8: closed parent blocks child completion ───────────────────────
test("closed parent blocks child completion", () => {
// Insert a milestone already in 'complete' state
insertMilestone({ id: "M001", status: "complete" });
insertSlice({ id: "S01", milestoneId: "M001" });
const milestone = getMilestone("M001");
assert.ok(milestone !== null, "milestone M001 exists");
assert.ok(
isClosedStatus(milestone!.status),
"parent milestone is closed — isClosedStatus returns true",
);
// The guard in complete-slice checks parent status via isClosedStatus.
// If isClosedStatus(milestone.status) === true, the child cannot be completed.
const parentIsClosed = isClosedStatus(milestone!.status);
assert.ok(parentIsClosed, "closed parent guard fires: milestone.status is 'complete'");
// Verify the slice itself is not yet closed
const slice = getSlice("M001", "S01");
assert.ok(slice !== null, "slice S01 exists");
assert.ok(!isClosedStatus(slice!.status), "slice S01 is not yet closed (parent is already closed)");
});
});

View file

@ -0,0 +1,140 @@
// GSD State Machine Regression Tests — Event Replay & Reconciliation (#3161)
import { describe, test, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import {
openDatabase,
closeDatabase,
insertMilestone,
insertSlice,
insertTask,
getTask,
updateTaskStatus,
insertVerificationEvidence,
upsertDecision,
} from "../gsd-db.ts";
import { extractEntityKey } from "../workflow-reconcile.ts";
// ─── Helpers ─────────────────────────────────────────────────────────────────
const MID = "M001";
const SID = "S01";
const TID = "T01";
const TS = new Date().toISOString();
function setupDb(): void {
openDatabase(":memory:");
insertMilestone({ id: MID, title: "Test Milestone" });
insertSlice({ id: SID, milestoneId: MID, title: "Test Slice" });
insertTask({ id: TID, sliceId: SID, milestoneId: MID, title: "Test Task" });
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("event-replay-idempotency", () => {
beforeEach(() => {
setupDb();
});
afterEach(() => {
closeDatabase();
});
test("updateTaskStatus is idempotent for complete_task replay", () => {
// Simulates replaying a complete_task event twice (e.g. crash recovery)
updateTaskStatus(MID, SID, TID, "done", TS);
updateTaskStatus(MID, SID, TID, "done", TS);
const task = getTask(MID, SID, TID);
assert.ok(task !== null, "task should exist after status update");
assert.equal(task!.status, "done", "status should be 'done' after double replay");
});
test("updateTaskStatus is idempotent for start_task replay", () => {
// Simulates replaying a start_task event twice
updateTaskStatus(MID, SID, TID, "in-progress");
updateTaskStatus(MID, SID, TID, "in-progress");
const task = getTask(MID, SID, TID);
assert.ok(task !== null, "task should exist after status update");
assert.equal(task!.status, "in-progress", "status should be 'in-progress' after double replay");
});
test("updateTaskStatus for report_blocker does not set blocker_discovered flag (M4)", () => {
// M4 finding: report_blocker replay only calls updateTaskStatus("blocked").
// The blocker_discovered column is NOT set during replay — this is a known
// lossy replay: status is recovered but the blocker flag is not.
updateTaskStatus(MID, SID, TID, "blocked");
const task = getTask(MID, SID, TID);
assert.ok(task !== null, "task should exist after blocked status update");
assert.equal(task!.status, "blocked", "status should be 'blocked'");
assert.equal(
task!.blocker_discovered,
false,
"blocker_discovered should remain false — report_blocker replay is lossy (M4 finding)",
);
});
test("insertVerificationEvidence is NOT idempotent — duplicates accumulate (M5)", () => {
// M5 finding: insertVerificationEvidence uses a plain INSERT (no ON CONFLICT),
// so replaying the same record_verification event twice produces two rows.
// Both calls must succeed without throwing — the duplication is the risk.
const evidence = {
taskId: TID,
sliceId: SID,
milestoneId: MID,
command: "npm test",
exitCode: 0,
verdict: "pass",
durationMs: 1200,
};
assert.doesNotThrow(
() => insertVerificationEvidence(evidence),
"first insertVerificationEvidence call should not throw",
);
assert.doesNotThrow(
() => insertVerificationEvidence(evidence),
"second insertVerificationEvidence call should not throw — duplicates accumulate silently (M5 finding)",
);
});
test("upsertDecision is idempotent via INSERT OR REPLACE", () => {
// save_decision replay uses upsertDecision which is INSERT OR REPLACE,
// so replaying the same decision id twice overwrites without error.
const base = {
id: "arch:logging",
when_context: "during planning",
scope: "arch",
decision: "logging",
rationale: "structured logs",
revisable: "yes" as const,
made_by: "agent" as const,
superseded_by: null,
};
upsertDecision({ ...base, choice: "structured" });
upsertDecision({ ...base, choice: "unstructured" });
// No error means the second call replaced the first — idempotent at the id level.
// The final choice is "unstructured" per INSERT OR REPLACE semantics.
});
test("unknown event commands in replayEvents are silently skipped — extractEntityKey returns null for unknown commands", () => {
// replayEvents uses a switch/default that silently skips unrecognised commands.
// We verify this via extractEntityKey which follows the same command set.
// A future_command not in the switch must return null (not throw).
const event = {
cmd: "future_command",
params: { foo: "bar" },
ts: new Date().toISOString(),
hash: "0000000000000000",
actor: "agent" as const,
session_id: "test-session",
};
const key = extractEntityKey(event);
assert.equal(key, null, "extractEntityKey should return null for unknown commands");
});
});

View file

@ -0,0 +1,959 @@
/**
* state-machine-live-validation.test.ts Live operational validation of the
* GSD state machine with real handlers, real DB, and real filesystem.
*
* Exercises every phase transition, completion guard, edge case, and reopen
* path end-to-end. This is NOT a unit test it drives the actual tool handlers
* against a real temp directory with a real SQLite database.
*
* Findings reference: #3161 (state machine validation report)
*/
// GSD State Machine Live Validation (#3161)
import { describe, test, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import {
mkdtempSync,
mkdirSync,
writeFileSync,
readFileSync,
rmSync,
existsSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// ── DB layer ──────────────────────────────────────────────────────────────
import {
openDatabase,
closeDatabase,
insertMilestone,
insertSlice,
insertTask,
getTask,
getSlice,
getMilestone,
getSliceTasks,
getMilestoneSlices,
updateTaskStatus,
updateSliceStatus,
updateMilestoneStatus,
} from "../../gsd-db.ts";
// ── Tool handlers ─────────────────────────────────────────────────────────
import { handleCompleteTask } from "../../tools/complete-task.ts";
import { handleCompleteSlice } from "../../tools/complete-slice.ts";
import { handleCompleteMilestone } from "../../tools/complete-milestone.ts";
import { handleReopenTask } from "../../tools/reopen-task.ts";
import { handleReopenSlice } from "../../tools/reopen-slice.ts";
// ── State derivation ──────────────────────────────────────────────────────
import {
deriveState,
deriveStateFromDb,
invalidateStateCache,
isGhostMilestone,
} from "../../state.ts";
// ── Status guards ─────────────────────────────────────────────────────────
import { isClosedStatus } from "../../status-guards.ts";
// ── Events ────────────────────────────────────────────────────────────────
import { readEvents } from "../../workflow-events.ts";
// ── Cache invalidation ───────────────────────────────────────────────────
import { invalidateAllCaches } from "../../cache.ts";
// ═══════════════════════════════════════════════════════════════════════════
// Fixture Helpers
// ═══════════════════════════════════════════════════════════════════════════
function makeTempDir(): string {
return mkdtempSync(join(tmpdir(), "gsd-live-validation-"));
}
/**
* Create a realistic .gsd/ fixture with:
* - M001 milestone with ROADMAP, CONTEXT
* - S01 slice with PLAN (2 tasks T01, T02)
* - S02 slice with PLAN (1 task T01)
* - Task PLAN stubs for each task
* - REQUIREMENTS.md and DECISIONS.md
*/
function createFullFixture(): string {
const base = makeTempDir();
const gsdDir = join(base, ".gsd");
const m001Dir = join(gsdDir, "milestones", "M001");
const s01Dir = join(m001Dir, "slices", "S01");
const s01Tasks = join(s01Dir, "tasks");
const s02Dir = join(m001Dir, "slices", "S02");
const s02Tasks = join(s02Dir, "tasks");
mkdirSync(s01Tasks, { recursive: true });
mkdirSync(s02Tasks, { recursive: true });
// CONTEXT.md — needed to get past needs-discussion
writeFileSync(
join(m001Dir, "M001-CONTEXT.md"),
[
"# M001: Live Validation Milestone",
"",
"## Purpose",
"Validate the state machine end-to-end.",
].join("\n"),
);
// ROADMAP.md
writeFileSync(
join(m001Dir, "M001-ROADMAP.md"),
[
"# M001: Live Validation Milestone",
"",
"## Vision",
"Prove state machine correctness.",
"",
"## Success Criteria",
"- All operations succeed",
"",
"## Slices",
"",
"- [ ] **S01: First Feature** `risk:low` `depends:[]`",
" - After this: First feature proven.",
"",
"- [ ] **S02: Second Feature** `risk:low` `depends:[]`",
" - After this: Second feature proven.",
"",
"## Boundary Map",
"",
"| From | To | Produces | Consumes |",
"|------|----|----------|----------|",
"| S01 | terminal | feature-a | nothing |",
"| S02 | terminal | feature-b | nothing |",
].join("\n"),
);
// S01 PLAN
writeFileSync(
join(s01Dir, "S01-PLAN.md"),
[
"# S01: First Feature",
"",
"**Goal:** Implement first feature.",
"",
"## Tasks",
"",
"- [ ] **T01: Implementation** `est:30m`",
" - Do: Build it",
" - Verify: Run tests",
"",
"- [ ] **T02: Testing** `est:30m`",
" - Do: Write tests",
" - Verify: Run tests",
].join("\n"),
);
// S01 task plan stubs
writeFileSync(join(s01Tasks, "T01-PLAN.md"), "# T01 Plan\nImplement.\n");
writeFileSync(join(s01Tasks, "T02-PLAN.md"), "# T02 Plan\nTest.\n");
// S02 PLAN
writeFileSync(
join(s02Dir, "S02-PLAN.md"),
[
"# S02: Second Feature",
"",
"**Goal:** Implement second feature.",
"",
"## Tasks",
"",
"- [ ] **T01: Implementation** `est:30m`",
" - Do: Build it",
" - Verify: Run tests",
].join("\n"),
);
// S02 task plan stub
writeFileSync(join(s02Tasks, "T01-PLAN.md"), "# T01 Plan\nBuild.\n");
// REQUIREMENTS.md
writeFileSync(
join(gsdDir, "REQUIREMENTS.md"),
[
"# Requirements",
"",
"## Active",
"",
"| ID | Description | Owner |",
"|----|-------------|-------|",
"| R001 | Feature works | S01 |",
].join("\n"),
);
// DECISIONS.md
writeFileSync(
join(gsdDir, "DECISIONS.md"),
[
"# Decisions",
"",
"| ID | Decision | Choice | Rationale |",
"|----|----------|--------|-----------|",
].join("\n"),
);
return base;
}
function makeTaskParams(
taskId: string,
sliceId: string,
milestoneId: string,
overrides?: Partial<Record<string, unknown>>,
): Record<string, unknown> {
return {
taskId,
sliceId,
milestoneId,
oneLiner: `Completed ${taskId}`,
narrative: `Implemented ${taskId} with full coverage.`,
verification: "All tests pass.",
keyFiles: ["src/feature.ts"],
keyDecisions: [],
deviations: "None.",
knownIssues: "None.",
blockerDiscovered: false,
verificationEvidence: [
{ command: "npm test", exitCode: 0, verdict: "pass", durationMs: 1000 },
],
...overrides,
};
}
function makeSliceParams(
sliceId: string,
milestoneId: string,
): Record<string, unknown> {
return {
sliceId,
milestoneId,
sliceTitle: `${sliceId} Feature`,
oneLiner: `${sliceId} proven`,
narrative: "All tasks completed.",
verification: "Tests pass.",
keyFiles: ["src/feature.ts"],
keyDecisions: [],
patternsEstablished: [],
observabilitySurfaces: [],
deviations: "None.",
knownLimitations: "None.",
followUps: "None.",
requirementsAdvanced: [],
requirementsValidated: [],
requirementsSurfaced: [],
requirementsInvalidated: [],
filesModified: [{ path: "src/feature.ts", description: "Feature" }],
uatContent: "Acceptance criteria met.",
provides: ["feature"],
requires: [],
affects: [],
drillDownPaths: [],
};
}
function makeMilestoneParams(milestoneId: string): Record<string, unknown> {
return {
milestoneId,
title: "Live Validation Milestone",
oneLiner: "Milestone proven end-to-end",
narrative: "All slices completed and verified.",
successCriteriaResults: "All criteria met.",
definitionOfDoneResults: "All items checked.",
requirementOutcomes: "All requirements satisfied.",
keyDecisions: ["Chose approach A"],
keyFiles: ["src/feature.ts"],
lessonsLearned: ["Integration testing is valuable"],
followUps: "None.",
deviations: "None.",
verificationPassed: true,
};
}
// ═══════════════════════════════════════════════════════════════════════════
// Test Suite
// ═══════════════════════════════════════════════════════════════════════════
describe("state-machine-live-validation", () => {
let base: string;
afterEach(() => {
closeDatabase();
if (base) rmSync(base, { recursive: true, force: true });
});
// ─────────────────────────────────────────────────────────────────────────
// PHASE 1: Full happy-path lifecycle
// ─────────────────────────────────────────────────────────────────────────
describe("happy path: full lifecycle M001 → complete", () => {
test("step 1: empty project derives pre-planning", async () => {
base = makeTempDir();
mkdirSync(join(base, ".gsd", "milestones"), { recursive: true });
const state = await deriveState(base);
assert.equal(state.phase, "pre-planning");
assert.equal(state.activeMilestone, null);
});
test("step 2: milestone with CONTEXT-DRAFT derives needs-discussion", async () => {
base = makeTempDir();
const mDir = join(base, ".gsd", "milestones", "M001");
mkdirSync(mDir, { recursive: true });
writeFileSync(join(mDir, "M001-CONTEXT-DRAFT.md"), "# Draft\nDraft context.\n");
invalidateStateCache();
const state = await deriveState(base);
assert.equal(state.phase, "needs-discussion");
assert.equal(state.activeMilestone?.id, "M001");
});
test("step 3: full fixture with ROADMAP+PLAN derives planning or executing", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
invalidateStateCache();
const state = await deriveState(base);
// Without DB migration, filesystem path is used — should be planning or executing
assert.ok(
["planning", "executing", "pre-planning"].includes(state.phase),
`expected planning/executing/pre-planning, got: ${state.phase}`,
);
});
test("step 4: complete T01 in S01 — handler succeeds, DB reflects completion", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
// Seed DB with hierarchy
insertMilestone({ id: "M001", title: "Live Validation", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", title: "First Feature", status: "in_progress" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", title: "Implementation", status: "pending" });
insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", title: "Testing", status: "pending" });
const result = await handleCompleteTask(makeTaskParams("T01", "S01", "M001") as any, base);
assert.ok(!("error" in result), `expected success, got: ${JSON.stringify(result)}`);
// Verify DB state
const task = getTask("M001", "S01", "T01");
assert.ok(task, "T01 should exist in DB");
assert.ok(isClosedStatus(task!.status), `T01 status should be closed, got: ${task!.status}`);
// Verify SUMMARY.md written to disk
const summaryPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks", "T01-SUMMARY.md");
assert.ok(existsSync(summaryPath), "T01-SUMMARY.md should exist on disk");
// Verify event log entry
const events = readEvents(join(base, ".gsd", "event-log.jsonl"));
const taskEvent = events.find(e => e.cmd === "complete-task" && (e.params as any).taskId === "T01");
assert.ok(taskEvent, "event log should contain complete-task for T01");
});
test("step 5: complete T02 in S01 — both tasks now done", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Live Validation", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", title: "First Feature", status: "in_progress" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", title: "Implementation", status: "complete" });
insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", title: "Testing", status: "pending" });
const result = await handleCompleteTask(makeTaskParams("T02", "S01", "M001") as any, base);
assert.ok(!("error" in result), `expected success, got: ${JSON.stringify(result)}`);
// Both tasks complete
const tasks = getSliceTasks("M001", "S01");
assert.equal(tasks.length, 2);
assert.ok(tasks.every(t => isClosedStatus(t.status)), "all tasks should be closed");
});
test("step 6: complete slice S01 — all tasks done, slice closes", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Live Validation", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", title: "First Feature", status: "in_progress" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", title: "Impl", status: "complete" });
insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", title: "Test", status: "complete" });
const result = await handleCompleteSlice(makeSliceParams("S01", "M001") as any, base);
assert.ok(!("error" in result), `expected success, got: ${JSON.stringify(result)}`);
const slice = getSlice("M001", "S01");
assert.ok(slice, "S01 should exist");
assert.ok(isClosedStatus(slice!.status), `S01 should be closed, got: ${slice!.status}`);
// SUMMARY.md on disk
const summaryPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md");
assert.ok(existsSync(summaryPath), "S01-SUMMARY.md should exist");
});
test("step 7: complete S02 task + slice — both slices done", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Live Validation", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete" });
insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "in_progress" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", title: "Impl", status: "complete" });
insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", title: "Impl", status: "pending" });
// Complete task
const taskResult = await handleCompleteTask(makeTaskParams("T01", "S02", "M001") as any, base);
assert.ok(!("error" in taskResult), `task: ${JSON.stringify(taskResult)}`);
// Complete slice
const sliceResult = await handleCompleteSlice(makeSliceParams("S02", "M001") as any, base);
assert.ok(!("error" in sliceResult), `slice: ${JSON.stringify(sliceResult)}`);
// Both slices complete
const slices = getMilestoneSlices("M001");
assert.ok(slices.length >= 2, "should have 2+ slices");
assert.ok(slices.every(s => isClosedStatus(s.status)), "all slices should be closed");
});
test("step 8: complete milestone M001 — full lifecycle done", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Live Validation", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete" });
insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "complete" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", title: "Impl", status: "complete" });
insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", title: "Test", status: "complete" });
insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", title: "Impl", status: "complete" });
const result = await handleCompleteMilestone(makeMilestoneParams("M001") as any, base);
assert.ok(!("error" in result), `expected success, got: ${JSON.stringify(result)}`);
const milestone = getMilestone("M001");
assert.ok(milestone, "M001 should exist");
assert.ok(isClosedStatus(milestone!.status), `M001 should be closed, got: ${milestone!.status}`);
// SUMMARY.md on disk
const summaryPath = join(base, ".gsd", "milestones", "M001", "M001-SUMMARY.md");
assert.ok(existsSync(summaryPath), "M001-SUMMARY.md should exist");
});
});
// ─────────────────────────────────────────────────────────────────────────
// PHASE 2: Completion guard edge cases
// ─────────────────────────────────────────────────────────────────────────
describe("completion guards — edge cases", () => {
test("cannot complete task with empty taskId", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
const result = await handleCompleteTask(makeTaskParams("", "S01", "M001") as any, base);
assert.ok("error" in result);
assert.match((result as any).error, /taskId is required/);
});
test("cannot complete task in closed milestone", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Done", status: "complete" });
insertSlice({ id: "S01", milestoneId: "M001" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
const result = await handleCompleteTask(makeTaskParams("T01", "S01", "M001") as any, base);
assert.ok("error" in result);
assert.match((result as any).error, /closed milestone/);
});
test("cannot complete task in closed slice", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", status: "complete" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
const result = await handleCompleteTask(makeTaskParams("T01", "S01", "M001") as any, base);
assert.ok("error" in result);
assert.match((result as any).error, /closed slice/);
});
test("double task completion returns error (H5-related)", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", status: "in_progress" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
const result = await handleCompleteTask(makeTaskParams("T01", "S01", "M001") as any, base);
assert.ok("error" in result);
assert.match((result as any).error, /already complete/);
});
test("cannot complete slice with zero tasks — vacuous truth guard", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", status: "in_progress" });
// No tasks inserted
const result = await handleCompleteSlice(makeSliceParams("S01", "M001") as any, base);
assert.ok("error" in result);
assert.match((result as any).error, /no tasks found/);
});
test("cannot complete slice with incomplete tasks", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", status: "in_progress" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", status: "pending" });
const result = await handleCompleteSlice(makeSliceParams("S01", "M001") as any, base);
assert.ok("error" in result);
assert.match((result as any).error, /incomplete tasks/);
});
test("double slice completion returns error", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", status: "complete" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
const result = await handleCompleteSlice(makeSliceParams("S01", "M001") as any, base);
assert.ok("error" in result);
assert.match((result as any).error, /already complete/);
});
test("cannot complete milestone with zero slices", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
const result = await handleCompleteMilestone(makeMilestoneParams("M001") as any, base);
assert.ok("error" in result);
assert.match((result as any).error, /no slices found/);
});
test("cannot complete milestone with incomplete slices", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", status: "complete" });
insertSlice({ id: "S02", milestoneId: "M001", status: "in_progress" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", status: "pending" });
const result = await handleCompleteMilestone(makeMilestoneParams("M001") as any, base);
assert.ok("error" in result);
assert.match((result as any).error, /incomplete slices/);
});
test("cannot complete milestone with incomplete tasks in complete slice (deep check)", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
// Slice marked complete but task is still pending — simulates inconsistent state
insertSlice({ id: "S01", milestoneId: "M001", status: "complete" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
const result = await handleCompleteMilestone(makeMilestoneParams("M001") as any, base);
assert.ok("error" in result);
assert.match((result as any).error, /incomplete tasks/);
});
test("cannot complete milestone without verificationPassed=true", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", status: "complete" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
const params = makeMilestoneParams("M001");
params.verificationPassed = false;
const result = await handleCompleteMilestone(params as any, base);
assert.ok("error" in result);
assert.match((result as any).error, /verification did not pass/);
});
test("double milestone completion returns error", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Done", status: "complete" });
insertSlice({ id: "S01", milestoneId: "M001", status: "complete" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
const result = await handleCompleteMilestone(makeMilestoneParams("M001") as any, base);
assert.ok("error" in result);
assert.match((result as any).error, /already complete/);
});
});
// ─────────────────────────────────────────────────────────────────────────
// PHASE 3: Reopen operations
// ─────────────────────────────────────────────────────────────────────────
describe("reopen operations", () => {
test("reopen task: resets completed task to pending", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", status: "in_progress" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
const result = await handleReopenTask(
{ milestoneId: "M001", sliceId: "S01", taskId: "T01", reason: "Need to redo" },
base,
);
assert.ok(!("error" in result), `expected success: ${JSON.stringify(result)}`);
const task = getTask("M001", "S01", "T01");
assert.equal(task!.status, "pending");
});
test("cannot reopen task that is not complete", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", status: "in_progress" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
const result = await handleReopenTask(
{ milestoneId: "M001", sliceId: "S01", taskId: "T01" },
base,
);
assert.ok("error" in result);
assert.match((result as any).error, /not complete/);
});
test("cannot reopen task in closed slice — must reopen slice first", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", status: "complete" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
const result = await handleReopenTask(
{ milestoneId: "M001", sliceId: "S01", taskId: "T01" },
base,
);
assert.ok("error" in result);
assert.match((result as any).error, /closed slice/);
});
test("cannot reopen task in closed milestone", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Done", status: "complete" });
insertSlice({ id: "S01", milestoneId: "M001", status: "complete" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
const result = await handleReopenTask(
{ milestoneId: "M001", sliceId: "S01", taskId: "T01" },
base,
);
assert.ok("error" in result);
assert.match((result as any).error, /closed milestone/);
});
test("reopen slice: resets slice to in_progress and all tasks to pending", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", status: "complete" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", status: "complete" });
const result = await handleReopenSlice(
{ milestoneId: "M001", sliceId: "S01", reason: "Need rework" },
base,
);
assert.ok(!("error" in result), `expected success: ${JSON.stringify(result)}`);
assert.equal((result as any).tasksReset, 2);
// Verify slice state
const slice = getSlice("M001", "S01");
assert.equal(slice!.status, "in_progress");
// Verify all tasks reset to pending
const tasks = getSliceTasks("M001", "S01");
assert.ok(tasks.every(t => t.status === "pending"), "all tasks should be pending after slice reopen");
});
test("cannot reopen slice in closed milestone", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Done", status: "complete" });
insertSlice({ id: "S01", milestoneId: "M001", status: "complete" });
const result = await handleReopenSlice(
{ milestoneId: "M001", sliceId: "S01" },
base,
);
assert.ok("error" in result);
assert.match((result as any).error, /closed milestone/);
});
test("no reopen-milestone tool exists — milestone completion is irrevocable (H5)", async () => {
// This test documents the H5 finding: there is no handleReopenMilestone function.
// A completed milestone can only be undone via direct DB manipulation.
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Done", status: "complete" });
const milestone = getMilestone("M001");
assert.ok(isClosedStatus(milestone!.status), "milestone is closed");
// The only escape is direct DB manipulation — no handler exists
updateMilestoneStatus("M001", "active", null);
const reopened = getMilestone("M001");
assert.equal(reopened!.status, "active", "direct DB manipulation can reopen, but no tool exposes this");
});
});
// ─────────────────────────────────────────────────────────────────────────
// PHASE 4: Phantom parents and auto-creation (H6)
// ─────────────────────────────────────────────────────────────────────────
describe("phantom parent auto-creation (H6)", () => {
test("completing task for non-existent milestone/slice auto-creates them", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
// No milestone or slice pre-inserted — handler will auto-create
const result = await handleCompleteTask(makeTaskParams("T01", "S99", "M099") as any, base);
assert.ok(!("error" in result), `expected success: ${JSON.stringify(result)}`);
// Phantom milestone created
const milestone = getMilestone("M099");
assert.ok(milestone, "phantom milestone M099 should exist");
assert.equal(milestone!.title, "", "phantom milestone has empty title");
// Phantom slice created
const slice = getSlice("M099", "S99");
assert.ok(slice, "phantom slice S99 should exist");
});
test("completing slice for non-existent milestone auto-creates it", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
// Insert task to satisfy completion guard
insertMilestone({ id: "M099" });
insertSlice({ id: "S99", milestoneId: "M099" });
insertTask({ id: "T01", sliceId: "S99", milestoneId: "M099", status: "complete" });
const result = await handleCompleteSlice(makeSliceParams("S99", "M099") as any, base);
assert.ok(!("error" in result), `expected success: ${JSON.stringify(result)}`);
});
});
// ─────────────────────────────────────────────────────────────────────────
// PHASE 5: State derivation consistency
// ─────────────────────────────────────────────────────────────────────────
describe("state derivation with live DB", () => {
test("deriveStateFromDb reflects task completion immediately", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "in_progress" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", status: "pending" });
invalidateStateCache();
const stateBefore = await deriveStateFromDb(base);
assert.equal(stateBefore.phase, "executing", `before: expected executing, got ${stateBefore.phase}`);
// Complete T01
updateTaskStatus("M001", "S01", "T01", "complete", new Date().toISOString());
invalidateStateCache();
const stateAfterT01 = await deriveStateFromDb(base);
// Still executing — T02 is pending
assert.equal(stateAfterT01.phase, "executing", `after T01: expected executing, got ${stateAfterT01.phase}`);
// Complete T02
updateTaskStatus("M001", "S01", "T02", "complete", new Date().toISOString());
invalidateStateCache();
const stateAfterT02 = await deriveStateFromDb(base);
// All tasks done → summarizing
assert.equal(stateAfterT02.phase, "summarizing", `after T02: expected summarizing, got ${stateAfterT02.phase}`);
});
test("deriveStateFromDb reflects slice completion → next slice or validating", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete" });
insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "in_progress" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", status: "pending" });
invalidateStateCache();
const state = await deriveStateFromDb(base);
// S01 done, S02 has pending task → executing
assert.equal(state.phase, "executing", `expected executing for S02, got ${state.phase}`);
assert.equal(state.activeSlice?.id, "S02", "active slice should be S02");
});
test("deriveStateFromDb with all slices done → validating-milestone", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete" });
insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "complete" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", status: "complete" });
invalidateStateCache();
const state = await deriveStateFromDb(base);
assert.equal(state.phase, "validating-milestone", `expected validating-milestone, got ${state.phase}`);
});
test("ghost milestone is skipped by deriveState", async () => {
base = makeTempDir();
const gsdDir = join(base, ".gsd", "milestones");
// M001 is ghost — empty dir
mkdirSync(join(gsdDir, "M001"), { recursive: true });
// M002 has content
mkdirSync(join(gsdDir, "M002"), { recursive: true });
writeFileSync(join(gsdDir, "M002", "M002-CONTEXT-DRAFT.md"), "# Draft\nContent.\n");
assert.ok(isGhostMilestone(base, "M001"), "M001 should be ghost");
assert.ok(!isGhostMilestone(base, "M002"), "M002 should not be ghost");
invalidateStateCache();
const state = await deriveState(base);
assert.equal(state.activeMilestone?.id, "M002", "should skip ghost M001 and use M002");
});
});
// ─────────────────────────────────────────────────────────────────────────
// PHASE 6: Event log integrity
// ─────────────────────────────────────────────────────────────────────────
describe("event log integrity across operations", () => {
test("full operation sequence produces correct event log", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "in_progress" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", status: "pending" });
// Complete T01
await handleCompleteTask(makeTaskParams("T01", "S01", "M001") as any, base);
// Complete T02
await handleCompleteTask(makeTaskParams("T02", "S01", "M001") as any, base);
// Complete S01
await handleCompleteSlice(makeSliceParams("S01", "M001") as any, base);
const events = readEvents(join(base, ".gsd", "event-log.jsonl"));
// Should have 3 events: 2 task completions + 1 slice completion
assert.ok(events.length >= 3, `expected ≥3 events, got ${events.length}`);
const taskEvents = events.filter(e => e.cmd === "complete-task");
assert.equal(taskEvents.length, 2, "2 task completion events");
const sliceEvents = events.filter(e => e.cmd === "complete-slice");
assert.equal(sliceEvents.length, 1, "1 slice completion event");
// Events are ordered chronologically
for (let i = 1; i < events.length; i++) {
assert.ok(
events[i]!.ts >= events[i - 1]!.ts,
`events should be chronologically ordered: ${events[i - 1]!.ts} <= ${events[i]!.ts}`,
);
}
// All events have hashes and session IDs
for (const event of events) {
assert.ok(event.hash, "event should have hash");
assert.ok(event.session_id, "event should have session_id");
}
});
test("reopen operations produce events", async () => {
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", status: "in_progress" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
await handleReopenTask(
{ milestoneId: "M001", sliceId: "S01", taskId: "T01", reason: "redo" },
base,
);
const events = readEvents(join(base, ".gsd", "event-log.jsonl"));
const reopenEvent = events.find(e => e.cmd === "reopen-task");
assert.ok(reopenEvent, "should have reopen-task event");
assert.equal((reopenEvent!.params as any).taskId, "T01");
assert.equal((reopenEvent!.params as any).reason, "redo");
});
});
// ─────────────────────────────────────────────────────────────────────────
// PHASE 7: Reopen-then-redo cycle
// ─────────────────────────────────────────────────────────────────────────
describe("reopen-then-redo cycle", () => {
test("complete → reopen → M12: stale SUMMARY causes immediate auto-reconcile", async () => {
// Finding M12: reopen-task does NOT delete the SUMMARY.md from disk.
// The reopen handler's own post-mutation hook calls renderAllProjections
// which triggers deriveStateFromDb, which sees the stale SUMMARY.md and
// auto-reconciles the task BACK to "complete" (#2514) within the same call.
//
// Result: the reopen is effectively a no-op when filesystem artifacts exist.
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "in_progress" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
// Complete — writes T01-SUMMARY.md to disk
const r1 = await handleCompleteTask(makeTaskParams("T01", "S01", "M001") as any, base);
assert.ok(!("error" in r1), `first complete: ${JSON.stringify(r1)}`);
const summaryPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks", "T01-SUMMARY.md");
assert.ok(existsSync(summaryPath), "SUMMARY.md exists after completion");
// Reopen — handler sets DB to "pending" in transaction, but post-mutation
// hook triggers reconciler which immediately sets it back to "complete"
const r2 = await handleReopenTask({ milestoneId: "M001", sliceId: "S01", taskId: "T01" }, base);
assert.ok(!("error" in r2), `reopen handler succeeded: ${JSON.stringify(r2)}`);
// M12: After reopen completes, DB shows "complete" not "pending" because
// the reconciler auto-corrected it from the stale SUMMARY.md
const task = getTask("M001", "S01", "T01");
assert.equal(task!.status, "complete", "M12: reconciler overrides reopen — task is back to complete");
assert.ok(existsSync(summaryPath), "M12: SUMMARY.md was never cleaned up");
});
test("complete slice → reopen → M12: reconciler overrides task reset via stale SUMMARY", async () => {
// Same M12 pattern at the slice level: reopen-slice resets all tasks to
// "pending" in DB, but task SUMMARY.md artifacts remain on disk. The
// reopen handler's post-mutation hook triggers reconciler which sees the
// stale artifacts and auto-corrects tasks back to "complete".
base = createFullFixture();
openDatabase(join(base, ".gsd", "gsd.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "in_progress" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
// Complete task + slice
await handleCompleteTask(makeTaskParams("T01", "S01", "M001") as any, base);
await handleCompleteSlice(makeSliceParams("S01", "M001") as any, base);
assert.ok(isClosedStatus(getSlice("M001", "S01")!.status));
// Reopen slice — transaction resets slice to in_progress and task to pending,
// but post-mutation hook triggers reconciler which sees stale SUMMARY.md
await handleReopenSlice({ milestoneId: "M001", sliceId: "S01" }, base);
// Slice status is correctly in_progress (no slice SUMMARY reconciliation)
assert.equal(getSlice("M001", "S01")!.status, "in_progress");
// M12: Task was reset to "pending" in the transaction, but reconciler
// already corrected it back to "complete" from the stale SUMMARY.md
const task = getTask("M001", "S01", "T01");
assert.equal(task!.status, "complete", "M12: reconciler overrides reopen — task back to complete");
});
});
});

View file

@ -0,0 +1,162 @@
// GSD State Machine Regression Tests — Event Replay & Reconciliation (#3161)
import { describe, test, afterEach } from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { findForkPoint, readEvents, appendEvent } from "../workflow-events.ts";
import type { WorkflowEvent } from "../workflow-events.ts";
import { extractEntityKey, detectConflicts } from "../workflow-reconcile.ts";
// ─── Helper: build a full WorkflowEvent from cmd + params ────────────────────
function makeEvent(cmd: string, params: Record<string, unknown>, ts?: string): WorkflowEvent {
const hash = createHash("sha256")
.update(JSON.stringify({ cmd, params }))
.digest("hex")
.slice(0, 16);
return { cmd, params, ts: ts ?? new Date().toISOString(), hash, actor: "agent", session_id: "test-session" };
}
// ─── Temp dir management ─────────────────────────────────────────────────────
const tempDirs: string[] = [];
function tempDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gsd-recon-test-"));
tempDirs.push(dir);
return dir;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
}
});
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("reconciliation-edge-cases", () => {
// findForkPoint
test("findForkPoint returns -1 for completely diverged logs", () => {
const eA = makeEvent("complete_task", { milestoneId: "M001", sliceId: "S01", taskId: "T01" });
const eB = makeEvent("complete_task", { milestoneId: "M001", sliceId: "S01", taskId: "T02" });
const logA: WorkflowEvent[] = [eA];
const logB: WorkflowEvent[] = [eB];
assert.equal(findForkPoint(logA, logB), -1, "completely diverged logs should return -1");
});
test("findForkPoint returns last index when one log is prefix of another", () => {
const e1 = makeEvent("start_task", { milestoneId: "M001", sliceId: "S01", taskId: "T01" });
const e2 = makeEvent("complete_task", { milestoneId: "M001", sliceId: "S01", taskId: "T01" });
const e3 = makeEvent("complete_slice", { milestoneId: "M001", sliceId: "S01" });
const logA: WorkflowEvent[] = [e1, e2];
const logB: WorkflowEvent[] = [e1, e2, e3];
assert.equal(findForkPoint(logA, logB), 1, "prefix log should fork at last shared index");
});
test("findForkPoint returns -1 for empty logs", () => {
assert.equal(findForkPoint([], []), -1, "two empty logs should return -1");
});
// extractEntityKey
test("extractEntityKey returns null for malformed events (missing taskId)", () => {
const event = makeEvent("complete_task", {});
// params has no taskId — should return null rather than return a bad key
assert.equal(extractEntityKey(event), null, "missing taskId should yield null entity key");
});
test("extractEntityKey returns null for unknown commands", () => {
const event = makeEvent("future_cmd", { foo: "bar" });
assert.equal(extractEntityKey(event), null, "unknown command should yield null entity key");
});
test("plan_slice and complete_slice use different entity types", () => {
const planEvent = makeEvent("plan_slice", { sliceId: "S01" });
const completeEvent = makeEvent("complete_slice", { sliceId: "S01" });
const planKey = extractEntityKey(planEvent);
const completeKey = extractEntityKey(completeEvent);
assert.ok(planKey !== null, "plan_slice should produce an entity key");
assert.ok(completeKey !== null, "complete_slice should produce an entity key");
assert.equal(planKey!.type, "slice_plan", "plan_slice entity type should be 'slice_plan'");
assert.equal(completeKey!.type, "slice", "complete_slice entity type should be 'slice'");
assert.notEqual(
planKey!.type,
completeKey!.type,
"plan_slice and complete_slice must map to different entity types",
);
});
// detectConflicts
test("detectConflicts finds no conflicts when entities do not overlap", () => {
const mainDiverged: WorkflowEvent[] = [
makeEvent("complete_task", { milestoneId: "M001", sliceId: "S01", taskId: "T01" }),
];
const wtDiverged: WorkflowEvent[] = [
makeEvent("complete_task", { milestoneId: "M001", sliceId: "S01", taskId: "T02" }),
];
const conflicts = detectConflicts(mainDiverged, wtDiverged);
assert.equal(conflicts.length, 0, "non-overlapping task edits should produce no conflicts");
});
test("detectConflicts flags conflict when both sides touch the same task", () => {
const mainDiverged: WorkflowEvent[] = [
makeEvent("start_task", { milestoneId: "M001", sliceId: "S01", taskId: "T01" }),
];
const wtDiverged: WorkflowEvent[] = [
makeEvent("complete_task", { milestoneId: "M001", sliceId: "S01", taskId: "T01" }),
];
const conflicts = detectConflicts(mainDiverged, wtDiverged);
assert.equal(conflicts.length, 1, "same task touched by both sides should produce exactly one conflict");
const conflict = conflicts[0]!;
assert.equal(conflict.entityType, "task", "conflict entityType should be 'task'");
assert.equal(conflict.entityId, "T01", "conflict entityId should be 'T01'");
});
test("detectConflicts ignores events with null entity keys", () => {
// Events with unknown commands produce null keys and must not cause false conflicts.
const mainDiverged: WorkflowEvent[] = [
makeEvent("unknown_future_cmd", { milestoneId: "M001" }),
];
const wtDiverged: WorkflowEvent[] = [
makeEvent("another_unknown_cmd", { milestoneId: "M001" }),
];
const conflicts = detectConflicts(mainDiverged, wtDiverged);
assert.equal(conflicts.length, 0, "unknown commands with null entity keys should not produce conflicts");
});
// appendEvent — filesystem creation
test("appendEvent creates event log if directory does not exist", () => {
const base = tempDir();
// Remove the .gsd directory if it somehow exists — appendEvent should create it.
const gsdDir = path.join(base, ".gsd");
if (fs.existsSync(gsdDir)) fs.rmSync(gsdDir, { recursive: true, force: true });
appendEvent(base, {
cmd: "complete_task",
params: { milestoneId: "M001", sliceId: "S01", taskId: "T01" },
ts: new Date().toISOString(),
actor: "agent",
});
const logPath = path.join(base, ".gsd", "event-log.jsonl");
assert.ok(fs.existsSync(logPath), "event-log.jsonl should be created by appendEvent");
const events = readEvents(logPath);
assert.equal(events.length, 1, "event log should contain exactly one event");
assert.equal(events[0]!.cmd, "complete_task", "persisted event should have the correct cmd");
});
});

View file

@ -0,0 +1,257 @@
// GSD State Machine Regression Tests — Completion Hierarchy & State Derivation (#3161)
import { describe, test, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { deriveState, isGhostMilestone, invalidateStateCache } from "../state.ts";
// ─── Fixture Helpers ───────────────────────────────────────────────────────
function createFixtureBase(): string {
const base = mkdtempSync(join(tmpdir(), "gsd-parity-test-"));
mkdirSync(join(base, ".gsd", "milestones"), { recursive: true });
return base;
}
function cleanup(base: string): void {
rmSync(base, { recursive: true, force: true });
}
function writeMilestoneFile(base: string, mid: string, suffix: string, content: string): void {
const dir = join(base, ".gsd", "milestones", mid);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${mid}-${suffix}.md`), content);
}
function writeMilestoneValidation(base: string, mid: string, verdict: string = "pass"): void {
const dir = join(base, ".gsd", "milestones", mid);
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, `${mid}-VALIDATION.md`),
`---\nverdict: ${verdict}\nremediation_round: 0\n---\n\n# Validation\nValidated.`,
);
}
// ─── Setup / Teardown ──────────────────────────────────────────────────────
beforeEach(() => {
invalidateStateCache();
});
afterEach(() => {
invalidateStateCache();
});
// ═══════════════════════════════════════════════════════════════════════════
// Tests
// ═══════════════════════════════════════════════════════════════════════════
describe("state-derivation-parity", () => {
// ─── Test 1: ghost milestone with only META.json ─────────────────────────
test("ghost milestone with only META.json is correctly detected", () => {
const base = createFixtureBase();
try {
const dir = join(base, ".gsd", "milestones", "M001");
mkdirSync(dir, { recursive: true });
// Write only META.json — no CONTEXT, CONTEXT-DRAFT, ROADMAP, or SUMMARY
writeFileSync(join(dir, "META.json"), JSON.stringify({ id: "M001", createdAt: new Date().toISOString() }));
assert.ok(
isGhostMilestone(base, "M001"),
"milestone with only META.json is a ghost",
);
} finally {
cleanup(base);
}
});
// ─── Test 2: non-ghost milestone with CONTEXT is not ghost ───────────────
test("non-ghost milestone with CONTEXT is not ghost", () => {
const base = createFixtureBase();
try {
writeMilestoneFile(base, "M001", "CONTEXT", "# M001 Context\n\nThis milestone has real content.");
assert.ok(
!isGhostMilestone(base, "M001"),
"milestone with CONTEXT.md is not a ghost",
);
} finally {
cleanup(base);
}
});
// ─── Test 3: empty milestones dir derives pre-planning phase ─────────────
test("empty milestones dir derives pre-planning phase", async () => {
const base = createFixtureBase();
try {
const state = await deriveState(base);
assert.equal(state.phase, "pre-planning", "empty milestones dir yields pre-planning phase");
assert.equal(state.activeMilestone, null, "no active milestone for empty dir");
assert.equal(state.activeSlice, null, "no active slice for empty dir");
assert.deepEqual(state.registry, [], "registry is empty for empty dir");
} finally {
cleanup(base);
}
});
// ─── Test 4: state includes blockers field for future blocked-phase detection ──
test("deriveState result always includes a defined phase and nextAction", async () => {
// Document that the state shape includes a `phase` string and `nextAction` string.
// Triggering "blocked" via filesystem alone requires circular dep setup which
// is outside the scope of these parity tests. Instead we verify the shape.
const base = createFixtureBase();
try {
// Provide a milestone with a ROADMAP that has a single incomplete slice
const dir = join(base, ".gsd", "milestones", "M001");
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, "M001-ROADMAP.md"),
`# M001: Test\n\n**Vision:** Parity check.\n\n## Slices\n\n- [ ] **S01: First Slice** \`risk:low\` \`depends:[]\`\n > After this: First slice done.\n`,
);
const state = await deriveState(base);
assert.ok(typeof state.phase === "string", "state.phase is a string");
assert.ok(typeof state.nextAction === "string", "state.nextAction is a string");
// The state object is the same shape regardless of phase — blockers would
// appear when the phase is "blocked". We document that the field may exist.
assert.ok("activeMilestone" in state, "state has activeMilestone field");
assert.ok("registry" in state, "state has registry field");
} finally {
cleanup(base);
}
});
// ─── Test 5: CONTEXT-DRAFT but no CONTEXT returns needs-discussion ────────
test("deriveState with CONTEXT-DRAFT but no CONTEXT returns needs-discussion", async () => {
const base = createFixtureBase();
try {
writeMilestoneFile(
base,
"M001",
"CONTEXT-DRAFT",
"# Draft Context\n\nSeed discussion material for M001.",
);
const state = await deriveState(base);
assert.equal(
state.phase,
"needs-discussion",
"CONTEXT-DRAFT with no CONTEXT yields needs-discussion phase",
);
assert.equal(state.activeMilestone?.id, "M001", "active milestone is M001");
assert.equal(state.activeSlice, null, "no active slice in needs-discussion phase");
} finally {
cleanup(base);
}
});
// ─── Test 6: deriveState skips ghost milestones when finding active milestone ──
test("deriveState skips ghost milestones when finding active milestone", async () => {
const base = createFixtureBase();
try {
// M001: ghost — just an empty directory
mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
// M002: has CONTEXT-DRAFT — should become active
writeMilestoneFile(
base,
"M002",
"CONTEXT-DRAFT",
"# Draft for M002\n\nThis is the real milestone.",
);
const state = await deriveState(base);
// M001 is a ghost so it is skipped; M002 becomes the active milestone
assert.equal(
state.activeMilestone?.id,
"M002",
"ghost M001 is skipped; M002 is the active milestone",
);
assert.equal(
state.phase,
"needs-discussion",
"phase is needs-discussion because M002 has only CONTEXT-DRAFT",
);
} finally {
cleanup(base);
}
});
// ─── Bonus: isGhostMilestone returns true for fully empty directory ───────
test("isGhostMilestone returns true for milestone directory with no files", () => {
const base = createFixtureBase();
try {
mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
// No files at all in the directory
assert.ok(
isGhostMilestone(base, "M001"),
"milestone directory with no files is a ghost",
);
} finally {
cleanup(base);
}
});
// ─── Bonus: isGhostMilestone returns false when ROADMAP exists ────────────
test("isGhostMilestone returns false when ROADMAP exists", () => {
const base = createFixtureBase();
try {
writeMilestoneFile(base, "M001", "ROADMAP", "# M001\n\n## Slices\n\n- [ ] **S01: First** `risk:low` `depends:[]`\n > After this: done.\n");
assert.ok(
!isGhostMilestone(base, "M001"),
"milestone with ROADMAP is not a ghost",
);
} finally {
cleanup(base);
}
});
// ─── Bonus: isGhostMilestone returns false when CONTEXT-DRAFT exists ──────
test("isGhostMilestone returns false when CONTEXT-DRAFT exists", () => {
const base = createFixtureBase();
try {
writeMilestoneFile(base, "M001", "CONTEXT-DRAFT", "# Draft\n\nSeed material.");
assert.ok(
!isGhostMilestone(base, "M001"),
"milestone with CONTEXT-DRAFT is not a ghost",
);
} finally {
cleanup(base);
}
});
// ─── Bonus: multiple ghost milestones before a real one are all skipped ───
test("deriveState skips multiple ghost milestones to find the first real one", async () => {
const base = createFixtureBase();
try {
// M001 and M002: ghosts
mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
mkdirSync(join(base, ".gsd", "milestones", "M002"), { recursive: true });
// M003: has CONTEXT-DRAFT — first real milestone
writeMilestoneFile(base, "M003", "CONTEXT-DRAFT", "# M003 Draft\n\nFirst substantive milestone.");
const state = await deriveState(base);
assert.equal(
state.activeMilestone?.id,
"M003",
"both ghost milestones skipped; M003 is active",
);
assert.equal(
state.phase,
"needs-discussion",
"phase is needs-discussion for M003 with CONTEXT-DRAFT",
);
} finally {
cleanup(base);
}
});
});

View file

@ -0,0 +1,174 @@
// GSD State Machine Regression Tests — Stuck Detection Coverage (#3161)
import test from "node:test";
import assert from "node:assert/strict";
import { detectStuck } from "../auto/detect-stuck.ts";
// ─── Baseline: window too small ──────────────────────────────────────────────
test("returns null for empty window", () => {
assert.equal(detectStuck([]), null);
});
test("returns null for single entry", () => {
assert.equal(detectStuck([{ key: "A" }]), null);
});
test("returns null for two different entries without errors", () => {
assert.equal(detectStuck([{ key: "A" }, { key: "B" }]), null);
});
// ─── Rule 1: Same error repeated consecutively ───────────────────────────────
test("Rule 1: same error twice consecutively triggers stuck", () => {
const result = detectStuck([
{ key: "A", error: "ENOENT: no such file" },
{ key: "A", error: "ENOENT: no such file" },
]);
assert.notEqual(result, null);
assert.equal(result!.stuck, true);
assert.ok(result!.reason.includes("Same error"), `reason was: ${result!.reason}`);
});
test("Rule 1: different errors do not trigger stuck", () => {
// Only 2 entries with different errors — Rule 2 needs 3 entries, so null.
const result = detectStuck([
{ key: "A", error: "err1" },
{ key: "A", error: "err2" },
]);
assert.equal(result, null);
});
test("Rule 1: only last two entries matter for error check", () => {
// First two share an error, but the last two have distinct errors — no trigger.
const result = detectStuck([
{ key: "A", error: "same-error" },
{ key: "A", error: "same-error" },
{ key: "B", error: "different-error-1" },
{ key: "C", error: "different-error-2" },
]);
assert.equal(result, null);
});
// ─── Rule 2: Same unit key 3+ consecutive times ───────────────────────────────
test("Rule 2: same unit key 3 consecutive times triggers stuck", () => {
const result = detectStuck([
{ key: "A" },
{ key: "A" },
{ key: "A" },
]);
assert.notEqual(result, null);
assert.equal(result!.stuck, true);
assert.ok(
result!.reason.includes("3 consecutive times"),
`reason was: ${result!.reason}`,
);
});
test("Rule 2: same key twice is not enough", () => {
assert.equal(detectStuck([{ key: "A" }, { key: "A" }]), null);
});
test("Rule 2: interrupted sequence does not trigger", () => {
// A, B, A — last three are not all the same key.
assert.equal(
detectStuck([{ key: "A" }, { key: "B" }, { key: "A" }]),
null,
);
});
// ─── Rule 3: Oscillation A→B→A→B ─────────────────────────────────────────────
test("Rule 3: A-B-A-B oscillation triggers stuck", () => {
const result = detectStuck([
{ key: "A" },
{ key: "B" },
{ key: "A" },
{ key: "B" },
]);
assert.notEqual(result, null);
assert.equal(result!.stuck, true);
assert.ok(
result!.reason.includes("Oscillation"),
`reason was: ${result!.reason}`,
);
});
test("Rule 3: A-B-A-C does not trigger oscillation", () => {
assert.equal(
detectStuck([{ key: "A" }, { key: "B" }, { key: "A" }, { key: "C" }]),
null,
);
});
test("Rule 3: A-A-A-A triggers Rule 2 not Rule 3", () => {
// Rule 2 fires first (last 3 are all the same key).
const result = detectStuck([
{ key: "A" },
{ key: "A" },
{ key: "A" },
{ key: "A" },
]);
assert.notEqual(result, null);
assert.equal(result!.stuck, true);
assert.ok(
result!.reason.includes("3 consecutive times"),
`expected Rule 2 reason but got: ${result!.reason}`,
);
assert.ok(
!result!.reason.includes("Oscillation"),
`unexpectedly matched Rule 3: ${result!.reason}`,
);
});
// ─── Gap documentation: 3-unit cycle evades detection ────────────────────────
test("Three-unit cycle A-B-C-A-B-C does NOT trigger stuck (documents gap L13)", () => {
// None of the three rules fires for a 3-unit repeating cycle.
// This test intentionally documents the coverage gap where such cycles
// slip through undetected (#3161).
const result = detectStuck([
{ key: "A" },
{ key: "B" },
{ key: "C" },
{ key: "A" },
{ key: "B" },
{ key: "C" },
]);
assert.equal(result, null);
});
// ─── Window boundary: earlier patterns do not contaminate recent check ─────────
test("window bounded: detection uses last N entries correctly", () => {
// The first three entries would trigger Rule 2, but the last entries are
// healthy — only the tail matters.
const result = detectStuck([
{ key: "X" },
{ key: "X" },
{ key: "X" }, // would be stuck if this were the end
{ key: "A" },
{ key: "B" }, // last two: different keys, no error
]);
assert.equal(result, null);
});
// ─── Rule priority: Rule 1 before Rule 2 ─────────────────────────────────────
test("Rule 1 takes priority over Rule 2 when both match", () => {
// Last 3 entries share the same key (Rule 2 candidate) AND last 2 share
// the same error (Rule 1 candidate). Rule 1 is evaluated first.
const result = detectStuck([
{ key: "A", error: "boom" },
{ key: "A", error: "boom" },
{ key: "A", error: "boom" },
]);
assert.notEqual(result, null);
assert.equal(result!.stuck, true);
assert.ok(
result!.reason.includes("Same error"),
`expected Rule 1 reason but got: ${result!.reason}`,
);
});