refactor(test): replace try/finally with t.after() in gsd/tests (a-d) (#2395)

This commit is contained in:
Tom Boucher 2026-03-24 23:31:29 -04:00 committed by GitHub
parent 30775f4dcc
commit 2223298f76
23 changed files with 1857 additions and 2088 deletions

View file

@ -130,119 +130,119 @@ test("auto-loop 'all milestones complete' path merges before stopping (#962)", (
// ─── Integration: single milestone completes → merged to main ────────────────
test("single milestone worktree is merged to main when all complete (#962)", () => {
test("single milestone worktree is merged to main when all complete (#962)", (t) => {
const savedCwd = process.cwd();
let tempDir = "";
try {
tempDir = createTempRepo();
// Set up a single milestone
createMilestoneArtifacts(tempDir, "M001");
run("git add .", tempDir);
run('git commit -m "add milestone"', tempDir);
// Create worktree and simulate work
const wt = createAutoWorktree(tempDir, "M001");
assert.ok(isInAutoWorktree(tempDir), "should be in auto-worktree");
writeFileSync(join(wt, "feature.ts"), "export const feature = true;\n");
run("git add .", wt);
run('git commit -m "feat(M001): add feature"', wt);
// Simulate the fix: merge before stopping (what the "all complete" path now does)
const roadmapPath = join(
tempDir,
".gsd",
"milestones",
"M001",
"M001-ROADMAP.md",
);
const roadmapContent = readFileSync(roadmapPath, "utf-8");
const mergeResult = mergeMilestoneToMain(tempDir, "M001", roadmapContent);
// Verify work is on main
assert.ok(
existsSync(join(tempDir, "feature.ts")),
"feature.ts should be on main after merge",
);
assert.equal(process.cwd(), tempDir, "cwd restored to project root");
assert.ok(!isInAutoWorktree(tempDir), "no longer in auto-worktree");
assert.equal(getAutoWorktreeOriginalBase(), null, "originalBase cleared");
// Verify milestone branch was cleaned up
const branches = run("git branch", tempDir);
assert.ok(
!branches.includes("milestone/M001"),
"milestone branch should be deleted",
);
// Verify squash commit on main
const log = run("git log --oneline -3", tempDir);
assert.ok(
log.includes("M001"),
"squash commit on main should reference M001",
);
assert.ok(mergeResult.commitMessage.length > 0, "commit message returned");
} finally {
t.after(() => {
process.chdir(savedCwd);
if (tempDir && existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
rmSync(tempDir, { recursive: true, force: true });
}
}
});
tempDir = createTempRepo();
// Set up a single milestone
createMilestoneArtifacts(tempDir, "M001");
run("git add .", tempDir);
run('git commit -m "add milestone"', tempDir);
// Create worktree and simulate work
const wt = createAutoWorktree(tempDir, "M001");
assert.ok(isInAutoWorktree(tempDir), "should be in auto-worktree");
writeFileSync(join(wt, "feature.ts"), "export const feature = true;\n");
run("git add .", wt);
run('git commit -m "feat(M001): add feature"', wt);
// Simulate the fix: merge before stopping (what the "all complete" path now does)
const roadmapPath = join(
tempDir,
".gsd",
"milestones",
"M001",
"M001-ROADMAP.md",
);
const roadmapContent = readFileSync(roadmapPath, "utf-8");
const mergeResult = mergeMilestoneToMain(tempDir, "M001", roadmapContent);
// Verify work is on main
assert.ok(
existsSync(join(tempDir, "feature.ts")),
"feature.ts should be on main after merge",
);
assert.equal(process.cwd(), tempDir, "cwd restored to project root");
assert.ok(!isInAutoWorktree(tempDir), "no longer in auto-worktree");
assert.equal(getAutoWorktreeOriginalBase(), null, "originalBase cleared");
// Verify milestone branch was cleaned up
const branches = run("git branch", tempDir);
assert.ok(
!branches.includes("milestone/M001"),
"milestone branch should be deleted",
);
// Verify squash commit on main
const log = run("git log --oneline -3", tempDir);
assert.ok(
log.includes("M001"),
"squash commit on main should reference M001",
);
assert.ok(mergeResult.commitMessage.length > 0, "commit message returned");
});
// ─── Integration: last of multiple milestones completes → merged ─────────────
test("last milestone worktree is merged when it's the final one (#962)", () => {
test("last milestone worktree is merged when it's the final one (#962)", (t) => {
const savedCwd = process.cwd();
let tempDir = "";
try {
tempDir = createTempRepo();
// Set up two milestones
createMilestoneArtifacts(tempDir, "M001");
createMilestoneArtifacts(tempDir, "M002");
run("git add .", tempDir);
run('git commit -m "add milestones"', tempDir);
// Complete M001 first (merge it)
const wt1 = createAutoWorktree(tempDir, "M001");
writeFileSync(join(wt1, "m001-work.ts"), "export const m001 = true;\n");
run("git add .", wt1);
run('git commit -m "feat(M001): m001 work"', wt1);
const roadmap1 = readFileSync(
join(tempDir, ".gsd", "milestones", "M001", "M001-ROADMAP.md"),
"utf-8",
);
mergeMilestoneToMain(tempDir, "M001", roadmap1);
// Now complete M002 (the LAST milestone — this is the #962 scenario)
const wt2 = createAutoWorktree(tempDir, "M002");
writeFileSync(join(wt2, "m002-work.ts"), "export const m002 = true;\n");
run("git add .", wt2);
run('git commit -m "feat(M002): m002 work"', wt2);
const roadmap2 = readFileSync(
join(tempDir, ".gsd", "milestones", "M002", "M002-ROADMAP.md"),
"utf-8",
);
mergeMilestoneToMain(tempDir, "M002", roadmap2);
// Both features should now be on main
assert.ok(existsSync(join(tempDir, "m001-work.ts")), "M001 work on main");
assert.ok(existsSync(join(tempDir, "m002-work.ts")), "M002 work on main");
assert.ok(!isInAutoWorktree(tempDir), "not in worktree after final merge");
// Both milestone branches should be cleaned up
const branches = run("git branch", tempDir);
assert.ok(!branches.includes("milestone/M001"), "M001 branch deleted");
assert.ok(!branches.includes("milestone/M002"), "M002 branch deleted");
} finally {
t.after(() => {
process.chdir(savedCwd);
if (tempDir && existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
rmSync(tempDir, { recursive: true, force: true });
}
}
});
tempDir = createTempRepo();
// Set up two milestones
createMilestoneArtifacts(tempDir, "M001");
createMilestoneArtifacts(tempDir, "M002");
run("git add .", tempDir);
run('git commit -m "add milestones"', tempDir);
// Complete M001 first (merge it)
const wt1 = createAutoWorktree(tempDir, "M001");
writeFileSync(join(wt1, "m001-work.ts"), "export const m001 = true;\n");
run("git add .", wt1);
run('git commit -m "feat(M001): m001 work"', wt1);
const roadmap1 = readFileSync(
join(tempDir, ".gsd", "milestones", "M001", "M001-ROADMAP.md"),
"utf-8",
);
mergeMilestoneToMain(tempDir, "M001", roadmap1);
// Now complete M002 (the LAST milestone — this is the #962 scenario)
const wt2 = createAutoWorktree(tempDir, "M002");
writeFileSync(join(wt2, "m002-work.ts"), "export const m002 = true;\n");
run("git add .", wt2);
run('git commit -m "feat(M002): m002 work"', wt2);
const roadmap2 = readFileSync(
join(tempDir, ".gsd", "milestones", "M002", "M002-ROADMAP.md"),
"utf-8",
);
mergeMilestoneToMain(tempDir, "M002", roadmap2);
// Both features should now be on main
assert.ok(existsSync(join(tempDir, "m001-work.ts")), "M001 work on main");
assert.ok(existsSync(join(tempDir, "m002-work.ts")), "M002 work on main");
assert.ok(!isInAutoWorktree(tempDir), "not in worktree after final merge");
// Both milestone branches should be cleaned up
const branches = run("git branch", tempDir);
assert.ok(!branches.includes("milestone/M001"), "M001 branch deleted");
assert.ok(!branches.includes("milestone/M002"), "M002 branch deleted");
});

View file

@ -110,26 +110,24 @@ test("clearLock is safe when no lock file exists", () => {
rmSync(dir, { recursive: true, force: true });
});
test("bootstrap cleanup releases session lock artifacts", () => {
test("bootstrap cleanup releases session lock artifacts", (t) => {
const dir = mkdtempSync(join(tmpdir(), "gsd-lock-test-"));
mkdirSync(join(dir, ".gsd"), { recursive: true });
try {
const result = acquireSessionLock(dir);
assert.equal(result.acquired, true, "session lock should be acquired");
assert.ok(existsSync(join(dir, ".gsd", "auto.lock")), "auto.lock should exist while lock is held");
if (properLockfileAvailable) {
assert.ok(existsSync(join(dir, ".gsd.lock")), ".gsd.lock should exist while lock is held");
}
t.after(() => rmSync(dir, { recursive: true, force: true }));
releaseSessionLock(dir);
clearLock(dir);
assert.ok(!existsSync(join(dir, ".gsd", "auto.lock")), "auto.lock should be removed by bootstrap cleanup");
assert.ok(!existsSync(join(dir, ".gsd.lock")), ".gsd.lock should be removed by bootstrap cleanup");
} finally {
rmSync(dir, { recursive: true, force: true });
const result = acquireSessionLock(dir);
assert.equal(result.acquired, true, "session lock should be acquired");
assert.ok(existsSync(join(dir, ".gsd", "auto.lock")), "auto.lock should exist while lock is held");
if (properLockfileAvailable) {
assert.ok(existsSync(join(dir, ".gsd.lock")), ".gsd.lock should exist while lock is held");
}
releaseSessionLock(dir);
clearLock(dir);
assert.ok(!existsSync(join(dir, ".gsd", "auto.lock")), "auto.lock should be removed by bootstrap cleanup");
assert.ok(!existsSync(join(dir, ".gsd.lock")), ".gsd.lock should be removed by bootstrap cleanup");
});
// ─── isLockProcessAlive detects live vs dead PIDs ────────────────────────

View file

@ -51,93 +51,79 @@ function cleanup(base: string): void {
try { rmSync(base, { recursive: true, force: true }); } catch { /* */ }
}
test("resolveMilestonePath returns null for missing milestone", () => {
test("resolveMilestonePath returns null for missing milestone", (t) => {
const base = makeTmpBase();
mkdirSync(join(base, ".gsd", "milestones"), { recursive: true });
try {
const result = resolveMilestonePath(base, "M999");
assert.equal(result, null, "should return null for non-existent milestone");
} finally {
cleanup(base);
}
t.after(() => cleanup(base));
const result = resolveMilestonePath(base, "M999");
assert.equal(result, null, "should return null for non-existent milestone");
});
test("resolveMilestonePath returns path for existing milestone", () => {
test("resolveMilestonePath returns path for existing milestone", (t) => {
const base = makeTmpBase();
mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
try {
const result = resolveMilestonePath(base, "M001");
assert.ok(result, "should return a path for existing milestone");
assert.ok(result.includes("M001"), "path should contain the milestone ID");
} finally {
cleanup(base);
}
t.after(() => cleanup(base));
const result = resolveMilestonePath(base, "M001");
assert.ok(result, "should return a path for existing milestone");
assert.ok(result.includes("M001"), "path should contain the milestone ID");
});
test("resolveMilestoneFile returns null when no SUMMARY exists", () => {
test("resolveMilestoneFile returns null when no SUMMARY exists", (t) => {
const base = makeTmpBase();
mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
try {
const result = resolveMilestoneFile(base, "M001", "SUMMARY");
assert.equal(result, null, "should return null when no SUMMARY file");
} finally {
cleanup(base);
}
t.after(() => cleanup(base));
const result = resolveMilestoneFile(base, "M001", "SUMMARY");
assert.equal(result, null, "should return null when no SUMMARY file");
});
test("resolveMilestoneFile returns path when SUMMARY exists (completed)", () => {
test("resolveMilestoneFile returns path when SUMMARY exists (completed)", (t) => {
const base = makeTmpBase();
const mDir = join(base, ".gsd", "milestones", "M001");
mkdirSync(mDir, { recursive: true });
writeFileSync(join(mDir, "M001-SUMMARY.md"), "# Summary\nDone.");
try {
const result = resolveMilestoneFile(base, "M001", "SUMMARY");
assert.ok(result, "should return a path when SUMMARY exists");
assert.ok(result.includes("SUMMARY"), "path should reference SUMMARY");
} finally {
cleanup(base);
}
t.after(() => cleanup(base));
const result = resolveMilestoneFile(base, "M001", "SUMMARY");
assert.ok(result, "should return a path when SUMMARY exists");
assert.ok(result.includes("SUMMARY"), "path should reference SUMMARY");
});
// ─── Combined validation logic (mirrors auto.ts resume guard) ───────────────
test("stale milestone: missing dir means paused session should be discarded", () => {
test("stale milestone: missing dir means paused session should be discarded", (t) => {
const base = makeTmpBase();
mkdirSync(join(base, ".gsd", "milestones"), { recursive: true });
try {
const mDir = resolveMilestonePath(base, "M999");
const summaryFile = resolveMilestoneFile(base, "M999", "SUMMARY");
const isStale = !mDir || !!summaryFile;
assert.ok(isStale, "milestone that doesn't exist should be detected as stale");
} finally {
cleanup(base);
}
t.after(() => cleanup(base));
const mDir = resolveMilestonePath(base, "M999");
const summaryFile = resolveMilestoneFile(base, "M999", "SUMMARY");
const isStale = !mDir || !!summaryFile;
assert.ok(isStale, "milestone that doesn't exist should be detected as stale");
});
test("stale milestone: completed (has SUMMARY) means paused session should be discarded", () => {
test("stale milestone: completed (has SUMMARY) means paused session should be discarded", (t) => {
const base = makeTmpBase();
const mDir = join(base, ".gsd", "milestones", "M001");
mkdirSync(mDir, { recursive: true });
writeFileSync(join(mDir, "M001-SUMMARY.md"), "# Summary\nDone.");
try {
const dir = resolveMilestonePath(base, "M001");
const summaryFile = resolveMilestoneFile(base, "M001", "SUMMARY");
const isStale = !dir || !!summaryFile;
assert.ok(isStale, "milestone with SUMMARY should be detected as stale");
} finally {
cleanup(base);
}
t.after(() => cleanup(base));
const dir = resolveMilestonePath(base, "M001");
const summaryFile = resolveMilestoneFile(base, "M001", "SUMMARY");
const isStale = !dir || !!summaryFile;
assert.ok(isStale, "milestone with SUMMARY should be detected as stale");
});
test("valid milestone: exists and has no SUMMARY means paused session is valid", () => {
test("valid milestone: exists and has no SUMMARY means paused session is valid", (t) => {
const base = makeTmpBase();
mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
try {
const dir = resolveMilestonePath(base, "M001");
const summaryFile = resolveMilestoneFile(base, "M001", "SUMMARY");
const isStale = !dir || !!summaryFile;
assert.ok(!isStale, "active milestone should not be detected as stale");
} finally {
cleanup(base);
}
t.after(() => cleanup(base));
const dir = resolveMilestonePath(base, "M001");
const summaryFile = resolveMilestoneFile(base, "M001", "SUMMARY");
const isStale = !dir || !!summaryFile;
assert.ok(!isStale, "active milestone should not be detected as stale");
});

View file

@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
import { runGSDDoctor, selectDoctorScope, filterDoctorIssues } from "../doctor.js";
test("auto-preflight scopes to active milestone, ignoring historical", async () => {
test("auto-preflight scopes to active milestone, ignoring historical", async (t) => {
const tmpBase = mkdtempSync(join(tmpdir(), "gsd-auto-preflight-test-"));
const gsd = join(tmpBase, ".gsd");
@ -23,18 +23,16 @@ test("auto-preflight scopes to active milestone, ignoring historical", async ()
writeFileSync(join(gsd, "milestones", "M009", "M009-ROADMAP.md"), `# M009: Active\n\n## Slices\n- [ ] **S01: Active Slice** \`risk:low\` \`depends:[]\`\n > After this: active works\n`);
writeFileSync(join(gsd, "milestones", "M009", "slices", "S01", "S01-PLAN.md"), `# S01: Active Slice\n\n**Goal:** Active\n**Demo:** Active\n\n## Must-Haves\n- done\n\n## Tasks\n- [ ] **T01: Active Task** \`est:5m\`\n todo\n`);
try {
const scope = await selectDoctorScope(tmpBase);
assert.equal(scope, "M009/S01", "active scope selected instead of historical milestone");
t.after(() => rmSync(tmpBase, { recursive: true, force: true }));
const scopedReport = await runGSDDoctor(tmpBase, { fix: false, scope });
const scopedBlocking = filterDoctorIssues(scopedReport.issues, { scope, includeWarnings: false });
assert.equal(scopedBlocking.length, 0, "no blocking issues in active scope");
const scope = await selectDoctorScope(tmpBase);
assert.equal(scope, "M009/S01", "active scope selected instead of historical milestone");
const historicalReport = await runGSDDoctor(tmpBase, { fix: false });
const historicalWarnings = historicalReport.issues.filter(issue => issue.unitId.startsWith("M001/S01") && issue.severity === "warning");
assert.equal(historicalWarnings.length, 0, "completed historical milestone produces no checkbox/file-mismatch warnings");
} finally {
rmSync(tmpBase, { recursive: true, force: true });
}
const scopedReport = await runGSDDoctor(tmpBase, { fix: false, scope });
const scopedBlocking = filterDoctorIssues(scopedReport.issues, { scope, includeWarnings: false });
assert.equal(scopedBlocking.length, 0, "no blocking issues in active scope");
const historicalReport = await runGSDDoctor(tmpBase, { fix: false });
const historicalWarnings = historicalReport.issues.filter(issue => issue.unitId.startsWith("M001/S01") && issue.severity === "warning");
assert.equal(historicalWarnings.length, 0, "completed historical milestone produces no checkbox/file-mismatch warnings");
});

File diff suppressed because it is too large Load diff

View file

@ -43,31 +43,36 @@ function makeNoUICtx(cwd: string) {
// ─── Scenario 1: No manifest exists ──────────────────────────────────────────
test('secrets gate: no manifest exists — getManifestStatus returns null', async () => {
test('secrets gate: no manifest exists — getManifestStatus returns null', async (t) => {
const tmp = makeTempDir('gate-no-manifest');
try {
// No .gsd directory at all
const result = await getManifestStatus(tmp, 'M001');
assert.strictEqual(result, null, 'should return null when no manifest file exists');
} finally {
rmSync(tmp, { recursive: true, force: true });
}
t.after(() => rmSync(tmp, { recursive: true, force: true }));
// No .gsd directory at all
const result = await getManifestStatus(tmp, 'M001');
assert.strictEqual(result, null, 'should return null when no manifest file exists');
});
// ─── Scenario 2: Pending keys exist ─────────────────────────────────────────
test('secrets gate: pending keys exist — gate triggers collection, manifest updated on disk', async () => {
test('secrets gate: pending keys exist — gate triggers collection, manifest updated on disk', async (t) => {
const tmp = makeTempDir('gate-pending');
const savedA = process.env.GSD_GATE_TEST_EXISTING;
try {
// Simulate one key already in env
process.env.GSD_GATE_TEST_EXISTING = 'already-here';
// Ensure pending keys are NOT in env
t.after(() => {
delete process.env.GSD_GATE_TEST_EXISTING;
if (savedA !== undefined) process.env.GSD_GATE_TEST_EXISTING = savedA;
delete process.env.GSD_GATE_TEST_PEND_A;
delete process.env.GSD_GATE_TEST_PEND_B;
rmSync(tmp, { recursive: true, force: true });
});
writeManifest(tmp, `# Secrets Manifest
// Simulate one key already in env
process.env.GSD_GATE_TEST_EXISTING = 'already-here';
// Ensure pending keys are NOT in env
delete process.env.GSD_GATE_TEST_PEND_A;
delete process.env.GSD_GATE_TEST_PEND_B;
writeManifest(tmp, `# Secrets Manifest
**Milestone:** M001
**Generated:** 2025-06-20T10:00:00Z
@ -97,62 +102,60 @@ test('secrets gate: pending keys exist — gate triggers collection, manifest up
1. Already in env
`);
// (a) Verify getManifestStatus shows pending keys
const status = await getManifestStatus(tmp, 'M001');
assert.notStrictEqual(status, null, 'manifest should exist');
assert.ok(status!.pending.length > 0, 'should have pending keys');
assert.deepStrictEqual(status!.pending, ['GSD_GATE_TEST_PEND_A', 'GSD_GATE_TEST_PEND_B'], 'pending keys');
assert.deepStrictEqual(status!.existing, ['GSD_GATE_TEST_EXISTING'], 'existing keys');
// (a) Verify getManifestStatus shows pending keys
const status = await getManifestStatus(tmp, 'M001');
assert.notStrictEqual(status, null, 'manifest should exist');
assert.ok(status!.pending.length > 0, 'should have pending keys');
assert.deepStrictEqual(status!.pending, ['GSD_GATE_TEST_PEND_A', 'GSD_GATE_TEST_PEND_B'], 'pending keys');
assert.deepStrictEqual(status!.existing, ['GSD_GATE_TEST_EXISTING'], 'existing keys');
// (b) Call collectSecretsFromManifest with no-UI context
// With hasUI: false, collectOneSecret returns null → pending keys become "skipped"
const result = await collectSecretsFromManifest(tmp, 'M001', makeNoUICtx(tmp));
// (b) Call collectSecretsFromManifest with no-UI context
// With hasUI: false, collectOneSecret returns null → pending keys become "skipped"
const result = await collectSecretsFromManifest(tmp, 'M001', makeNoUICtx(tmp));
// (c) Verify return shape
assert.deepStrictEqual(result.applied, [], 'no keys applied (no UI to enter values)');
assert.ok(result.skipped.includes('GSD_GATE_TEST_PEND_A'), 'PEND_A should be skipped');
assert.ok(result.skipped.includes('GSD_GATE_TEST_PEND_B'), 'PEND_B should be skipped');
assert.deepStrictEqual(result.existingSkipped, ['GSD_GATE_TEST_EXISTING']);
// (c) Verify return shape
assert.deepStrictEqual(result.applied, [], 'no keys applied (no UI to enter values)');
assert.ok(result.skipped.includes('GSD_GATE_TEST_PEND_A'), 'PEND_A should be skipped');
assert.ok(result.skipped.includes('GSD_GATE_TEST_PEND_B'), 'PEND_B should be skipped');
assert.deepStrictEqual(result.existingSkipped, ['GSD_GATE_TEST_EXISTING']);
// (d) Verify manifest on disk was updated — pending entries that went through
// collection are now "skipped". The existing-in-env entry retains its manifest
// status ("pending") because collectSecretsFromManifest only updates entries
// that flow through collectOneSecret. At runtime, getManifestStatus overrides
// env-present entries to "existing" regardless of manifest status.
const manifestPath = join(tmp, '.gsd', 'milestones', 'M001', 'M001-SECRETS.md');
const updatedContent = readFileSync(manifestPath, 'utf8');
assert.ok(
updatedContent.includes('**Status:** skipped'),
'formerly-pending entries should now have status "skipped" in the manifest file',
);
// Count: PEND_A → skipped, PEND_B → skipped, EXISTING stays pending on disk
const skippedMatches = updatedContent.match(/\*\*Status:\*\* skipped/g);
assert.strictEqual(skippedMatches?.length, 2, 'two entries should have status "skipped"');
const pendingMatches = updatedContent.match(/\*\*Status:\*\* pending/g);
assert.strictEqual(pendingMatches?.length, 1, 'one entry (existing-in-env) retains pending on disk');
// (d) Verify manifest on disk was updated — pending entries that went through
// collection are now "skipped". The existing-in-env entry retains its manifest
// status ("pending") because collectSecretsFromManifest only updates entries
// that flow through collectOneSecret. At runtime, getManifestStatus overrides
// env-present entries to "existing" regardless of manifest status.
const manifestPath = join(tmp, '.gsd', 'milestones', 'M001', 'M001-SECRETS.md');
const updatedContent = readFileSync(manifestPath, 'utf8');
assert.ok(
updatedContent.includes('**Status:** skipped'),
'formerly-pending entries should now have status "skipped" in the manifest file',
);
// Count: PEND_A → skipped, PEND_B → skipped, EXISTING stays pending on disk
const skippedMatches = updatedContent.match(/\*\*Status:\*\* skipped/g);
assert.strictEqual(skippedMatches?.length, 2, 'two entries should have status "skipped"');
const pendingMatches = updatedContent.match(/\*\*Status:\*\* pending/g);
assert.strictEqual(pendingMatches?.length, 1, 'one entry (existing-in-env) retains pending on disk');
// (e) Verify getManifestStatus now shows no pending
const statusAfter = await getManifestStatus(tmp, 'M001');
assert.notStrictEqual(statusAfter, null);
assert.deepStrictEqual(statusAfter!.pending, [], 'no pending keys after collection');
} finally {
delete process.env.GSD_GATE_TEST_EXISTING;
if (savedA !== undefined) process.env.GSD_GATE_TEST_EXISTING = savedA;
delete process.env.GSD_GATE_TEST_PEND_A;
delete process.env.GSD_GATE_TEST_PEND_B;
rmSync(tmp, { recursive: true, force: true });
}
// (e) Verify getManifestStatus now shows no pending
const statusAfter = await getManifestStatus(tmp, 'M001');
assert.notStrictEqual(statusAfter, null);
assert.deepStrictEqual(statusAfter!.pending, [], 'no pending keys after collection');
});
// ─── Scenario 3: No pending keys — all collected or in env ──────────────────
test('secrets gate: no pending keys — getManifestStatus shows pending.length === 0', async () => {
test('secrets gate: no pending keys — getManifestStatus shows pending.length === 0', async (t) => {
const tmp = makeTempDir('gate-no-pending');
const savedKey = process.env.GSD_GATE_TEST_ENVKEY;
try {
process.env.GSD_GATE_TEST_ENVKEY = 'some-value';
t.after(() => {
delete process.env.GSD_GATE_TEST_ENVKEY;
if (savedKey !== undefined) process.env.GSD_GATE_TEST_ENVKEY = savedKey;
rmSync(tmp, { recursive: true, force: true });
});
writeManifest(tmp, `# Secrets Manifest
process.env.GSD_GATE_TEST_ENVKEY = 'some-value';
writeManifest(tmp, `# Secrets Manifest
**Milestone:** M001
**Generated:** 2025-06-20T10:00:00Z
@ -182,15 +185,10 @@ test('secrets gate: no pending keys — getManifestStatus shows pending.length =
1. In env already
`);
const result = await getManifestStatus(tmp, 'M001');
assert.notStrictEqual(result, null, 'manifest should exist');
assert.deepStrictEqual(result!.pending, [], 'no pending keys — gate would skip');
assert.deepStrictEqual(result!.collected, ['ALREADY_COLLECTED']);
assert.deepStrictEqual(result!.skipped, ['ALREADY_SKIPPED']);
assert.deepStrictEqual(result!.existing, ['GSD_GATE_TEST_ENVKEY']);
} finally {
delete process.env.GSD_GATE_TEST_ENVKEY;
if (savedKey !== undefined) process.env.GSD_GATE_TEST_ENVKEY = savedKey;
rmSync(tmp, { recursive: true, force: true });
}
const result = await getManifestStatus(tmp, 'M001');
assert.notStrictEqual(result, null, 'manifest should exist');
assert.deepStrictEqual(result!.pending, [], 'no pending keys — gate would skip');
assert.deepStrictEqual(result!.collected, ['ALREADY_COLLECTED']);
assert.deepStrictEqual(result!.skipped, ['ALREADY_SKIPPED']);
assert.deepStrictEqual(result!.existing, ['GSD_GATE_TEST_ENVKEY']);
});

View file

@ -36,176 +36,156 @@ function makeTempDir(prefix: string): string {
// ─── appendCapture ────────────────────────────────────────────────────────────
test("captures: appendCapture creates CAPTURES.md on first call", () => {
test("captures: appendCapture creates CAPTURES.md on first call", (t) => {
const tmp = makeTempDir("cap-create");
try {
const id = appendCapture(tmp, "first thought");
assert.ok(id.startsWith("CAP-"), "ID should start with CAP-");
assert.ok(
existsSync(join(tmp, ".gsd", "CAPTURES.md")),
"CAPTURES.md should exist",
);
const content = readFileSync(join(tmp, ".gsd", "CAPTURES.md"), "utf-8");
assert.ok(content.includes("# Captures"), "should have header");
assert.ok(content.includes(`### ${id}`), "should have entry heading");
assert.ok(
content.includes("**Text:** first thought"),
"should have text field",
);
assert.ok(
content.includes("**Status:** pending"),
"should have pending status",
);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
t.after(() => rmSync(tmp, { recursive: true, force: true }));
const id = appendCapture(tmp, "first thought");
assert.ok(id.startsWith("CAP-"), "ID should start with CAP-");
assert.ok(
existsSync(join(tmp, ".gsd", "CAPTURES.md")),
"CAPTURES.md should exist",
);
const content = readFileSync(join(tmp, ".gsd", "CAPTURES.md"), "utf-8");
assert.ok(content.includes("# Captures"), "should have header");
assert.ok(content.includes(`### ${id}`), "should have entry heading");
assert.ok(
content.includes("**Text:** first thought"),
"should have text field",
);
assert.ok(
content.includes("**Status:** pending"),
"should have pending status",
);
});
test("captures: appendCapture appends to existing file", () => {
test("captures: appendCapture appends to existing file", (t) => {
const tmp = makeTempDir("cap-append");
try {
const id1 = appendCapture(tmp, "thought one");
const id2 = appendCapture(tmp, "thought two");
assert.notStrictEqual(id1, id2, "IDs should be unique");
t.after(() => rmSync(tmp, { recursive: true, force: true }));
const content = readFileSync(join(tmp, ".gsd", "CAPTURES.md"), "utf-8");
assert.ok(content.includes(`### ${id1}`), "should have first entry");
assert.ok(content.includes(`### ${id2}`), "should have second entry");
assert.ok(
content.includes("**Text:** thought one"),
"should have first text",
);
assert.ok(
content.includes("**Text:** thought two"),
"should have second text",
);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
const id1 = appendCapture(tmp, "thought one");
const id2 = appendCapture(tmp, "thought two");
assert.notStrictEqual(id1, id2, "IDs should be unique");
const content = readFileSync(join(tmp, ".gsd", "CAPTURES.md"), "utf-8");
assert.ok(content.includes(`### ${id1}`), "should have first entry");
assert.ok(content.includes(`### ${id2}`), "should have second entry");
assert.ok(
content.includes("**Text:** thought one"),
"should have first text",
);
assert.ok(
content.includes("**Text:** thought two"),
"should have second text",
);
});
// ─── loadAllCaptures / loadPendingCaptures ────────────────────────────────────
test("captures: loadAllCaptures parses entries correctly", () => {
test("captures: loadAllCaptures parses entries correctly", (t) => {
const tmp = makeTempDir("cap-load");
try {
appendCapture(tmp, "alpha");
appendCapture(tmp, "beta");
t.after(() => rmSync(tmp, { recursive: true, force: true }));
const all = loadAllCaptures(tmp);
assert.strictEqual(all.length, 2, "should have 2 entries");
assert.strictEqual(all[0].text, "alpha");
assert.strictEqual(all[1].text, "beta");
assert.strictEqual(all[0].status, "pending");
assert.strictEqual(all[1].status, "pending");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
appendCapture(tmp, "alpha");
appendCapture(tmp, "beta");
const all = loadAllCaptures(tmp);
assert.strictEqual(all.length, 2, "should have 2 entries");
assert.strictEqual(all[0].text, "alpha");
assert.strictEqual(all[1].text, "beta");
assert.strictEqual(all[0].status, "pending");
assert.strictEqual(all[1].status, "pending");
});
test("captures: loadAllCaptures returns empty array when no file", () => {
test("captures: loadAllCaptures returns empty array when no file", (t) => {
const tmp = makeTempDir("cap-nofile");
try {
const all = loadAllCaptures(tmp);
assert.strictEqual(all.length, 0);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
t.after(() => rmSync(tmp, { recursive: true, force: true }));
const all = loadAllCaptures(tmp);
assert.strictEqual(all.length, 0);
});
test("captures: loadPendingCaptures filters resolved entries", () => {
test("captures: loadPendingCaptures filters resolved entries", (t) => {
const tmp = makeTempDir("cap-pending");
try {
const id1 = appendCapture(tmp, "pending one");
appendCapture(tmp, "pending two");
t.after(() => rmSync(tmp, { recursive: true, force: true }));
markCaptureResolved(tmp, id1, "note", "acknowledged", "just a note");
const id1 = appendCapture(tmp, "pending one");
appendCapture(tmp, "pending two");
const pending = loadPendingCaptures(tmp);
assert.strictEqual(pending.length, 1, "should have 1 pending");
assert.strictEqual(pending[0].text, "pending two");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
markCaptureResolved(tmp, id1, "note", "acknowledged", "just a note");
const pending = loadPendingCaptures(tmp);
assert.strictEqual(pending.length, 1, "should have 1 pending");
assert.strictEqual(pending[0].text, "pending two");
});
test("captures: loadAllCaptures preserves resolved entries", () => {
test("captures: loadAllCaptures preserves resolved entries", (t) => {
const tmp = makeTempDir("cap-all-resolved");
try {
const id1 = appendCapture(tmp, "pending one");
appendCapture(tmp, "pending two");
t.after(() => rmSync(tmp, { recursive: true, force: true }));
markCaptureResolved(tmp, id1, "note", "acknowledged", "just a note");
const id1 = appendCapture(tmp, "pending one");
appendCapture(tmp, "pending two");
const all = loadAllCaptures(tmp);
assert.strictEqual(all.length, 2, "all should still have 2");
assert.strictEqual(all[0].status, "resolved");
assert.strictEqual(all[1].status, "pending");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
markCaptureResolved(tmp, id1, "note", "acknowledged", "just a note");
const all = loadAllCaptures(tmp);
assert.strictEqual(all.length, 2, "all should still have 2");
assert.strictEqual(all[0].status, "resolved");
assert.strictEqual(all[1].status, "pending");
});
// ─── hasPendingCaptures ───────────────────────────────────────────────────────
test("captures: hasPendingCaptures returns false when no file", () => {
test("captures: hasPendingCaptures returns false when no file", (t) => {
const tmp = makeTempDir("cap-has-nofile");
try {
assert.strictEqual(hasPendingCaptures(tmp), false);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
t.after(() => rmSync(tmp, { recursive: true, force: true }));
assert.strictEqual(hasPendingCaptures(tmp), false);
});
test("captures: hasPendingCaptures returns true with pending entries", () => {
test("captures: hasPendingCaptures returns true with pending entries", (t) => {
const tmp = makeTempDir("cap-has-true");
try {
appendCapture(tmp, "something");
assert.strictEqual(hasPendingCaptures(tmp), true);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
t.after(() => rmSync(tmp, { recursive: true, force: true }));
appendCapture(tmp, "something");
assert.strictEqual(hasPendingCaptures(tmp), true);
});
test("captures: hasPendingCaptures returns false when all resolved", () => {
test("captures: hasPendingCaptures returns false when all resolved", (t) => {
const tmp = makeTempDir("cap-has-false");
try {
const id = appendCapture(tmp, "will resolve");
markCaptureResolved(tmp, id, "note", "done", "resolved it");
assert.strictEqual(hasPendingCaptures(tmp), false);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
t.after(() => rmSync(tmp, { recursive: true, force: true }));
const id = appendCapture(tmp, "will resolve");
markCaptureResolved(tmp, id, "note", "done", "resolved it");
assert.strictEqual(hasPendingCaptures(tmp), false);
});
// ─── markCaptureResolved ──────────────────────────────────────────────────────
test("captures: markCaptureResolved updates entry in place", () => {
test("captures: markCaptureResolved updates entry in place", (t) => {
const tmp = makeTempDir("cap-resolve");
try {
const id1 = appendCapture(tmp, "keep pending");
const id2 = appendCapture(tmp, "will resolve");
appendCapture(tmp, "also pending");
t.after(() => rmSync(tmp, { recursive: true, force: true }));
markCaptureResolved(tmp, id2, "quick-task", "executed inline", "small fix");
const id1 = appendCapture(tmp, "keep pending");
const id2 = appendCapture(tmp, "will resolve");
appendCapture(tmp, "also pending");
const all = loadAllCaptures(tmp);
assert.strictEqual(all.length, 3, "should still have 3 entries");
markCaptureResolved(tmp, id2, "quick-task", "executed inline", "small fix");
const resolved = all.find((c) => c.id === id2)!;
assert.strictEqual(resolved.status, "resolved");
assert.strictEqual(resolved.classification, "quick-task");
assert.strictEqual(resolved.resolution, "executed inline");
assert.strictEqual(resolved.rationale, "small fix");
assert.ok(resolved.resolvedAt, "should have resolved timestamp");
const all = loadAllCaptures(tmp);
assert.strictEqual(all.length, 3, "should still have 3 entries");
// Others should be unaffected
const kept = all.find((c) => c.id === id1)!;
assert.strictEqual(kept.status, "pending");
assert.strictEqual(kept.classification, undefined);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
const resolved = all.find((c) => c.id === id2)!;
assert.strictEqual(resolved.status, "resolved");
assert.strictEqual(resolved.classification, "quick-task");
assert.strictEqual(resolved.resolution, "executed inline");
assert.strictEqual(resolved.rationale, "small fix");
assert.ok(resolved.resolvedAt, "should have resolved timestamp");
// Others should be unaffected
const kept = all.find((c) => c.id === id1)!;
assert.strictEqual(kept.status, "pending");
assert.strictEqual(kept.classification, undefined);
});
// ─── resolveCapturesPath ──────────────────────────────────────────────────────
@ -371,58 +351,50 @@ test("triage: parseTriageOutput handles all five classification types", () => {
// ─── Edge Cases ───────────────────────────────────────────────────────────────
test("captures: appendCapture handles special characters in text", () => {
test("captures: appendCapture handles special characters in text", (t) => {
const tmp = makeTempDir("cap-special");
try {
const id = appendCapture(tmp, 'text with "quotes" and **bold** and `code`');
const all = loadAllCaptures(tmp);
assert.strictEqual(all.length, 1);
assert.ok(all[0].text.includes('"quotes"'), "should preserve quotes");
assert.ok(all[0].text.includes("**bold**"), "should preserve bold");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
t.after(() => rmSync(tmp, { recursive: true, force: true }));
const id = appendCapture(tmp, 'text with "quotes" and **bold** and `code`');
const all = loadAllCaptures(tmp);
assert.strictEqual(all.length, 1);
assert.ok(all[0].text.includes('"quotes"'), "should preserve quotes");
assert.ok(all[0].text.includes("**bold**"), "should preserve bold");
});
test("captures: markCaptureResolved is no-op for non-existent ID", () => {
test("captures: markCaptureResolved is no-op for non-existent ID", (t) => {
const tmp = makeTempDir("cap-noop");
try {
appendCapture(tmp, "real capture");
// Should not throw
markCaptureResolved(tmp, "CAP-nonexistent", "note", "test", "test");
const all = loadAllCaptures(tmp);
assert.strictEqual(all.length, 1);
assert.strictEqual(all[0].status, "pending", "original should be unchanged");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
t.after(() => rmSync(tmp, { recursive: true, force: true }));
appendCapture(tmp, "real capture");
// Should not throw
markCaptureResolved(tmp, "CAP-nonexistent", "note", "test", "test");
const all = loadAllCaptures(tmp);
assert.strictEqual(all.length, 1);
assert.strictEqual(all[0].status, "pending", "original should be unchanged");
});
test("captures: markCaptureResolved is no-op when no file exists", () => {
test("captures: markCaptureResolved is no-op when no file exists", (t) => {
const tmp = makeTempDir("cap-nofile-resolve");
try {
// Should not throw
markCaptureResolved(tmp, "CAP-abc", "note", "test", "test");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
t.after(() => rmSync(tmp, { recursive: true, force: true }));
// Should not throw
markCaptureResolved(tmp, "CAP-abc", "note", "test", "test");
});
test("captures: re-resolving a capture overwrites previous resolution", () => {
test("captures: re-resolving a capture overwrites previous resolution", (t) => {
const tmp = makeTempDir("cap-reresolve");
try {
const id = appendCapture(tmp, "will re-resolve");
markCaptureResolved(tmp, id, "note", "first resolution", "first rationale");
markCaptureResolved(tmp, id, "inject", "second resolution", "second rationale");
t.after(() => rmSync(tmp, { recursive: true, force: true }));
const all = loadAllCaptures(tmp);
assert.strictEqual(all.length, 1);
assert.strictEqual(all[0].classification, "inject", "should have updated classification");
assert.strictEqual(all[0].resolution, "second resolution");
assert.strictEqual(all[0].rationale, "second rationale");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
const id = appendCapture(tmp, "will re-resolve");
markCaptureResolved(tmp, id, "note", "first resolution", "first rationale");
markCaptureResolved(tmp, id, "inject", "second resolution", "second rationale");
const all = loadAllCaptures(tmp);
assert.strictEqual(all.length, 1);
assert.strictEqual(all[0].classification, "inject", "should have updated classification");
assert.strictEqual(all[0].resolution, "second resolution");
assert.strictEqual(all[0].rationale, "second rationale");
});
test("triage: parseTriageOutput preserves affectedFiles and targetSlice", () => {

View file

@ -8,7 +8,6 @@
* `/plugin marketplace add ...` source model.
*/
import { describe, it, before, after, mock } from 'node:test';
import assert from 'node:assert';
import { existsSync, mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs';
@ -306,45 +305,45 @@ describe(
});
});
it('should not persist marketplace agent directories into package sources', async () => {
it('should not persist marketplace agent directories into package sources', async (t) => {
const isolatedAgentDir = join(tempDir, '.gsd', 'agent');
const settingsPath = join(isolatedAgentDir, 'settings.json');
rmSync(isolatedAgentDir, { recursive: true, force: true });
process.env.GSD_CODING_AGENT_DIR = isolatedAgentDir;
try {
mkdirSync(isolatedAgentDir, { recursive: true });
const tempSettings: Record<string, unknown> = { packages: [] };
writeFileSync(settingsPath, JSON.stringify(tempSettings, null, 2));
const { ctx } = createMockContext([
'Plugins only',
'Yes - discover plugins and select components',
'Import all components',
'Yes, continue',
]);
const readPrefs = () => ({ ...prefs });
const writePrefs = async (p: Record<string, unknown>) => {
Object.assign(prefs, p);
};
await runClaudeImportFlow(ctx, 'global', readPrefs, writePrefs);
const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as { packages?: unknown[] };
const packageEntries = Array.isArray(settings.packages) ? settings.packages : [];
const hasAgentsDirPackage = packageEntries.some((entry) => {
const source = typeof entry === 'string'
? entry
: (entry && typeof entry === 'object' ? (entry as { source?: unknown }).source : undefined);
return typeof source === 'string' && source.endsWith('/agents');
});
assert.strictEqual(hasAgentsDirPackage, false, 'Marketplace agent directories should not be persisted as package sources');
} finally {
t.after(() => {
delete process.env.GSD_CODING_AGENT_DIR;
rmSync(isolatedAgentDir, { recursive: true, force: true });
}
});
mkdirSync(isolatedAgentDir, { recursive: true });
const tempSettings: Record<string, unknown> = { packages: [] };
writeFileSync(settingsPath, JSON.stringify(tempSettings, null, 2));
const { ctx } = createMockContext([
'Plugins only',
'Yes - discover plugins and select components',
'Import all components',
'Yes, continue',
]);
const readPrefs = () => ({ ...prefs });
const writePrefs = async (p: Record<string, unknown>) => {
Object.assign(prefs, p);
};
await runClaudeImportFlow(ctx, 'global', readPrefs, writePrefs);
const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as { packages?: unknown[] };
const packageEntries = Array.isArray(settings.packages) ? settings.packages : [];
const hasAgentsDirPackage = packageEntries.some((entry) => {
const source = typeof entry === 'string'
? entry
: (entry && typeof entry === 'object' ? (entry as { source?: unknown }).source : undefined);
return typeof source === 'string' && source.endsWith('/agents');
});
assert.strictEqual(hasAgentsDirPackage, false, 'Marketplace agent directories should not be persisted as package sources');
});
});
}

View file

@ -91,142 +91,140 @@ async function loadGuidanceExport(): Promise<{ collectOneSecretWithGuidance: Fun
// ─── collectSecretsFromManifest: categorization ───────────────────────────────
test("collectSecretsFromManifest: categorizes entries — pending keys need collection, existing keys are skipped", async () => {
test("collectSecretsFromManifest: categorizes entries — pending keys need collection, existing keys are skipped", async (t) => {
const { collectSecretsFromManifest } = await loadOrchestrator();
const tmp = makeTempDir("manifest-collect");
const savedA = process.env.EXISTING_KEY_A;
try {
process.env.EXISTING_KEY_A = "already-set";
const manifest = makeManifest([
{ key: "EXISTING_KEY_A", status: "pending" },
{ key: "PENDING_KEY_B", status: "pending", guidance: ["Step 1: Go to dashboard", "Step 2: Click create key"] },
{ key: "SKIPPED_KEY_C", status: "skipped" },
]);
await writeManifestFile(tmp, manifest);
let callIndex = 0;
const mockCtx = {
cwd: tmp,
hasUI: true,
ui: {
custom: async (_factory: any) => {
callIndex++;
if (callIndex <= 1) return null; // summary screen dismiss
return "mock-secret-value"; // collect pending key
},
},
};
const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any);
// EXISTING_KEY_A should be in existingSkipped (it's in process.env)
assert.ok(result.existingSkipped?.includes("EXISTING_KEY_A"),
"EXISTING_KEY_A should be in existingSkipped");
// PENDING_KEY_B should have been collected (applied)
assert.ok(result.applied.includes("PENDING_KEY_B"),
"PENDING_KEY_B should be in applied");
// SKIPPED_KEY_C should remain skipped
assert.ok(result.skipped.includes("SKIPPED_KEY_C"),
"SKIPPED_KEY_C should be in skipped");
} finally {
t.after(() => {
delete process.env.EXISTING_KEY_A;
if (savedA !== undefined) process.env.EXISTING_KEY_A = savedA;
rmSync(tmp, { recursive: true, force: true });
}
});
process.env.EXISTING_KEY_A = "already-set";
const manifest = makeManifest([
{ key: "EXISTING_KEY_A", status: "pending" },
{ key: "PENDING_KEY_B", status: "pending", guidance: ["Step 1: Go to dashboard", "Step 2: Click create key"] },
{ key: "SKIPPED_KEY_C", status: "skipped" },
]);
await writeManifestFile(tmp, manifest);
let callIndex = 0;
const mockCtx = {
cwd: tmp,
hasUI: true,
ui: {
custom: async (_factory: any) => {
callIndex++;
if (callIndex <= 1) return null; // summary screen dismiss
return "mock-secret-value"; // collect pending key
},
},
};
const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any);
// EXISTING_KEY_A should be in existingSkipped (it's in process.env)
assert.ok(result.existingSkipped?.includes("EXISTING_KEY_A"),
"EXISTING_KEY_A should be in existingSkipped");
// PENDING_KEY_B should have been collected (applied)
assert.ok(result.applied.includes("PENDING_KEY_B"),
"PENDING_KEY_B should be in applied");
// SKIPPED_KEY_C should remain skipped
assert.ok(result.skipped.includes("SKIPPED_KEY_C"),
"SKIPPED_KEY_C should be in skipped");
});
test("collectSecretsFromManifest: existing keys are excluded from the collection list — not prompted", async () => {
test("collectSecretsFromManifest: existing keys are excluded from the collection list — not prompted", async (t) => {
const { collectSecretsFromManifest } = await loadOrchestrator();
const tmp = makeTempDir("manifest-collect-skip");
const savedA = process.env.ALREADY_SET_KEY;
try {
process.env.ALREADY_SET_KEY = "present";
const manifest = makeManifest([
{ key: "ALREADY_SET_KEY", status: "pending" },
{ key: "NEEDS_COLLECTION", status: "pending" },
]);
await writeManifestFile(tmp, manifest);
const collectedKeyNames: string[] = [];
let summaryShown = false;
const mockCtx = {
cwd: tmp,
hasUI: true,
ui: {
custom: async (factory: any) => {
// Intercept the factory to check what key is being collected
if (!summaryShown) {
summaryShown = true;
return null; // dismiss summary
}
collectedKeyNames.push("prompted");
return "mock-value";
},
},
};
const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any);
// ALREADY_SET_KEY should not have been prompted — only NEEDS_COLLECTION should
assert.ok(!result.applied.includes("ALREADY_SET_KEY"),
"ALREADY_SET_KEY should not be in applied (it was auto-skipped)");
assert.ok(result.existingSkipped?.includes("ALREADY_SET_KEY"),
"ALREADY_SET_KEY should be in existingSkipped");
} finally {
t.after(() => {
delete process.env.ALREADY_SET_KEY;
if (savedA !== undefined) process.env.ALREADY_SET_KEY = savedA;
rmSync(tmp, { recursive: true, force: true });
}
});
process.env.ALREADY_SET_KEY = "present";
const manifest = makeManifest([
{ key: "ALREADY_SET_KEY", status: "pending" },
{ key: "NEEDS_COLLECTION", status: "pending" },
]);
await writeManifestFile(tmp, manifest);
const collectedKeyNames: string[] = [];
let summaryShown = false;
const mockCtx = {
cwd: tmp,
hasUI: true,
ui: {
custom: async (factory: any) => {
// Intercept the factory to check what key is being collected
if (!summaryShown) {
summaryShown = true;
return null; // dismiss summary
}
collectedKeyNames.push("prompted");
return "mock-value";
},
},
};
const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any);
// ALREADY_SET_KEY should not have been prompted — only NEEDS_COLLECTION should
assert.ok(!result.applied.includes("ALREADY_SET_KEY"),
"ALREADY_SET_KEY should not be in applied (it was auto-skipped)");
assert.ok(result.existingSkipped?.includes("ALREADY_SET_KEY"),
"ALREADY_SET_KEY should be in existingSkipped");
});
test("collectSecretsFromManifest: manifest statuses are updated after collection", async () => {
test("collectSecretsFromManifest: manifest statuses are updated after collection", async (t) => {
const { collectSecretsFromManifest } = await loadOrchestrator();
const tmp = makeTempDir("manifest-update");
try {
const manifest = makeManifest([
{ key: "KEY_TO_COLLECT", status: "pending" },
{ key: "KEY_TO_SKIP", status: "pending" },
]);
const manifestPath = await writeManifestFile(tmp, manifest);
t.after(() => rmSync(tmp, { recursive: true, force: true }));
let callIndex = 0;
const mockCtx = {
cwd: tmp,
hasUI: true,
ui: {
custom: async (_factory: any) => {
callIndex++;
if (callIndex <= 1) return null; // summary screen dismiss
if (callIndex === 2) return "secret-value"; // KEY_TO_COLLECT
return null; // KEY_TO_SKIP — user skips
},
const manifest = makeManifest([
{ key: "KEY_TO_COLLECT", status: "pending" },
{ key: "KEY_TO_SKIP", status: "pending" },
]);
const manifestPath = await writeManifestFile(tmp, manifest);
let callIndex = 0;
const mockCtx = {
cwd: tmp,
hasUI: true,
ui: {
custom: async (_factory: any) => {
callIndex++;
if (callIndex <= 1) return null; // summary screen dismiss
if (callIndex === 2) return "secret-value"; // KEY_TO_COLLECT
return null; // KEY_TO_SKIP — user skips
},
};
},
};
await collectSecretsFromManifest(tmp, "M001", mockCtx as any);
await collectSecretsFromManifest(tmp, "M001", mockCtx as any);
// Read back the manifest file and verify statuses were updated
const { parseSecretsManifest } = await loadFilesExports();
const updatedContent = readFileSync(manifestPath, "utf8");
const updatedManifest = parseSecretsManifest(updatedContent);
// Read back the manifest file and verify statuses were updated
const { parseSecretsManifest } = await loadFilesExports();
const updatedContent = readFileSync(manifestPath, "utf8");
const updatedManifest = parseSecretsManifest(updatedContent);
const keyToCollect = updatedManifest.entries.find(e => e.key === "KEY_TO_COLLECT");
const keyToSkip = updatedManifest.entries.find(e => e.key === "KEY_TO_SKIP");
const keyToCollect = updatedManifest.entries.find(e => e.key === "KEY_TO_COLLECT");
const keyToSkip = updatedManifest.entries.find(e => e.key === "KEY_TO_SKIP");
assert.equal(keyToCollect?.status, "collected",
"KEY_TO_COLLECT should have status 'collected' after providing a value");
assert.equal(keyToSkip?.status, "skipped",
"KEY_TO_SKIP should have status 'skipped' after user skipped it");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
assert.equal(keyToCollect?.status, "collected",
"KEY_TO_COLLECT should have status 'collected' after providing a value");
assert.equal(keyToSkip?.status, "skipped",
"KEY_TO_SKIP should have status 'skipped' after user skipped it");
});
// ─── showSecretsSummary: render output ────────────────────────────────────────
@ -423,47 +421,47 @@ test("collectOneSecret: no guidance provided — render output has no guidance s
// ─── collectSecretsFromManifest: returns structured result ────────────────────
test("collectSecretsFromManifest: returns result with applied, skipped, and existingSkipped arrays", async () => {
test("collectSecretsFromManifest: returns result with applied, skipped, and existingSkipped arrays", async (t) => {
const { collectSecretsFromManifest } = await loadOrchestrator();
const tmp = makeTempDir("manifest-result");
const savedKey = process.env.RESULT_TEST_EXISTING;
try {
process.env.RESULT_TEST_EXISTING = "already-here";
const manifest = makeManifest([
{ key: "RESULT_TEST_EXISTING", status: "pending" },
{ key: "RESULT_TEST_NEW", status: "pending" },
]);
await writeManifestFile(tmp, manifest);
let callIndex = 0;
const mockCtx = {
cwd: tmp,
hasUI: true,
ui: {
custom: async (_factory: any) => {
callIndex++;
if (callIndex <= 1) return null; // summary dismiss
return "secret-value"; // collect the pending key
},
},
};
const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any);
// Verify result shape
assert.ok(Array.isArray(result.applied), "result should have applied array");
assert.ok(Array.isArray(result.skipped), "result should have skipped array");
assert.ok(Array.isArray(result.existingSkipped), "result should have existingSkipped array");
assert.ok(result.existingSkipped.includes("RESULT_TEST_EXISTING"),
"existing key should be in existingSkipped");
assert.ok(result.applied.includes("RESULT_TEST_NEW"),
"collected key should be in applied");
} finally {
t.after(() => {
delete process.env.RESULT_TEST_EXISTING;
if (savedKey !== undefined) process.env.RESULT_TEST_EXISTING = savedKey;
rmSync(tmp, { recursive: true, force: true });
}
});
process.env.RESULT_TEST_EXISTING = "already-here";
const manifest = makeManifest([
{ key: "RESULT_TEST_EXISTING", status: "pending" },
{ key: "RESULT_TEST_NEW", status: "pending" },
]);
await writeManifestFile(tmp, manifest);
let callIndex = 0;
const mockCtx = {
cwd: tmp,
hasUI: true,
ui: {
custom: async (_factory: any) => {
callIndex++;
if (callIndex <= 1) return null; // summary dismiss
return "secret-value"; // collect the pending key
},
},
};
const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any);
// Verify result shape
assert.ok(Array.isArray(result.applied), "result should have applied array");
assert.ok(Array.isArray(result.skipped), "result should have skipped array");
assert.ok(Array.isArray(result.existingSkipped), "result should have existingSkipped array");
assert.ok(result.existingSkipped.includes("RESULT_TEST_EXISTING"),
"existing key should be in existingSkipped");
assert.ok(result.applied.includes("RESULT_TEST_NEW"),
"collected key should be in applied");
});

View file

@ -7,40 +7,40 @@ import fs from "node:fs";
import { handleInspect } from "../commands-inspect.ts";
import { closeDatabase, openDatabase } from "../gsd-db.ts";
test("/gsd inspect opens existing database when it was not yet opened in session", async () => {
test("/gsd inspect opens existing database when it was not yet opened in session", async (t) => {
closeDatabase();
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gsd-inspect-db-"));
const prevCwd = process.cwd();
try {
const gsdDir = path.join(tmp, ".gsd");
fs.mkdirSync(gsdDir, { recursive: true });
const dbPath = path.join(gsdDir, "gsd.db");
assert.equal(openDatabase(dbPath), true);
closeDatabase();
process.chdir(tmp);
const notifications: Array<{ message: string; level: string }> = [];
const ctx = {
ui: {
notify(message: string, level: string) {
notifications.push({ message, level });
},
},
} as any;
await handleInspect(ctx);
assert.equal(notifications.length, 1);
assert.equal(notifications[0].level, "info");
assert.match(notifications[0].message, /=== GSD Database Inspect ===/);
assert.doesNotMatch(notifications[0].message, /No GSD database available/);
} finally {
t.after(() => {
process.chdir(prevCwd);
closeDatabase();
fs.rmSync(tmp, { recursive: true, force: true });
}
});
const gsdDir = path.join(tmp, ".gsd");
fs.mkdirSync(gsdDir, { recursive: true });
const dbPath = path.join(gsdDir, "gsd.db");
assert.equal(openDatabase(dbPath), true);
closeDatabase();
process.chdir(tmp);
const notifications: Array<{ message: string; level: string }> = [];
const ctx = {
ui: {
notify(message: string, level: string) {
notifications.push({ message, level });
},
},
} as any;
await handleInspect(ctx);
assert.equal(notifications.length, 1);
assert.equal(notifications[0].level, "info");
assert.match(notifications[0].message, /=== GSD Database Inspect ===/);
assert.doesNotMatch(notifications[0].message, /No GSD database available/);
});

View file

@ -42,22 +42,22 @@ function writeDebugLog(dir: string, name: string, entries: Record<string, unknow
// ─── Tests ──────────────────────────────────────────────────────────────────
test("logs shows empty state message when no logs exist", async () => {
test("logs shows empty state message when no logs exist", async (t) => {
const dir = createTestDir();
const ctx = createMockCtx();
const origCwd = process.cwd();
process.chdir(dir);
try {
await handleLogs("", ctx as any);
assert.equal(ctx.notifications.length, 1);
assert.ok(ctx.notifications[0].msg.includes("No logs found"));
} finally {
t.after(() => {
process.chdir(origCwd);
rmSync(dir, { recursive: true, force: true });
}
});
await handleLogs("", ctx as any);
assert.equal(ctx.notifications.length, 1);
assert.ok(ctx.notifications[0].msg.includes("No logs found"));
});
test("logs lists activity logs", async () => {
test("logs lists activity logs", async (t) => {
const dir = createTestDir();
const ctx = createMockCtx();
const origCwd = process.cwd();
@ -71,21 +71,21 @@ test("logs lists activity logs", async () => {
{ role: "assistant", content: "Completing slice S01" },
]);
try {
await handleLogs("", ctx as any);
assert.equal(ctx.notifications.length, 1);
const msg = ctx.notifications[0].msg;
assert.ok(msg.includes("Activity Logs"), "should show activity logs header");
assert.ok(msg.includes("execute-task"), "should show unit type");
assert.ok(msg.includes("complete-slice"), "should show second log");
assert.ok(msg.includes("/gsd logs <#>"), "should show usage hint");
} finally {
t.after(() => {
process.chdir(origCwd);
rmSync(dir, { recursive: true, force: true });
}
});
await handleLogs("", ctx as any);
assert.equal(ctx.notifications.length, 1);
const msg = ctx.notifications[0].msg;
assert.ok(msg.includes("Activity Logs"), "should show activity logs header");
assert.ok(msg.includes("execute-task"), "should show unit type");
assert.ok(msg.includes("complete-slice"), "should show second log");
assert.ok(msg.includes("/gsd logs <#>"), "should show usage hint");
});
test("logs <N> shows activity log details", async () => {
test("logs <N> shows activity log details", async (t) => {
const dir = createTestDir();
const ctx = createMockCtx();
const origCwd = process.cwd();
@ -99,40 +99,40 @@ test("logs <N> shows activity log details", async () => {
{ role: "assistant", content: "I ran the tests and wrote a file" },
]);
try {
await handleLogs("1", ctx as any);
assert.equal(ctx.notifications.length, 1);
const msg = ctx.notifications[0].msg;
assert.ok(msg.includes("Activity Log #1"), "should show log number");
assert.ok(msg.includes("execute-task"), "should show unit type");
assert.ok(msg.includes("Tool calls: 2"), "should count tool calls");
assert.ok(msg.includes("Errors: 1"), "should count errors");
assert.ok(msg.includes("/tmp/test.ts"), "should show files written");
assert.ok(msg.includes("npm test"), "should show commands run");
} finally {
t.after(() => {
process.chdir(origCwd);
rmSync(dir, { recursive: true, force: true });
}
});
await handleLogs("1", ctx as any);
assert.equal(ctx.notifications.length, 1);
const msg = ctx.notifications[0].msg;
assert.ok(msg.includes("Activity Log #1"), "should show log number");
assert.ok(msg.includes("execute-task"), "should show unit type");
assert.ok(msg.includes("Tool calls: 2"), "should count tool calls");
assert.ok(msg.includes("Errors: 1"), "should count errors");
assert.ok(msg.includes("/tmp/test.ts"), "should show files written");
assert.ok(msg.includes("npm test"), "should show commands run");
});
test("logs <N> shows not found for invalid seq", async () => {
test("logs <N> shows not found for invalid seq", async (t) => {
const dir = createTestDir();
const ctx = createMockCtx();
const origCwd = process.cwd();
process.chdir(dir);
try {
await handleLogs("999", ctx as any);
assert.equal(ctx.notifications.length, 1);
assert.ok(ctx.notifications[0].msg.includes("not found"));
assert.equal(ctx.notifications[0].level, "warning");
} finally {
t.after(() => {
process.chdir(origCwd);
rmSync(dir, { recursive: true, force: true });
}
});
await handleLogs("999", ctx as any);
assert.equal(ctx.notifications.length, 1);
assert.ok(ctx.notifications[0].msg.includes("not found"));
assert.equal(ctx.notifications[0].level, "warning");
});
test("logs debug lists debug logs", async () => {
test("logs debug lists debug logs", async (t) => {
const dir = createTestDir();
const ctx = createMockCtx();
const origCwd = process.cwd();
@ -143,19 +143,19 @@ test("logs debug lists debug logs", async () => {
{ ts: "2026-03-18T10:35:00Z", event: "debug-summary", dispatches: 5 },
]);
try {
await handleLogs("debug", ctx as any);
assert.equal(ctx.notifications.length, 1);
const msg = ctx.notifications[0].msg;
assert.ok(msg.includes("Debug Logs"), "should show debug logs header");
assert.ok(msg.includes("debug-2026-03-18T10-30-00.log"), "should show filename");
} finally {
t.after(() => {
process.chdir(origCwd);
rmSync(dir, { recursive: true, force: true });
}
});
await handleLogs("debug", ctx as any);
assert.equal(ctx.notifications.length, 1);
const msg = ctx.notifications[0].msg;
assert.ok(msg.includes("Debug Logs"), "should show debug logs header");
assert.ok(msg.includes("debug-2026-03-18T10-30-00.log"), "should show filename");
});
test("logs debug <N> shows debug log summary", async () => {
test("logs debug <N> shows debug log summary", async (t) => {
const dir = createTestDir();
const ctx = createMockCtx();
const origCwd = process.cwd();
@ -167,21 +167,21 @@ test("logs debug <N> shows debug log summary", async () => {
{ ts: "2026-03-18T10:35:00Z", event: "debug-summary", dispatches: 5 },
]);
try {
await handleLogs("debug 1", ctx as any);
assert.equal(ctx.notifications.length, 1);
const msg = ctx.notifications[0].msg;
assert.ok(msg.includes("Debug Log:"), "should show debug log header");
assert.ok(msg.includes("Events: 3"), "should count events");
assert.ok(msg.includes("Dispatches: 5"), "should show dispatch count");
assert.ok(msg.includes("dispatch-error"), "should show errors");
} finally {
t.after(() => {
process.chdir(origCwd);
rmSync(dir, { recursive: true, force: true });
}
});
await handleLogs("debug 1", ctx as any);
assert.equal(ctx.notifications.length, 1);
const msg = ctx.notifications[0].msg;
assert.ok(msg.includes("Debug Log:"), "should show debug log header");
assert.ok(msg.includes("Events: 3"), "should count events");
assert.ok(msg.includes("Dispatches: 5"), "should show dispatch count");
assert.ok(msg.includes("dispatch-error"), "should show errors");
});
test("logs tail shows recent activity summaries", async () => {
test("logs tail shows recent activity summaries", async (t) => {
const dir = createTestDir();
const ctx = createMockCtx();
const origCwd = process.cwd();
@ -195,20 +195,20 @@ test("logs tail shows recent activity summaries", async () => {
{ role: "toolResult", toolCallId: "1", toolName: "bash", isError: true },
]);
try {
await handleLogs("tail 2", ctx as any);
assert.equal(ctx.notifications.length, 1);
const msg = ctx.notifications[0].msg;
assert.ok(msg.includes("Last 2 activity log(s)"), "should show count");
assert.ok(msg.includes("#1"), "should show first log");
assert.ok(msg.includes("#2"), "should show second log");
} finally {
t.after(() => {
process.chdir(origCwd);
rmSync(dir, { recursive: true, force: true });
}
});
await handleLogs("tail 2", ctx as any);
assert.equal(ctx.notifications.length, 1);
const msg = ctx.notifications[0].msg;
assert.ok(msg.includes("Last 2 activity log(s)"), "should show count");
assert.ok(msg.includes("#1"), "should show first log");
assert.ok(msg.includes("#2"), "should show second log");
});
test("logs clear removes old logs", async () => {
test("logs clear removes old logs", async (t) => {
const dir = createTestDir();
const ctx = createMockCtx();
const origCwd = process.cwd();
@ -225,17 +225,17 @@ test("logs clear removes old logs", async () => {
writeActivityLog(dir, i, "execute-task", `M001/S01/T0${i}`, [{ type: "toolCall" }]);
}
try {
await handleLogs("clear", ctx as any);
assert.equal(ctx.notifications.length, 1);
// Old log should be removed, recent ones kept
assert.ok(!existsSync(oldFile), "old log should be removed");
assert.ok(
existsSync(join(dir, ".gsd", "activity", "007-execute-task-M001-S01-T07.jsonl")),
"most recent log should be kept",
);
} finally {
t.after(() => {
process.chdir(origCwd);
rmSync(dir, { recursive: true, force: true });
}
});
await handleLogs("clear", ctx as any);
assert.equal(ctx.notifications.length, 1);
// Old log should be removed, recent ones kept
assert.ok(!existsSync(oldFile), "old log should be removed");
assert.ok(
existsSync(join(dir, ".gsd", "activity", "007-execute-task-M001-S01-T07.jsonl")),
"most recent log should be kept",
);
});

View file

@ -162,7 +162,7 @@ describe("continue-here", () => {
});
describe("continueHereFired runtime record field", () => {
it("AutoUnitRuntimeRecord includes continueHereFired with default false", async () => {
it("AutoUnitRuntimeRecord includes continueHereFired with default false", async (t) => {
// Import writeUnitRuntimeRecord to verify the field is present and defaults
const { writeUnitRuntimeRecord, readUnitRuntimeRecord, clearUnitRuntimeRecord } = await import("../unit-runtime.js");
const fs = await import("node:fs");
@ -171,87 +171,83 @@ describe("continue-here", () => {
// Use a temp directory as basePath
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "continue-here-test-"));
try {
const record = writeUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02", Date.now(), {
phase: "dispatched",
wrapupWarningSent: false,
});
t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
assert.equal(record.continueHereFired, false, "default continueHereFired should be false");
const record = writeUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02", Date.now(), {
phase: "dispatched",
wrapupWarningSent: false,
});
// Verify it persists to disk
const read = readUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02");
assert.ok(read, "record should be readable");
assert.equal(read!.continueHereFired, false);
assert.equal(record.continueHereFired, false, "default continueHereFired should be false");
// Update to true
const updated = writeUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02", Date.now(), {
continueHereFired: true,
});
assert.equal(updated.continueHereFired, true, "updated continueHereFired should be true");
// Verify it persists to disk
const read = readUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02");
assert.ok(read, "record should be readable");
assert.equal(read!.continueHereFired, false);
// Verify persistence
const readUpdated = readUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02");
assert.equal(readUpdated!.continueHereFired, true, "persisted continueHereFired should be true");
// Update to true
const updated = writeUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02", Date.now(), {
continueHereFired: true,
});
assert.equal(updated.continueHereFired, true, "updated continueHereFired should be true");
// Clean up
clearUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02");
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
// Verify persistence
const readUpdated = readUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02");
assert.equal(readUpdated!.continueHereFired, true, "persisted continueHereFired should be true");
// Clean up
clearUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02");
});
});
describe("context-pressure monitor integration", () => {
it("should fire wrap-up when context >= threshold and mark continueHereFired", async () => {
it("should fire wrap-up when context >= threshold and mark continueHereFired", async (t) => {
const { writeUnitRuntimeRecord, readUnitRuntimeRecord, clearUnitRuntimeRecord } = await import("../unit-runtime.js");
const fs = await import("node:fs");
const path = await import("node:path");
const os = await import("node:os");
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "continue-here-monitor-"));
try {
// Simulate the monitor's one-shot logic:
// 1. Write initial runtime record (continueHereFired=false)
const startedAt = Date.now();
writeUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01", startedAt, {
phase: "dispatched",
wrapupWarningSent: false,
});
t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
const budget = computeBudgets(128_000);
const threshold = budget.continueThresholdPercent;
// Simulate the monitor's one-shot logic:
// 1. Write initial runtime record (continueHereFired=false)
const startedAt = Date.now();
writeUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01", startedAt, {
phase: "dispatched",
wrapupWarningSent: false,
});
// Simulate the monitor poll: context at 75% (above threshold)
const contextPercent = 75;
const runtime = readUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01");
assert.ok(runtime, "runtime record should exist");
assert.equal(runtime!.continueHereFired, false, "initially false");
const budget = computeBudgets(128_000);
const threshold = budget.continueThresholdPercent;
// Check: should fire
const shouldFire = !runtime!.continueHereFired
&& contextPercent >= threshold;
assert.ok(shouldFire, "should fire when context >= threshold and not yet fired");
// Simulate the monitor poll: context at 75% (above threshold)
const contextPercent = 75;
const runtime = readUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01");
assert.ok(runtime, "runtime record should exist");
assert.equal(runtime!.continueHereFired, false, "initially false");
// Mark as fired (what the monitor does)
writeUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01", startedAt, {
continueHereFired: true,
});
// Check: should fire
const shouldFire = !runtime!.continueHereFired
&& contextPercent >= threshold;
assert.ok(shouldFire, "should fire when context >= threshold and not yet fired");
// Verify one-shot: second poll should NOT fire
const runtime2 = readUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01");
assert.ok(runtime2, "runtime record should still exist");
assert.equal(runtime2!.continueHereFired, true, "should be marked as fired");
// Mark as fired (what the monitor does)
writeUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01", startedAt, {
continueHereFired: true,
});
const shouldFireAgain = !runtime2!.continueHereFired
&& contextPercent >= threshold;
assert.equal(shouldFireAgain, false, "must not fire again — one-shot guard");
// Verify one-shot: second poll should NOT fire
const runtime2 = readUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01");
assert.ok(runtime2, "runtime record should still exist");
assert.equal(runtime2!.continueHereFired, true, "should be marked as fired");
// Clean up
clearUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01");
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
const shouldFireAgain = !runtime2!.continueHereFired
&& contextPercent >= threshold;
assert.equal(shouldFireAgain, false, "must not fire again — one-shot guard");
// Clean up
clearUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01");
});
it("should not fire when context is below threshold", () => {

View file

@ -26,53 +26,45 @@ function cleanup(base: string): void {
// ─── writeLock / readCrashLock ────────────────────────────────────────────
test("writeLock creates lock file and readCrashLock reads it", () => {
test("writeLock creates lock file and readCrashLock reads it", (t) => {
const base = makeTmpBase();
try {
writeLock(base, "execute-task", "M001/S01/T01", 3, "/tmp/session.jsonl");
const lock = readCrashLock(base);
assert.ok(lock, "lock should exist");
assert.equal(lock!.unitType, "execute-task");
assert.equal(lock!.unitId, "M001/S01/T01");
assert.equal(lock!.completedUnits, 3);
assert.equal(lock!.sessionFile, "/tmp/session.jsonl");
assert.equal(lock!.pid, process.pid);
} finally {
cleanup(base);
}
t.after(() => cleanup(base));
writeLock(base, "execute-task", "M001/S01/T01", 3, "/tmp/session.jsonl");
const lock = readCrashLock(base);
assert.ok(lock, "lock should exist");
assert.equal(lock!.unitType, "execute-task");
assert.equal(lock!.unitId, "M001/S01/T01");
assert.equal(lock!.completedUnits, 3);
assert.equal(lock!.sessionFile, "/tmp/session.jsonl");
assert.equal(lock!.pid, process.pid);
});
test("readCrashLock returns null when no lock exists", () => {
test("readCrashLock returns null when no lock exists", (t) => {
const base = makeTmpBase();
try {
const lock = readCrashLock(base);
assert.equal(lock, null);
} finally {
cleanup(base);
}
t.after(() => cleanup(base));
const lock = readCrashLock(base);
assert.equal(lock, null);
});
// ─── clearLock ────────────────────────────────────────────────────────────
test("clearLock removes existing lock file", () => {
test("clearLock removes existing lock file", (t) => {
const base = makeTmpBase();
try {
writeLock(base, "plan-slice", "M001/S01", 0);
assert.ok(readCrashLock(base), "lock should exist before clear");
clearLock(base);
assert.equal(readCrashLock(base), null, "lock should be gone after clear");
} finally {
cleanup(base);
}
t.after(() => cleanup(base));
writeLock(base, "plan-slice", "M001/S01", 0);
assert.ok(readCrashLock(base), "lock should exist before clear");
clearLock(base);
assert.equal(readCrashLock(base), null, "lock should be gone after clear");
});
test("clearLock is safe when no lock exists", () => {
test("clearLock is safe when no lock exists", (t) => {
const base = makeTmpBase();
try {
assert.doesNotThrow(() => clearLock(base));
} finally {
cleanup(base);
}
t.after(() => cleanup(base));
assert.doesNotThrow(() => clearLock(base));
});
// ─── isLockProcessAlive ──────────────────────────────────────────────────

View file

@ -63,35 +63,33 @@ steps:
// ─── loadDefinition: valid YAML ──────────────────────────────────────────
test("loadDefinition: valid 3-step YAML returns correct structure", () => {
test("loadDefinition: valid 3-step YAML returns correct structure", (t) => {
const dir = writeDefYaml(VALID_3STEP_YAML);
try {
const def = loadDefinition(dir, "test-workflow");
t.after(() => { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } });
assert.equal(def.version, 1);
assert.equal(def.name, "test-workflow");
assert.equal(def.description, "A test workflow");
assert.deepEqual(def.params, { topic: "AI" });
assert.equal(def.steps.length, 3);
const def = loadDefinition(dir, "test-workflow");
// Step 1: research
assert.equal(def.steps[0].id, "research");
assert.equal(def.steps[0].name, "Research the topic");
assert.equal(def.steps[0].prompt, "Research {{topic}} and write findings to research.md");
assert.deepEqual(def.steps[0].requires, []);
assert.deepEqual(def.steps[0].produces, ["research.md"]);
assert.equal(def.version, 1);
assert.equal(def.name, "test-workflow");
assert.equal(def.description, "A test workflow");
assert.deepEqual(def.params, { topic: "AI" });
assert.equal(def.steps.length, 3);
// Step 2: outline — depends on research
assert.equal(def.steps[1].id, "outline");
assert.deepEqual(def.steps[1].requires, ["research"]);
// Step 1: research
assert.equal(def.steps[0].id, "research");
assert.equal(def.steps[0].name, "Research the topic");
assert.equal(def.steps[0].prompt, "Research {{topic}} and write findings to research.md");
assert.deepEqual(def.steps[0].requires, []);
assert.deepEqual(def.steps[0].produces, ["research.md"]);
// Step 3: draft — depends on outline
assert.equal(def.steps[2].id, "draft");
assert.deepEqual(def.steps[2].requires, ["outline"]);
assert.deepEqual(def.steps[2].produces, ["draft.md"]);
} finally {
try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ }
}
// Step 2: outline — depends on research
assert.equal(def.steps[1].id, "outline");
assert.deepEqual(def.steps[1].requires, ["research"]);
// Step 3: draft — depends on outline
assert.equal(def.steps[2].id, "draft");
assert.deepEqual(def.steps[2].requires, ["outline"]);
assert.deepEqual(def.steps[2].produces, ["draft.md"]);
});
// ─── validateDefinition: rejection cases ─────────────────────────────────
@ -223,23 +221,21 @@ test("validateDefinition: missing step name → error", () => {
// ─── loadDefinition: error cases ─────────────────────────────────────────
test("loadDefinition: missing file → descriptive error", () => {
test("loadDefinition: missing file → descriptive error", (t) => {
const dir = makeTmpDir();
try {
assert.throws(
() => loadDefinition(dir, "nonexistent"),
(err: Error) => {
assert.ok(err.message.includes("not found"));
assert.ok(err.message.includes("nonexistent.yaml"));
return true;
},
);
} finally {
try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ }
}
t.after(() => { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } });
assert.throws(
() => loadDefinition(dir, "nonexistent"),
(err: Error) => {
assert.ok(err.message.includes("not found"));
assert.ok(err.message.includes("nonexistent.yaml"));
return true;
},
);
});
test("loadDefinition: invalid YAML schema → descriptive error", () => {
test("loadDefinition: invalid YAML schema → descriptive error", (t) => {
const dir = writeDefYaml(`
version: 2
name: "bad"
@ -248,23 +244,21 @@ steps:
name: "A"
prompt: "do A"
`);
try {
assert.throws(
() => loadDefinition(dir, "test-workflow"),
(err: Error) => {
assert.ok(err.message.includes("Invalid workflow definition"));
assert.ok(err.message.includes("Unsupported version"));
return true;
},
);
} finally {
try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ }
}
t.after(() => { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } });
assert.throws(
() => loadDefinition(dir, "test-workflow"),
(err: Error) => {
assert.ok(err.message.includes("Invalid workflow definition"));
assert.ok(err.message.includes("Unsupported version"));
return true;
},
);
});
// ─── loadDefinition: snake_case → camelCase conversion ───────────────────
test("loadDefinition: depends_on in YAML maps to requires in TypeScript", () => {
test("loadDefinition: depends_on in YAML maps to requires in TypeScript", (t) => {
const dir = writeDefYaml(`
version: 1
name: "dep-test"
@ -277,15 +271,13 @@ steps:
prompt: "do second"
depends_on: [first]
`);
try {
const def = loadDefinition(dir, "test-workflow");
assert.deepEqual(def.steps[1].requires, ["first"]);
} finally {
try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ }
}
t.after(() => { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } });
const def = loadDefinition(dir, "test-workflow");
assert.deepEqual(def.steps[1].requires, ["first"]);
});
test("loadDefinition: context_from in YAML maps to contextFrom in TypeScript", () => {
test("loadDefinition: context_from in YAML maps to contextFrom in TypeScript", (t) => {
const dir = writeDefYaml(`
version: 1
name: "ctx-test"
@ -298,12 +290,10 @@ steps:
prompt: "do second"
context_from: [first]
`);
try {
const def = loadDefinition(dir, "test-workflow");
assert.deepEqual(def.steps[1].contextFrom, ["first"]);
} finally {
try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ }
}
t.after(() => { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } });
const def = loadDefinition(dir, "test-workflow");
assert.deepEqual(def.steps[1].contextFrom, ["first"]);
});
// ─── validateDefinition: iterate field validation ────────────────────────
@ -725,7 +715,7 @@ test("validateDefinition: valid minimal step (no requires/produces) → accepted
assert.equal(result.errors.length, 0);
});
test("loadDefinition: loads without params field → params is undefined", () => {
test("loadDefinition: loads without params field → params is undefined", (t) => {
const dir = writeDefYaml(`
version: 1
name: "no-params"
@ -734,15 +724,13 @@ steps:
name: "A"
prompt: "do A"
`);
try {
const def = loadDefinition(dir, "test-workflow");
assert.equal(def.params, undefined);
} finally {
try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ }
}
t.after(() => { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } });
const def = loadDefinition(dir, "test-workflow");
assert.equal(def.params, undefined);
});
test("loadDefinition: loads without description → description is undefined", () => {
test("loadDefinition: loads without description → description is undefined", (t) => {
const dir = writeDefYaml(`
version: 1
name: "no-desc"
@ -751,15 +739,13 @@ steps:
name: "A"
prompt: "do A"
`);
try {
const def = loadDefinition(dir, "test-workflow");
assert.equal(def.description, undefined);
} finally {
try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ }
}
t.after(() => { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } });
const def = loadDefinition(dir, "test-workflow");
assert.equal(def.description, undefined);
});
test("loadDefinition: step with no requires/produces defaults to empty arrays", () => {
test("loadDefinition: step with no requires/produces defaults to empty arrays", (t) => {
const dir = writeDefYaml(`
version: 1
name: "defaults"
@ -768,11 +754,9 @@ steps:
name: "A"
prompt: "do A"
`);
try {
const def = loadDefinition(dir, "test-workflow");
assert.deepEqual(def.steps[0].requires, []);
assert.deepEqual(def.steps[0].produces, []);
} finally {
try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ }
}
t.after(() => { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } });
const def = loadDefinition(dir, "test-workflow");
assert.deepEqual(def.steps[0].requires, []);
assert.deepEqual(def.steps[0].produces, []);
});

View file

@ -38,361 +38,315 @@ function cleanup(dir: string): void {
// ─── detectProjectState ─────────────────────────────────────────────────────────
test("detectProjectState: empty directory returns state=none", () => {
test("detectProjectState: empty directory returns state=none", (t) => {
const dir = makeTempDir("empty");
try {
const result = detectProjectState(dir);
assert.equal(result.state, "none");
assert.equal(result.v1, undefined);
assert.equal(result.v2, undefined);
} finally {
cleanup(dir);
}
t.after(() => cleanup(dir));
const result = detectProjectState(dir);
assert.equal(result.state, "none");
assert.equal(result.v1, undefined);
assert.equal(result.v2, undefined);
});
test("detectProjectState: directory with .gsd/milestones/M001 returns v2-gsd", () => {
test("detectProjectState: directory with .gsd/milestones/M001 returns v2-gsd", (t) => {
const dir = makeTempDir("v2-gsd");
try {
mkdirSync(join(dir, ".gsd", "milestones", "M001"), { recursive: true });
const result = detectProjectState(dir);
assert.equal(result.state, "v2-gsd");
assert.ok(result.v2);
assert.equal(result.v2!.milestoneCount, 1);
} finally {
cleanup(dir);
}
t.after(() => cleanup(dir));
mkdirSync(join(dir, ".gsd", "milestones", "M001"), { recursive: true });
const result = detectProjectState(dir);
assert.equal(result.state, "v2-gsd");
assert.ok(result.v2);
assert.equal(result.v2!.milestoneCount, 1);
});
test("detectProjectState: directory with empty .gsd/milestones returns v2-gsd-empty", () => {
test("detectProjectState: directory with empty .gsd/milestones returns v2-gsd-empty", (t) => {
const dir = makeTempDir("v2-empty");
try {
mkdirSync(join(dir, ".gsd", "milestones"), { recursive: true });
const result = detectProjectState(dir);
assert.equal(result.state, "v2-gsd-empty");
assert.ok(result.v2);
assert.equal(result.v2!.milestoneCount, 0);
} finally {
cleanup(dir);
}
t.after(() => cleanup(dir));
mkdirSync(join(dir, ".gsd", "milestones"), { recursive: true });
const result = detectProjectState(dir);
assert.equal(result.state, "v2-gsd-empty");
assert.ok(result.v2);
assert.equal(result.v2!.milestoneCount, 0);
});
test("detectProjectState: directory with .planning/ returns v1-planning", () => {
test("detectProjectState: directory with .planning/ returns v1-planning", (t) => {
const dir = makeTempDir("v1-planning");
try {
mkdirSync(join(dir, ".planning", "phases", "01-setup"), { recursive: true });
writeFileSync(join(dir, ".planning", "ROADMAP.md"), "# Roadmap\n", "utf-8");
const result = detectProjectState(dir);
assert.equal(result.state, "v1-planning");
assert.ok(result.v1);
assert.equal(result.v1!.hasRoadmap, true);
assert.equal(result.v1!.hasPhasesDir, true);
assert.equal(result.v1!.phaseCount, 1);
} finally {
cleanup(dir);
}
t.after(() => cleanup(dir));
mkdirSync(join(dir, ".planning", "phases", "01-setup"), { recursive: true });
writeFileSync(join(dir, ".planning", "ROADMAP.md"), "# Roadmap\n", "utf-8");
const result = detectProjectState(dir);
assert.equal(result.state, "v1-planning");
assert.ok(result.v1);
assert.equal(result.v1!.hasRoadmap, true);
assert.equal(result.v1!.hasPhasesDir, true);
assert.equal(result.v1!.phaseCount, 1);
});
test("detectProjectState: v2 takes priority over v1 when both exist", () => {
test("detectProjectState: v2 takes priority over v1 when both exist", (t) => {
const dir = makeTempDir("both");
try {
mkdirSync(join(dir, ".gsd", "milestones", "M001"), { recursive: true });
mkdirSync(join(dir, ".planning"), { recursive: true });
const result = detectProjectState(dir);
assert.equal(result.state, "v2-gsd");
} finally {
cleanup(dir);
}
t.after(() => cleanup(dir));
mkdirSync(join(dir, ".gsd", "milestones", "M001"), { recursive: true });
mkdirSync(join(dir, ".planning"), { recursive: true });
const result = detectProjectState(dir);
assert.equal(result.state, "v2-gsd");
});
test("detectProjectState: detects preferences in .gsd/", () => {
test("detectProjectState: detects preferences in .gsd/", (t) => {
const dir = makeTempDir("prefs");
try {
mkdirSync(join(dir, ".gsd", "milestones"), { recursive: true });
writeFileSync(join(dir, ".gsd", "preferences.md"), "---\nversion: 1\n---\n", "utf-8");
const result = detectProjectState(dir);
assert.ok(result.v2);
assert.equal(result.v2!.hasPreferences, true);
} finally {
cleanup(dir);
}
t.after(() => cleanup(dir));
mkdirSync(join(dir, ".gsd", "milestones"), { recursive: true });
writeFileSync(join(dir, ".gsd", "preferences.md"), "---\nversion: 1\n---\n", "utf-8");
const result = detectProjectState(dir);
assert.ok(result.v2);
assert.equal(result.v2!.hasPreferences, true);
});
// ─── detectV1Planning ───────────────────────────────────────────────────────────
test("detectV1Planning: returns null for missing .planning/", () => {
test("detectV1Planning: returns null for missing .planning/", (t) => {
const dir = makeTempDir("no-v1");
try {
assert.equal(detectV1Planning(dir), null);
} finally {
cleanup(dir);
}
t.after(() => cleanup(dir));
assert.equal(detectV1Planning(dir), null);
});
test("detectV1Planning: returns null when .planning is a file", () => {
test("detectV1Planning: returns null when .planning is a file", (t) => {
const dir = makeTempDir("v1-file");
try {
writeFileSync(join(dir, ".planning"), "not a directory", "utf-8");
assert.equal(detectV1Planning(dir), null);
} finally {
cleanup(dir);
}
t.after(() => cleanup(dir));
writeFileSync(join(dir, ".planning"), "not a directory", "utf-8");
assert.equal(detectV1Planning(dir), null);
});
test("detectV1Planning: detects phases directory with multiple phases", () => {
test("detectV1Planning: detects phases directory with multiple phases", (t) => {
const dir = makeTempDir("v1-phases");
try {
mkdirSync(join(dir, ".planning", "phases", "01-setup"), { recursive: true });
mkdirSync(join(dir, ".planning", "phases", "02-core"), { recursive: true });
mkdirSync(join(dir, ".planning", "phases", "03-deploy"), { recursive: true });
const result = detectV1Planning(dir);
assert.ok(result);
assert.equal(result!.phaseCount, 3);
assert.equal(result!.hasPhasesDir, true);
} finally {
cleanup(dir);
}
t.after(() => cleanup(dir));
mkdirSync(join(dir, ".planning", "phases", "01-setup"), { recursive: true });
mkdirSync(join(dir, ".planning", "phases", "02-core"), { recursive: true });
mkdirSync(join(dir, ".planning", "phases", "03-deploy"), { recursive: true });
const result = detectV1Planning(dir);
assert.ok(result);
assert.equal(result!.phaseCount, 3);
assert.equal(result!.hasPhasesDir, true);
});
test("detectV1Planning: detects ROADMAP.md", () => {
test("detectV1Planning: detects ROADMAP.md", (t) => {
const dir = makeTempDir("v1-roadmap");
try {
mkdirSync(join(dir, ".planning"), { recursive: true });
writeFileSync(join(dir, ".planning", "ROADMAP.md"), "# Roadmap", "utf-8");
const result = detectV1Planning(dir);
assert.ok(result);
assert.equal(result!.hasRoadmap, true);
assert.equal(result!.hasPhasesDir, false);
assert.equal(result!.phaseCount, 0);
} finally {
cleanup(dir);
}
t.after(() => cleanup(dir));
mkdirSync(join(dir, ".planning"), { recursive: true });
writeFileSync(join(dir, ".planning", "ROADMAP.md"), "# Roadmap", "utf-8");
const result = detectV1Planning(dir);
assert.ok(result);
assert.equal(result!.hasRoadmap, true);
assert.equal(result!.hasPhasesDir, false);
assert.equal(result!.phaseCount, 0);
});
// ─── detectProjectSignals ───────────────────────────────────────────────────────
test("detectProjectSignals: empty directory", () => {
test("detectProjectSignals: empty directory", (t) => {
const dir = makeTempDir("signals-empty");
try {
const signals = detectProjectSignals(dir);
assert.deepEqual(signals.detectedFiles, []);
assert.equal(signals.isGitRepo, false);
assert.equal(signals.isMonorepo, false);
assert.equal(signals.primaryLanguage, undefined);
assert.equal(signals.hasCI, false);
assert.equal(signals.hasTests, false);
assert.deepEqual(signals.verificationCommands, []);
} finally {
cleanup(dir);
}
t.after(() => cleanup(dir));
const signals = detectProjectSignals(dir);
assert.deepEqual(signals.detectedFiles, []);
assert.equal(signals.isGitRepo, false);
assert.equal(signals.isMonorepo, false);
assert.equal(signals.primaryLanguage, undefined);
assert.equal(signals.hasCI, false);
assert.equal(signals.hasTests, false);
assert.deepEqual(signals.verificationCommands, []);
});
test("detectProjectSignals: Node.js project", () => {
test("detectProjectSignals: Node.js project", (t) => {
const dir = makeTempDir("signals-node");
try {
writeFileSync(
join(dir, "package.json"),
JSON.stringify({
name: "test-project",
scripts: {
test: "jest",
build: "tsc",
lint: "eslint .",
},
}),
"utf-8",
);
writeFileSync(join(dir, "package-lock.json"), "{}", "utf-8");
mkdirSync(join(dir, ".git"), { recursive: true });
t.after(() => cleanup(dir));
const signals = detectProjectSignals(dir);
assert.ok(signals.detectedFiles.includes("package.json"));
assert.equal(signals.primaryLanguage, "javascript/typescript");
assert.equal(signals.isGitRepo, true);
assert.equal(signals.packageManager, "npm");
assert.ok(signals.verificationCommands.includes("npm test"));
assert.ok(signals.verificationCommands.some(c => c.includes("build")));
assert.ok(signals.verificationCommands.some(c => c.includes("lint")));
} finally {
cleanup(dir);
}
writeFileSync(
join(dir, "package.json"),
JSON.stringify({
name: "test-project",
scripts: {
test: "jest",
build: "tsc",
lint: "eslint .",
},
}),
"utf-8",
);
writeFileSync(join(dir, "package-lock.json"), "{}", "utf-8");
mkdirSync(join(dir, ".git"), { recursive: true });
const signals = detectProjectSignals(dir);
assert.ok(signals.detectedFiles.includes("package.json"));
assert.equal(signals.primaryLanguage, "javascript/typescript");
assert.equal(signals.isGitRepo, true);
assert.equal(signals.packageManager, "npm");
assert.ok(signals.verificationCommands.includes("npm test"));
assert.ok(signals.verificationCommands.some(c => c.includes("build")));
assert.ok(signals.verificationCommands.some(c => c.includes("lint")));
});
test("detectProjectSignals: Rust project", () => {
test("detectProjectSignals: Rust project", (t) => {
const dir = makeTempDir("signals-rust");
try {
writeFileSync(join(dir, "Cargo.toml"), '[package]\nname = "test"\n', "utf-8");
const signals = detectProjectSignals(dir);
assert.ok(signals.detectedFiles.includes("Cargo.toml"));
assert.equal(signals.primaryLanguage, "rust");
assert.ok(signals.verificationCommands.includes("cargo test"));
assert.ok(signals.verificationCommands.includes("cargo clippy"));
} finally {
cleanup(dir);
}
t.after(() => cleanup(dir));
writeFileSync(join(dir, "Cargo.toml"), '[package]\nname = "test"\n', "utf-8");
const signals = detectProjectSignals(dir);
assert.ok(signals.detectedFiles.includes("Cargo.toml"));
assert.equal(signals.primaryLanguage, "rust");
assert.ok(signals.verificationCommands.includes("cargo test"));
assert.ok(signals.verificationCommands.includes("cargo clippy"));
});
test("detectProjectSignals: Go project", () => {
test("detectProjectSignals: Go project", (t) => {
const dir = makeTempDir("signals-go");
try {
writeFileSync(join(dir, "go.mod"), "module example.com/test\n", "utf-8");
const signals = detectProjectSignals(dir);
assert.ok(signals.detectedFiles.includes("go.mod"));
assert.equal(signals.primaryLanguage, "go");
assert.ok(signals.verificationCommands.includes("go test ./..."));
} finally {
cleanup(dir);
}
t.after(() => cleanup(dir));
writeFileSync(join(dir, "go.mod"), "module example.com/test\n", "utf-8");
const signals = detectProjectSignals(dir);
assert.ok(signals.detectedFiles.includes("go.mod"));
assert.equal(signals.primaryLanguage, "go");
assert.ok(signals.verificationCommands.includes("go test ./..."));
});
test("detectProjectSignals: Python project", () => {
test("detectProjectSignals: Python project", (t) => {
const dir = makeTempDir("signals-python");
try {
writeFileSync(join(dir, "pyproject.toml"), "[tool.poetry]\n", "utf-8");
const signals = detectProjectSignals(dir);
assert.ok(signals.detectedFiles.includes("pyproject.toml"));
assert.equal(signals.primaryLanguage, "python");
assert.ok(signals.verificationCommands.includes("pytest"));
} finally {
cleanup(dir);
}
t.after(() => cleanup(dir));
writeFileSync(join(dir, "pyproject.toml"), "[tool.poetry]\n", "utf-8");
const signals = detectProjectSignals(dir);
assert.ok(signals.detectedFiles.includes("pyproject.toml"));
assert.equal(signals.primaryLanguage, "python");
assert.ok(signals.verificationCommands.includes("pytest"));
});
test("detectProjectSignals: monorepo detection via workspaces", () => {
test("detectProjectSignals: monorepo detection via workspaces", (t) => {
const dir = makeTempDir("signals-monorepo");
try {
writeFileSync(
join(dir, "package.json"),
JSON.stringify({ name: "mono", workspaces: ["packages/*"] }),
"utf-8",
);
const signals = detectProjectSignals(dir);
assert.equal(signals.isMonorepo, true);
} finally {
cleanup(dir);
}
t.after(() => cleanup(dir));
writeFileSync(
join(dir, "package.json"),
JSON.stringify({ name: "mono", workspaces: ["packages/*"] }),
"utf-8",
);
const signals = detectProjectSignals(dir);
assert.equal(signals.isMonorepo, true);
});
test("detectProjectSignals: monorepo detection via turbo.json", () => {
test("detectProjectSignals: monorepo detection via turbo.json", (t) => {
const dir = makeTempDir("signals-turbo");
try {
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "test" }), "utf-8");
writeFileSync(join(dir, "turbo.json"), "{}", "utf-8");
const signals = detectProjectSignals(dir);
assert.equal(signals.isMonorepo, true);
} finally {
cleanup(dir);
}
t.after(() => cleanup(dir));
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "test" }), "utf-8");
writeFileSync(join(dir, "turbo.json"), "{}", "utf-8");
const signals = detectProjectSignals(dir);
assert.equal(signals.isMonorepo, true);
});
test("detectProjectSignals: CI detection", () => {
test("detectProjectSignals: CI detection", (t) => {
const dir = makeTempDir("signals-ci");
try {
mkdirSync(join(dir, ".github", "workflows"), { recursive: true });
const signals = detectProjectSignals(dir);
assert.equal(signals.hasCI, true);
} finally {
cleanup(dir);
}
t.after(() => cleanup(dir));
mkdirSync(join(dir, ".github", "workflows"), { recursive: true });
const signals = detectProjectSignals(dir);
assert.equal(signals.hasCI, true);
});
test("detectProjectSignals: test detection via jest config", () => {
test("detectProjectSignals: test detection via jest config", (t) => {
const dir = makeTempDir("signals-tests");
try {
writeFileSync(join(dir, "jest.config.ts"), "export default {}", "utf-8");
const signals = detectProjectSignals(dir);
assert.equal(signals.hasTests, true);
} finally {
cleanup(dir);
}
t.after(() => cleanup(dir));
writeFileSync(join(dir, "jest.config.ts"), "export default {}", "utf-8");
const signals = detectProjectSignals(dir);
assert.equal(signals.hasTests, true);
});
test("detectProjectSignals: package manager detection", () => {
test("detectProjectSignals: package manager detection", (t) => {
const dir1 = makeTempDir("pm-pnpm");
const dir2 = makeTempDir("pm-yarn");
const dir3 = makeTempDir("pm-bun");
try {
writeFileSync(join(dir1, "pnpm-lock.yaml"), "", "utf-8");
writeFileSync(join(dir1, "package.json"), "{}", "utf-8");
assert.equal(detectProjectSignals(dir1).packageManager, "pnpm");
writeFileSync(join(dir2, "yarn.lock"), "", "utf-8");
writeFileSync(join(dir2, "package.json"), "{}", "utf-8");
assert.equal(detectProjectSignals(dir2).packageManager, "yarn");
writeFileSync(join(dir3, "bun.lockb"), "", "utf-8");
writeFileSync(join(dir3, "package.json"), "{}", "utf-8");
assert.equal(detectProjectSignals(dir3).packageManager, "bun");
} finally {
t.after(() => {
cleanup(dir1);
cleanup(dir2);
cleanup(dir3);
}
});
writeFileSync(join(dir1, "pnpm-lock.yaml"), "", "utf-8");
writeFileSync(join(dir1, "package.json"), "{}", "utf-8");
assert.equal(detectProjectSignals(dir1).packageManager, "pnpm");
writeFileSync(join(dir2, "yarn.lock"), "", "utf-8");
writeFileSync(join(dir2, "package.json"), "{}", "utf-8");
assert.equal(detectProjectSignals(dir2).packageManager, "yarn");
writeFileSync(join(dir3, "bun.lockb"), "", "utf-8");
writeFileSync(join(dir3, "package.json"), "{}", "utf-8");
assert.equal(detectProjectSignals(dir3).packageManager, "bun");
});
test("detectProjectSignals: skips default npm test script", () => {
test("detectProjectSignals: skips default npm test script", (t) => {
const dir = makeTempDir("signals-default-test");
try {
writeFileSync(
join(dir, "package.json"),
JSON.stringify({
name: "test",
scripts: { test: 'echo "Error: no test specified" && exit 1' },
}),
"utf-8",
);
const signals = detectProjectSignals(dir);
// Should NOT include the default npm test script
assert.equal(
signals.verificationCommands.some(c => c.includes("test")),
false,
);
} finally {
cleanup(dir);
}
t.after(() => cleanup(dir));
writeFileSync(
join(dir, "package.json"),
JSON.stringify({
name: "test",
scripts: { test: 'echo "Error: no test specified" && exit 1' },
}),
"utf-8",
);
const signals = detectProjectSignals(dir);
// Should NOT include the default npm test script
assert.equal(
signals.verificationCommands.some(c => c.includes("test")),
false,
);
});
test("detectProjectSignals: pnpm uses pnpm commands", () => {
test("detectProjectSignals: pnpm uses pnpm commands", (t) => {
const dir = makeTempDir("signals-pnpm-cmds");
try {
writeFileSync(
join(dir, "package.json"),
JSON.stringify({
name: "test",
scripts: { test: "vitest", build: "tsc" },
}),
"utf-8",
);
writeFileSync(join(dir, "pnpm-lock.yaml"), "", "utf-8");
const signals = detectProjectSignals(dir);
assert.ok(signals.verificationCommands.includes("pnpm test"));
assert.ok(signals.verificationCommands.includes("pnpm run build"));
} finally {
cleanup(dir);
}
t.after(() => cleanup(dir));
writeFileSync(
join(dir, "package.json"),
JSON.stringify({
name: "test",
scripts: { test: "vitest", build: "tsc" },
}),
"utf-8",
);
writeFileSync(join(dir, "pnpm-lock.yaml"), "", "utf-8");
const signals = detectProjectSignals(dir);
assert.ok(signals.verificationCommands.includes("pnpm test"));
assert.ok(signals.verificationCommands.includes("pnpm run build"));
});
test("detectProjectSignals: Ruby project with rspec", () => {
test("detectProjectSignals: Ruby project with rspec", (t) => {
const dir = makeTempDir("signals-ruby");
try {
writeFileSync(join(dir, "Gemfile"), 'source "https://rubygems.org"\n', "utf-8");
mkdirSync(join(dir, "spec"), { recursive: true });
const signals = detectProjectSignals(dir);
assert.ok(signals.detectedFiles.includes("Gemfile"));
assert.equal(signals.primaryLanguage, "ruby");
assert.ok(signals.verificationCommands.includes("bundle exec rspec"));
} finally {
cleanup(dir);
}
t.after(() => cleanup(dir));
writeFileSync(join(dir, "Gemfile"), 'source "https://rubygems.org"\n', "utf-8");
mkdirSync(join(dir, "spec"), { recursive: true });
const signals = detectProjectSignals(dir);
assert.ok(signals.detectedFiles.includes("Gemfile"));
assert.equal(signals.primaryLanguage, "ruby");
assert.ok(signals.verificationCommands.includes("bundle exec rspec"));
});
test("detectProjectSignals: Makefile with test target", () => {
test("detectProjectSignals: Makefile with test target", (t) => {
const dir = makeTempDir("signals-make");
try {
writeFileSync(join(dir, "Makefile"), "test:\n\tgo test ./...\n\nbuild:\n\tgo build\n", "utf-8");
const signals = detectProjectSignals(dir);
assert.ok(signals.detectedFiles.includes("Makefile"));
assert.ok(signals.verificationCommands.includes("make test"));
} finally {
cleanup(dir);
}
t.after(() => cleanup(dir));
writeFileSync(join(dir, "Makefile"), "test:\n\tgo test ./...\n\nbuild:\n\tgo build\n", "utf-8");
const signals = detectProjectSignals(dir);
assert.ok(signals.detectedFiles.includes("Makefile"));
assert.ok(signals.verificationCommands.includes("make test"));
});

View file

@ -73,7 +73,7 @@ describe("DevWorkflowEngine", () => {
assert.equal(engine.engineId, "dev");
});
test("deriveState returns EngineState with expected fields", async () => {
test("deriveState returns EngineState with expected fields", async (t) => {
const { DevWorkflowEngine } = await import("../dev-workflow-engine.ts");
const engine = new DevWorkflowEngine();
@ -81,31 +81,29 @@ describe("DevWorkflowEngine", () => {
const tempDir = mkdtempSync(join(tmpdir(), "gsd-engine-test-"));
mkdirSync(join(tempDir, ".gsd", "milestones"), { recursive: true });
try {
const state = await engine.deriveState(tempDir);
t.after(() => rmSync(tempDir, { recursive: true, force: true }));
assert.equal(typeof state.phase, "string", "phase should be a string");
assert.ok(
"currentMilestoneId" in state,
"state should have currentMilestoneId",
);
assert.ok(
"activeSliceId" in state,
"state should have activeSliceId",
);
assert.ok(
"activeTaskId" in state,
"state should have activeTaskId",
);
assert.equal(
typeof state.isComplete,
"boolean",
"isComplete should be boolean",
);
assert.ok("raw" in state, "state should have raw field");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
const state = await engine.deriveState(tempDir);
assert.equal(typeof state.phase, "string", "phase should be a string");
assert.ok(
"currentMilestoneId" in state,
"state should have currentMilestoneId",
);
assert.ok(
"activeSliceId" in state,
"state should have activeSliceId",
);
assert.ok(
"activeTaskId" in state,
"state should have activeTaskId",
);
assert.equal(
typeof state.isComplete,
"boolean",
"isComplete should be boolean",
);
assert.ok("raw" in state, "state should have raw field");
});
test("reconcile returns continue for non-complete state", async () => {
@ -280,16 +278,14 @@ describe("Kill switch (GSD_ENGINE_BYPASS)", () => {
}
});
test("GSD_ENGINE_BYPASS=1 does not affect resolveEngine (bypass checked in autoLoop)", async () => {
test("GSD_ENGINE_BYPASS=1 does not affect resolveEngine (bypass checked in autoLoop)", async (t) => {
const { resolveEngine } = await import("../engine-resolver.ts");
process.env.GSD_ENGINE_BYPASS = "1";
try {
// resolveEngine should still resolve normally — bypass is checked in autoLoop
const { engine } = resolveEngine({ activeEngineId: null });
assert.ok(engine, "should return an engine even with bypass set");
} finally {
delete process.env.GSD_ENGINE_BYPASS;
}
t.after(() => delete process.env.GSD_ENGINE_BYPASS);
// resolveEngine should still resolve normally — bypass is checked in autoLoop
const { engine } = resolveEngine({ activeEngineId: null });
assert.ok(engine, "should return an engine even with bypass set");
});
});

View file

@ -20,215 +20,199 @@ function teardownRepo(repo: string): void {
rmSync(repo, { recursive: true, force: true });
}
test("dispatch guard blocks when prior milestone has incomplete slices", () => {
test("dispatch guard blocks when prior milestone has incomplete slices", (t) => {
const repo = setupRepo();
try {
mkdirSync(join(repo, ".gsd", "milestones", "M002"), { recursive: true });
mkdirSync(join(repo, ".gsd", "milestones", "M003"), { recursive: true });
t.after(() => teardownRepo(repo));
// Seed DB: M002 with S01 complete, S02 pending
insertMilestone({ id: "M002", title: "Previous" });
insertSlice({ id: "S01", milestoneId: "M002", title: "Done", status: "complete", depends: [], sequence: 1 });
insertSlice({ id: "S02", milestoneId: "M002", title: "Pending", status: "pending", depends: ["S01"], sequence: 2 });
mkdirSync(join(repo, ".gsd", "milestones", "M002"), { recursive: true });
mkdirSync(join(repo, ".gsd", "milestones", "M003"), { recursive: true });
// M003 with two pending slices
insertMilestone({ id: "M003", title: "Current" });
insertSlice({ id: "S01", milestoneId: "M003", title: "First", status: "pending", depends: [], sequence: 1 });
insertSlice({ id: "S02", milestoneId: "M003", title: "Second", status: "pending", depends: ["S01"], sequence: 2 });
// Seed DB: M002 with S01 complete, S02 pending
insertMilestone({ id: "M002", title: "Previous" });
insertSlice({ id: "S01", milestoneId: "M002", title: "Done", status: "complete", depends: [], sequence: 1 });
insertSlice({ id: "S02", milestoneId: "M002", title: "Pending", status: "pending", depends: ["S01"], sequence: 2 });
// Need ROADMAP files for milestone discovery (findMilestoneIds reads disk)
writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), "# M002\n");
writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), "# M003\n");
// M003 with two pending slices
insertMilestone({ id: "M003", title: "Current" });
insertSlice({ id: "S01", milestoneId: "M003", title: "First", status: "pending", depends: [], sequence: 1 });
insertSlice({ id: "S02", milestoneId: "M003", title: "Second", status: "pending", depends: ["S01"], sequence: 2 });
assert.equal(
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M003/S01"),
"Cannot dispatch plan-slice M003/S01: earlier slice M002/S02 is not complete.",
);
} finally {
teardownRepo(repo);
}
// Need ROADMAP files for milestone discovery (findMilestoneIds reads disk)
writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), "# M002\n");
writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), "# M003\n");
assert.equal(
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M003/S01"),
"Cannot dispatch plan-slice M003/S01: earlier slice M002/S02 is not complete.",
);
});
test("dispatch guard blocks later slice in same milestone when earlier incomplete", () => {
test("dispatch guard blocks later slice in same milestone when earlier incomplete", (t) => {
const repo = setupRepo();
try {
mkdirSync(join(repo, ".gsd", "milestones", "M002"), { recursive: true });
mkdirSync(join(repo, ".gsd", "milestones", "M003"), { recursive: true });
t.after(() => teardownRepo(repo));
insertMilestone({ id: "M002", title: "Previous" });
insertSlice({ id: "S01", milestoneId: "M002", title: "Done", status: "complete", depends: [], sequence: 1 });
insertSlice({ id: "S02", milestoneId: "M002", title: "Done", status: "complete", depends: ["S01"], sequence: 2 });
mkdirSync(join(repo, ".gsd", "milestones", "M002"), { recursive: true });
mkdirSync(join(repo, ".gsd", "milestones", "M003"), { recursive: true });
insertMilestone({ id: "M003", title: "Current" });
insertSlice({ id: "S01", milestoneId: "M003", title: "First", status: "pending", depends: [], sequence: 1 });
insertSlice({ id: "S02", milestoneId: "M003", title: "Second", status: "pending", depends: ["S01"], sequence: 2 });
insertMilestone({ id: "M002", title: "Previous" });
insertSlice({ id: "S01", milestoneId: "M002", title: "Done", status: "complete", depends: [], sequence: 1 });
insertSlice({ id: "S02", milestoneId: "M002", title: "Done", status: "complete", depends: ["S01"], sequence: 2 });
writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), "# M002\n");
writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), "# M003\n");
insertMilestone({ id: "M003", title: "Current" });
insertSlice({ id: "S01", milestoneId: "M003", title: "First", status: "pending", depends: [], sequence: 1 });
insertSlice({ id: "S02", milestoneId: "M003", title: "Second", status: "pending", depends: ["S01"], sequence: 2 });
assert.equal(
getPriorSliceCompletionBlocker(repo, "main", "execute-task", "M003/S02/T01"),
"Cannot dispatch execute-task M003/S02/T01: dependency slice M003/S01 is not complete.",
);
} finally {
teardownRepo(repo);
}
writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), "# M002\n");
writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), "# M003\n");
assert.equal(
getPriorSliceCompletionBlocker(repo, "main", "execute-task", "M003/S02/T01"),
"Cannot dispatch execute-task M003/S02/T01: dependency slice M003/S01 is not complete.",
);
});
test("dispatch guard allows dispatch when all earlier slices complete", () => {
test("dispatch guard allows dispatch when all earlier slices complete", (t) => {
const repo = setupRepo();
try {
mkdirSync(join(repo, ".gsd", "milestones", "M003"), { recursive: true });
t.after(() => teardownRepo(repo));
insertMilestone({ id: "M003", title: "Current" });
insertSlice({ id: "S01", milestoneId: "M003", title: "First", status: "complete", depends: [], sequence: 1 });
insertSlice({ id: "S02", milestoneId: "M003", title: "Second", status: "pending", depends: ["S01"], sequence: 2 });
mkdirSync(join(repo, ".gsd", "milestones", "M003"), { recursive: true });
writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), "# M003\n");
insertMilestone({ id: "M003", title: "Current" });
insertSlice({ id: "S01", milestoneId: "M003", title: "First", status: "complete", depends: [], sequence: 1 });
insertSlice({ id: "S02", milestoneId: "M003", title: "Second", status: "pending", depends: ["S01"], sequence: 2 });
assert.equal(getPriorSliceCompletionBlocker(repo, "main", "execute-task", "M003/S02/T01"), null);
assert.equal(getPriorSliceCompletionBlocker(repo, "main", "plan-milestone", "M003"), null);
} finally {
teardownRepo(repo);
}
writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), "# M003\n");
assert.equal(getPriorSliceCompletionBlocker(repo, "main", "execute-task", "M003/S02/T01"), null);
assert.equal(getPriorSliceCompletionBlocker(repo, "main", "plan-milestone", "M003"), null);
});
test("dispatch guard unblocks slice when positionally-earlier slice depends on it (#1638)", () => {
test("dispatch guard unblocks slice when positionally-earlier slice depends on it (#1638)", (t) => {
// S05 depends on S06, but S05 appears first positionally.
// Old behavior: S06 blocked because S05 (positionally earlier) is incomplete.
// Fixed behavior: S06 has no unmet dependencies, so it can dispatch.
const repo = setupRepo();
try {
mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true });
t.after(() => teardownRepo(repo));
insertMilestone({ id: "M001", title: "Test" });
insertSlice({ id: "S01", milestoneId: "M001", title: "Setup", status: "complete", depends: [], sequence: 1 });
insertSlice({ id: "S02", milestoneId: "M001", title: "Core", status: "complete", depends: ["S01"], sequence: 2 });
insertSlice({ id: "S03", milestoneId: "M001", title: "API", status: "complete", depends: ["S02"], sequence: 3 });
insertSlice({ id: "S04", milestoneId: "M001", title: "Auth", status: "complete", depends: ["S03"], sequence: 4 });
insertSlice({ id: "S05", milestoneId: "M001", title: "Integration", status: "pending", depends: ["S04", "S06"], sequence: 5 });
insertSlice({ id: "S06", milestoneId: "M001", title: "Data Layer", status: "pending", depends: ["S04"], sequence: 6 });
mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true });
writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n");
insertMilestone({ id: "M001", title: "Test" });
insertSlice({ id: "S01", milestoneId: "M001", title: "Setup", status: "complete", depends: [], sequence: 1 });
insertSlice({ id: "S02", milestoneId: "M001", title: "Core", status: "complete", depends: ["S01"], sequence: 2 });
insertSlice({ id: "S03", milestoneId: "M001", title: "API", status: "complete", depends: ["S02"], sequence: 3 });
insertSlice({ id: "S04", milestoneId: "M001", title: "Auth", status: "complete", depends: ["S03"], sequence: 4 });
insertSlice({ id: "S05", milestoneId: "M001", title: "Integration", status: "pending", depends: ["S04", "S06"], sequence: 5 });
insertSlice({ id: "S06", milestoneId: "M001", title: "Data Layer", status: "pending", depends: ["S04"], sequence: 6 });
// S06 depends only on S04 (complete) — should be unblocked
assert.equal(
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S06"),
null,
);
writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n");
// S05 depends on S04 (complete) and S06 (incomplete) — should be blocked
assert.equal(
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S05"),
"Cannot dispatch plan-slice M001/S05: dependency slice M001/S06 is not complete.",
);
} finally {
teardownRepo(repo);
}
// S06 depends only on S04 (complete) — should be unblocked
assert.equal(
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S06"),
null,
);
// S05 depends on S04 (complete) and S06 (incomplete) — should be blocked
assert.equal(
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S05"),
"Cannot dispatch plan-slice M001/S05: dependency slice M001/S06 is not complete.",
);
});
test("dispatch guard falls back to positional ordering when no dependencies declared", () => {
test("dispatch guard falls back to positional ordering when no dependencies declared", (t) => {
const repo = setupRepo();
try {
mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true });
t.after(() => teardownRepo(repo));
insertMilestone({ id: "M001", title: "Test" });
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete", depends: [], sequence: 1 });
insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "pending", depends: [], sequence: 2 });
insertSlice({ id: "S03", milestoneId: "M001", title: "Third", status: "pending", depends: [], sequence: 3 });
mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true });
writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n");
insertMilestone({ id: "M001", title: "Test" });
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete", depends: [], sequence: 1 });
insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "pending", depends: [], sequence: 2 });
insertSlice({ id: "S03", milestoneId: "M001", title: "Third", status: "pending", depends: [], sequence: 3 });
// S03 has no dependencies — positional fallback blocks on S02
assert.equal(
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S03"),
"Cannot dispatch plan-slice M001/S03: earlier slice M001/S02 is not complete.",
);
writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n");
// S02 has no dependencies — positional fallback: S01 is done, so unblocked
assert.equal(
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S02"),
null,
);
} finally {
teardownRepo(repo);
}
// S03 has no dependencies — positional fallback blocks on S02
assert.equal(
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S03"),
"Cannot dispatch plan-slice M001/S03: earlier slice M001/S02 is not complete.",
);
// S02 has no dependencies — positional fallback: S01 is done, so unblocked
assert.equal(
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S02"),
null,
);
});
test("dispatch guard allows slice with all declared dependencies complete", () => {
test("dispatch guard allows slice with all declared dependencies complete", (t) => {
const repo = setupRepo();
try {
mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true });
t.after(() => teardownRepo(repo));
insertMilestone({ id: "M001", title: "Test" });
insertSlice({ id: "S01", milestoneId: "M001", title: "Setup", status: "complete", depends: [], sequence: 1 });
insertSlice({ id: "S02", milestoneId: "M001", title: "Core", status: "complete", depends: ["S01"], sequence: 2 });
insertSlice({ id: "S03", milestoneId: "M001", title: "Feature A", status: "pending", depends: ["S01", "S02"], sequence: 3 });
insertSlice({ id: "S04", milestoneId: "M001", title: "Feature B", status: "pending", depends: ["S01"], sequence: 4 });
mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true });
writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n");
insertMilestone({ id: "M001", title: "Test" });
insertSlice({ id: "S01", milestoneId: "M001", title: "Setup", status: "complete", depends: [], sequence: 1 });
insertSlice({ id: "S02", milestoneId: "M001", title: "Core", status: "complete", depends: ["S01"], sequence: 2 });
insertSlice({ id: "S03", milestoneId: "M001", title: "Feature A", status: "pending", depends: ["S01", "S02"], sequence: 3 });
insertSlice({ id: "S04", milestoneId: "M001", title: "Feature B", status: "pending", depends: ["S01"], sequence: 4 });
// S03 depends on S01 (done) and S02 (done) — unblocked
assert.equal(
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S03"),
null,
);
writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n");
// S04 depends only on S01 (done) — unblocked even though S03 is incomplete
assert.equal(
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S04"),
null,
);
} finally {
teardownRepo(repo);
}
// S03 depends on S01 (done) and S02 (done) — unblocked
assert.equal(
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S03"),
null,
);
// S04 depends only on S01 (done) — unblocked even though S03 is incomplete
assert.equal(
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S04"),
null,
);
});
test("dispatch guard skips completed milestone with SUMMARY even if it has unchecked remediation slices (#1716)", () => {
test("dispatch guard skips completed milestone with SUMMARY even if it has unchecked remediation slices (#1716)", (t) => {
const repo = setupRepo();
try {
mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true });
mkdirSync(join(repo, ".gsd", "milestones", "M002"), { recursive: true });
t.after(() => teardownRepo(repo));
// M001 is complete (has SUMMARY) but has unchecked remediation slices in DB
insertMilestone({ id: "M001", title: "Previous" });
insertSlice({ id: "S01", milestoneId: "M001", title: "Core", status: "complete", depends: [], sequence: 1 });
insertSlice({ id: "S02", milestoneId: "M001", title: "Tests", status: "complete", depends: ["S01"], sequence: 2 });
insertSlice({ id: "S03-R", milestoneId: "M001", title: "Remediation", status: "pending", depends: ["S02"], sequence: 3 });
insertSlice({ id: "S04-R", milestoneId: "M001", title: "Remediation 2", status: "pending", depends: ["S02"], sequence: 4 });
mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true });
mkdirSync(join(repo, ".gsd", "milestones", "M002"), { recursive: true });
insertMilestone({ id: "M002", title: "Current" });
insertSlice({ id: "S01", milestoneId: "M002", title: "Start", status: "pending", depends: [], sequence: 1 });
// M001 is complete (has SUMMARY) but has unchecked remediation slices in DB
insertMilestone({ id: "M001", title: "Previous" });
insertSlice({ id: "S01", milestoneId: "M001", title: "Core", status: "complete", depends: [], sequence: 1 });
insertSlice({ id: "S02", milestoneId: "M001", title: "Tests", status: "complete", depends: ["S01"], sequence: 2 });
insertSlice({ id: "S03-R", milestoneId: "M001", title: "Remediation", status: "pending", depends: ["S02"], sequence: 3 });
insertSlice({ id: "S04-R", milestoneId: "M001", title: "Remediation 2", status: "pending", depends: ["S02"], sequence: 4 });
// M001 SUMMARY on disk triggers skip
writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n");
writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-SUMMARY.md"),
"---\nstatus: complete\n---\n# M001 Summary\nDone.\n");
writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), "# M002\n");
insertMilestone({ id: "M002", title: "Current" });
insertSlice({ id: "S01", milestoneId: "M002", title: "Start", status: "pending", depends: [], sequence: 1 });
// M001 has SUMMARY — should be skipped, not block M002/S01
assert.equal(
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M002/S01"),
null,
);
} finally {
teardownRepo(repo);
}
// M001 SUMMARY on disk triggers skip
writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n");
writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-SUMMARY.md"),
"---\nstatus: complete\n---\n# M001 Summary\nDone.\n");
writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), "# M002\n");
// M001 has SUMMARY — should be skipped, not block M002/S01
assert.equal(
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M002/S01"),
null,
);
});
test("dispatch guard works without git repo", () => {
test("dispatch guard works without git repo", (t) => {
const repo = setupRepo();
try {
mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true });
t.after(() => teardownRepo(repo));
insertMilestone({ id: "M001", title: "Test" });
insertSlice({ id: "S01", milestoneId: "M001", title: "Done", status: "complete", depends: [], sequence: 1 });
insertSlice({ id: "S02", milestoneId: "M001", title: "Pending", status: "pending", depends: ["S01"], sequence: 2 });
mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true });
writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n");
insertMilestone({ id: "M001", title: "Test" });
insertSlice({ id: "S01", milestoneId: "M001", title: "Done", status: "complete", depends: [], sequence: 1 });
insertSlice({ id: "S02", milestoneId: "M001", title: "Pending", status: "pending", depends: ["S01"], sequence: 2 });
assert.equal(getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S02"), null);
} finally {
teardownRepo(repo);
}
writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n");
assert.equal(getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S02"), null);
});

View file

@ -71,62 +71,56 @@ function scaffoldTaskPlan(basePath: string, mid: string, sid: string, tid: strin
// ─── Tests ─────────────────────────────────────────────────────────────────
test("dispatch: missing task plan triggers plan-slice (not stop) — issue #909", async () => {
test("dispatch: missing task plan triggers plan-slice (not stop) — issue #909", async (t) => {
const tmp = mkdtempSync(join(tmpdir(), "gsd-909-"));
try {
// Slice plan exists with tasks, but tasks/ directory is empty
scaffoldSlicePlan(tmp, "M002", "S03");
t.after(() => rmSync(tmp, { recursive: true, force: true }));
const ctx = makeContext(tmp);
const result = await resolveDispatch(ctx);
// Slice plan exists with tasks, but tasks/ directory is empty
scaffoldSlicePlan(tmp, "M002", "S03");
assert.equal(result.action, "dispatch", "should dispatch, not stop");
assert.ok(result.action === "dispatch" && result.unitType === "plan-slice",
`unitType should be plan-slice, got: ${result.action === "dispatch" ? result.unitType : "(stop)"}`);
assert.ok(result.action === "dispatch" && result.unitId === "M002/S03",
`unitId should be M002/S03, got: ${result.action === "dispatch" ? result.unitId : "(stop)"}`);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
const ctx = makeContext(tmp);
const result = await resolveDispatch(ctx);
assert.equal(result.action, "dispatch", "should dispatch, not stop");
assert.ok(result.action === "dispatch" && result.unitType === "plan-slice",
`unitType should be plan-slice, got: ${result.action === "dispatch" ? result.unitType : "(stop)"}`);
assert.ok(result.action === "dispatch" && result.unitId === "M002/S03",
`unitId should be M002/S03, got: ${result.action === "dispatch" ? result.unitId : "(stop)"}`);
});
test("dispatch: present task plan proceeds to execute-task normally", async () => {
test("dispatch: present task plan proceeds to execute-task normally", async (t) => {
const tmp = mkdtempSync(join(tmpdir(), "gsd-909-ok-"));
try {
scaffoldSlicePlan(tmp, "M002", "S03");
scaffoldTaskPlan(tmp, "M002", "S03", "T01");
t.after(() => rmSync(tmp, { recursive: true, force: true }));
const ctx = makeContext(tmp);
const result = await resolveDispatch(ctx);
scaffoldSlicePlan(tmp, "M002", "S03");
scaffoldTaskPlan(tmp, "M002", "S03", "T01");
assert.equal(result.action, "dispatch");
assert.ok(result.action === "dispatch" && result.unitType === "execute-task",
`unitType should be execute-task, got: ${result.action === "dispatch" ? result.unitType : "(stop)"}`);
assert.ok(result.action === "dispatch" && result.unitId === "M002/S03/T01",
`unitId should be M002/S03/T01, got: ${result.action === "dispatch" ? result.unitId : "(stop)"}`);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
const ctx = makeContext(tmp);
const result = await resolveDispatch(ctx);
assert.equal(result.action, "dispatch");
assert.ok(result.action === "dispatch" && result.unitType === "execute-task",
`unitType should be execute-task, got: ${result.action === "dispatch" ? result.unitType : "(stop)"}`);
assert.ok(result.action === "dispatch" && result.unitId === "M002/S03/T01",
`unitId should be M002/S03/T01, got: ${result.action === "dispatch" ? result.unitId : "(stop)"}`);
});
test("dispatch: plan-slice recovery loop — second call after plan-slice still recovers cleanly", async () => {
test("dispatch: plan-slice recovery loop — second call after plan-slice still recovers cleanly", async (t) => {
// Simulate: plan-slice ran but T01-PLAN.md is still missing (e.g. agent crashed mid-write).
// Dispatch should still re-dispatch plan-slice, not hard-stop.
const tmp = mkdtempSync(join(tmpdir(), "gsd-909-loop-"));
try {
scaffoldSlicePlan(tmp, "M002", "S03");
t.after(() => rmSync(tmp, { recursive: true, force: true }));
const ctx = makeContext(tmp);
const r1 = await resolveDispatch(ctx);
assert.equal(r1.action, "dispatch");
assert.ok(r1.action === "dispatch" && r1.unitType === "plan-slice");
scaffoldSlicePlan(tmp, "M002", "S03");
// Still no task plan written — dispatch again
const r2 = await resolveDispatch(ctx);
assert.equal(r2.action, "dispatch");
assert.ok(r2.action === "dispatch" && r2.unitType === "plan-slice",
"should keep dispatching plan-slice until task plans appear");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
const ctx = makeContext(tmp);
const r1 = await resolveDispatch(ctx);
assert.equal(r1.action, "dispatch");
assert.ok(r1.action === "dispatch" && r1.unitType === "plan-slice");
// Still no task plan written — dispatch again
const r2 = await resolveDispatch(ctx);
assert.equal(r2.action, "dispatch");
assert.ok(r2.action === "dispatch" && r2.unitType === "plan-slice",
"should keep dispatching plan-slice until task plans appear");
});

View file

@ -66,7 +66,7 @@ function createFixture(): string {
return base;
}
test("dispatch uat targets last completed slice, not activeSlice (#1693)", async () => {
test("dispatch uat targets last completed slice, not activeSlice (#1693)", async (t) => {
const base = createFixture();
invalidateStateCache();
@ -88,31 +88,29 @@ test("dispatch uat targets last completed slice, not activeSlice (#1693)", async
},
} as any;
try {
await dispatchDirectPhase(ctx, pi, "uat", base);
t.after(() => rmSync(base, { recursive: true, force: true }));
// Should have dispatched (sendMessage called)
assert.ok(sentPrompt, "sendMessage should have been called with a prompt");
await dispatchDirectPhase(ctx, pi, "uat", base);
// The dispatch notification should reference M001/S01 (completed), not M001/S02 (active)
const dispatchNotification = notifications.find(n => n.message.startsWith("Dispatching"));
assert.ok(dispatchNotification, "dispatch notification should be present");
assert.match(
dispatchNotification.message,
/M001\/S01/,
"dispatch should target completed slice S01, not active slice S02",
);
assert.doesNotMatch(
dispatchNotification.message,
/M001\/S02/,
"dispatch should NOT target active (next incomplete) slice S02",
);
} finally {
rmSync(base, { recursive: true, force: true });
}
// Should have dispatched (sendMessage called)
assert.ok(sentPrompt, "sendMessage should have been called with a prompt");
// The dispatch notification should reference M001/S01 (completed), not M001/S02 (active)
const dispatchNotification = notifications.find(n => n.message.startsWith("Dispatching"));
assert.ok(dispatchNotification, "dispatch notification should be present");
assert.match(
dispatchNotification.message,
/M001\/S01/,
"dispatch should target completed slice S01, not active slice S02",
);
assert.doesNotMatch(
dispatchNotification.message,
/M001\/S02/,
"dispatch should NOT target active (next incomplete) slice S02",
);
});
test("dispatch uat warns when no completed slices exist", async () => {
test("dispatch uat warns when no completed slices exist", async (t) => {
const base = mkdtempSync(join(tmpdir(), "gsd-dispatch-uat-none-"));
invalidateStateCache();
@ -164,13 +162,11 @@ test("dispatch uat warns when no completed slices exist", async () => {
},
} as any;
try {
await dispatchDirectPhase(ctx, pi, "uat", base);
t.after(() => rmSync(base, { recursive: true, force: true }));
const warning = notifications.find(n => n.level === "warning");
assert.ok(warning, "should show a warning notification");
assert.match(warning.message, /no completed slices/, "warning should mention no completed slices");
} finally {
rmSync(base, { recursive: true, force: true });
}
await dispatchDirectPhase(ctx, pi, "uat", base);
const warning = notifications.find(n => n.level === "warning");
assert.ok(warning, "should show a warning notification");
assert.match(warning.message, /no completed slices/, "warning should mention no completed slices");
});

View file

@ -56,35 +56,33 @@ Done.
`);
}
test("doctor does not report any reconciliation issue codes", async () => {
test("doctor does not report any reconciliation issue codes", async (t) => {
const tmp = makeTmp("no-reconciliation");
try {
buildScaffold(tmp);
t.after(() => rmSync(tmp, { recursive: true, force: true }));
const report = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" });
buildScaffold(tmp);
const REMOVED_CODES = [
"task_done_missing_summary",
"task_summary_without_done_checkbox",
"all_tasks_done_missing_slice_summary",
"all_tasks_done_missing_slice_uat",
"all_tasks_done_roadmap_not_checked",
"slice_checked_missing_summary",
"slice_checked_missing_uat",
];
const report = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" });
const codes = report.issues.map(i => i.code);
for (const removed of REMOVED_CODES) {
assert.ok(!codes.includes(removed as any), `should NOT report removed code: ${removed}`);
}
const REMOVED_CODES = [
"task_done_missing_summary",
"task_summary_without_done_checkbox",
"all_tasks_done_missing_slice_summary",
"all_tasks_done_missing_slice_uat",
"all_tasks_done_roadmap_not_checked",
"slice_checked_missing_summary",
"slice_checked_missing_uat",
];
// No summary or UAT stubs should be created
const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md");
assert.ok(!existsSync(sliceSummaryPath), "should NOT have created summary stub");
const sliceUatPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-UAT.md");
assert.ok(!existsSync(sliceUatPath), "should NOT have created UAT stub");
} finally {
rmSync(tmp, { recursive: true, force: true });
const codes = report.issues.map(i => i.code);
for (const removed of REMOVED_CODES) {
assert.ok(!codes.includes(removed as any), `should NOT report removed code: ${removed}`);
}
// No summary or UAT stubs should be created
const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md");
assert.ok(!existsSync(sliceSummaryPath), "should NOT have created summary stub");
const sliceUatPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-UAT.md");
assert.ok(!existsSync(sliceUatPath), "should NOT have created UAT stub");
});

View file

@ -12,7 +12,7 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
import { runGSDDoctor } from "../doctor.js";
test("doctor fix=true sanitizes em-dash in milestone title", async () => {
test("doctor fix=true sanitizes em-dash in milestone title", async (t) => {
const tmpBase = mkdtempSync(join(tmpdir(), "gsd-doctor-delim-"));
const gsd = join(tmpBase, ".gsd");
const mDir = join(gsd, "milestones", "M001");
@ -34,33 +34,31 @@ test("doctor fix=true sanitizes em-dash in milestone title", async () => {
writeFileSync(join(sDir, "S01-PLAN.md"), `# S01: Initial Setup\n\n## Tasks\n- [ ] **T01: Scaffold** \`est:15m\`\n`);
writeFileSync(join(tDir, "T01-PLAN.md"), "# T01: Scaffold\n");
try {
// Run doctor with fix=true
const report = await runGSDDoctor(tmpBase, { fix: true });
t.after(() => rmSync(tmpBase, { recursive: true, force: true }));
// The em-dash should have been replaced
const fixed = readFileSync(join(mDir, "M001-ROADMAP.md"), "utf-8");
const h1 = fixed.split("\n").find(l => l.startsWith("# "))!;
assert.ok(h1, "H1 line should exist");
assert.ok(!h1.includes("\u2014"), "em-dash should be replaced");
assert.ok(!h1.includes("\u2013"), "en-dash should be replaced");
assert.ok(h1.includes("-"), "should contain ASCII hyphen as replacement");
// Run doctor with fix=true
const report = await runGSDDoctor(tmpBase, { fix: true });
// Should have recorded the fix
assert.ok(
report.fixesApplied.some(f => f.includes("sanitized")),
`fixesApplied should mention sanitization, got: ${JSON.stringify(report.fixesApplied)}`,
);
// The em-dash should have been replaced
const fixed = readFileSync(join(mDir, "M001-ROADMAP.md"), "utf-8");
const h1 = fixed.split("\n").find(l => l.startsWith("# "))!;
assert.ok(h1, "H1 line should exist");
assert.ok(!h1.includes("\u2014"), "em-dash should be replaced");
assert.ok(!h1.includes("\u2013"), "en-dash should be replaced");
assert.ok(h1.includes("-"), "should contain ASCII hyphen as replacement");
// The issue should NOT appear in the report (it was fixed)
const delimIssues = report.issues.filter(i => i.code === "delimiter_in_title" && i.unitId === "M001");
assert.equal(delimIssues.length, 0, "fixed issue should not appear in issues list");
} finally {
rmSync(tmpBase, { recursive: true, force: true });
}
// Should have recorded the fix
assert.ok(
report.fixesApplied.some(f => f.includes("sanitized")),
`fixesApplied should mention sanitization, got: ${JSON.stringify(report.fixesApplied)}`,
);
// The issue should NOT appear in the report (it was fixed)
const delimIssues = report.issues.filter(i => i.code === "delimiter_in_title" && i.unitId === "M001");
assert.equal(delimIssues.length, 0, "fixed issue should not appear in issues list");
});
test("doctor fix=false still reports delimiter_in_title as warning", async () => {
test("doctor fix=false still reports delimiter_in_title as warning", async (t) => {
const tmpBase = mkdtempSync(join(tmpdir(), "gsd-doctor-delim-nf-"));
const gsd = join(tmpBase, ".gsd");
const mDir = join(gsd, "milestones", "M001");
@ -72,16 +70,14 @@ test("doctor fix=false still reports delimiter_in_title as warning", async () =>
writeFileSync(join(sDir, "S01-PLAN.md"), `# S01: Setup\n\n## Tasks\n- [ ] **T01: Init** \`est:10m\`\n`);
writeFileSync(join(tDir, "T01-PLAN.md"), "# T01: Init\n");
try {
const report = await runGSDDoctor(tmpBase, { fix: false });
const delimIssues = report.issues.filter(i => i.code === "delimiter_in_title");
assert.ok(delimIssues.length > 0, "should report delimiter_in_title as issue when fix=false");
assert.equal(delimIssues[0].severity, "warning");
t.after(() => rmSync(tmpBase, { recursive: true, force: true }));
// File should be unchanged
const content = readFileSync(join(mDir, "M001-ROADMAP.md"), "utf-8");
assert.ok(content.includes("\u2014"), "file should not be modified when fix=false");
} finally {
rmSync(tmpBase, { recursive: true, force: true });
}
const report = await runGSDDoctor(tmpBase, { fix: false });
const delimIssues = report.issues.filter(i => i.code === "delimiter_in_title");
assert.ok(delimIssues.length > 0, "should report delimiter_in_title as issue when fix=false");
assert.equal(delimIssues[0].severity, "warning");
// File should be unchanged
const content = readFileSync(join(mDir, "M001-ROADMAP.md"), "utf-8");
assert.ok(content.includes("\u2014"), "file should not be modified when fix=false");
});

View file

@ -76,56 +76,53 @@ const REMOVED_CODES = [
"slice_checked_missing_uat",
];
test("fixLevel:task — no reconciliation issue codes are reported", async () => {
test("fixLevel:task — no reconciliation issue codes are reported", async (t) => {
const tmp = makeTmp("task-level");
try {
buildScaffold(tmp);
t.after(() => rmSync(tmp, { recursive: true, force: true }));
const report = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" });
buildScaffold(tmp);
const codes = report.issues.map(i => i.code);
for (const removed of REMOVED_CODES) {
assert.ok(!codes.includes(removed as any), `should NOT report removed code: ${removed}`);
}
} finally {
rmSync(tmp, { recursive: true, force: true });
const report = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" });
const codes = report.issues.map(i => i.code);
for (const removed of REMOVED_CODES) {
assert.ok(!codes.includes(removed as any), `should NOT report removed code: ${removed}`);
}
});
test("fixLevel:all — no reconciliation issue codes are reported", async () => {
test("fixLevel:all — no reconciliation issue codes are reported", async (t) => {
const tmp = makeTmp("all-level");
try {
buildScaffold(tmp);
t.after(() => rmSync(tmp, { recursive: true, force: true }));
const report = await runGSDDoctor(tmp, { fix: true });
buildScaffold(tmp);
const codes = report.issues.map(i => i.code);
for (const removed of REMOVED_CODES) {
assert.ok(!codes.includes(removed as any), `should NOT report removed code: ${removed}`);
}
const report = await runGSDDoctor(tmp, { fix: true });
// Summary and UAT stubs should NOT be created (no reconciliation)
const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md");
assert.ok(!existsSync(sliceSummaryPath), "should NOT have created summary stub");
// Roadmap should remain unchecked (no reconciliation)
const roadmapContent = readFileSync(join(tmp, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "utf8");
assert.ok(roadmapContent.includes("- [ ] **S01"), "roadmap should remain unchecked");
} finally {
rmSync(tmp, { recursive: true, force: true });
const codes = report.issues.map(i => i.code);
for (const removed of REMOVED_CODES) {
assert.ok(!codes.includes(removed as any), `should NOT report removed code: ${removed}`);
}
// Summary and UAT stubs should NOT be created (no reconciliation)
const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md");
assert.ok(!existsSync(sliceSummaryPath), "should NOT have created summary stub");
// Roadmap should remain unchecked (no reconciliation)
const roadmapContent = readFileSync(join(tmp, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "utf8");
assert.ok(roadmapContent.includes("- [ ] **S01"), "roadmap should remain unchecked");
});
test("fixLevel:all — delimiter_in_title still fixable", async () => {
test("fixLevel:all — delimiter_in_title still fixable", async (t) => {
const tmp = makeTmp("delimiter-fix");
try {
const gsd = join(tmp, ".gsd");
const m = join(gsd, "milestones", "M001");
const s = join(m, "slices", "S01", "tasks");
mkdirSync(s, { recursive: true });
t.after(() => rmSync(tmp, { recursive: true, force: true }));
// Roadmap with em dash in milestone title (should still be fixable)
writeFileSync(join(m, "M001-ROADMAP.md"), `# M001: Foundation \u2014 Build Core
const gsd = join(tmp, ".gsd");
const m = join(gsd, "milestones", "M001");
const s = join(m, "slices", "S01", "tasks");
mkdirSync(s, { recursive: true });
// Roadmap with em dash in milestone title (should still be fixable)
writeFileSync(join(m, "M001-ROADMAP.md"), `# M001: Foundation \u2014 Build Core
## Slices
@ -133,7 +130,7 @@ test("fixLevel:all — delimiter_in_title still fixable", async () => {
> Demo
`);
writeFileSync(join(m, "slices", "S01", "S01-PLAN.md"), `# S01: Test Slice
writeFileSync(join(m, "slices", "S01", "S01-PLAN.md"), `# S01: Test Slice
**Goal:** test
@ -142,13 +139,10 @@ test("fixLevel:all — delimiter_in_title still fixable", async () => {
- [ ] **T01: Do stuff** \`est:5m\`
`);
const report = await runGSDDoctor(tmp, { fix: true });
const report = await runGSDDoctor(tmp, { fix: true });
const delimiterIssues = report.issues.filter(i => i.code === "delimiter_in_title");
// The milestone-level delimiter is auto-fixed, but the report may or may not include it
// depending on whether it was fixed successfully. Just verify it ran without crashing.
assert.ok(report.issues !== undefined, "doctor produces a report");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
const delimiterIssues = report.issues.filter(i => i.code === "delimiter_in_title");
// The milestone-level delimiter is auto-fixed, but the report may or may not include it
// depending on whether it was fixed successfully. Just verify it ran without crashing.
assert.ok(report.issues !== undefined, "doctor produces a report");
});

View file

@ -58,72 +58,66 @@ Done.
`);
}
test("fixLevel:task — roadmap checkbox is never toggled by doctor (reconciliation removed)", async () => {
test("fixLevel:task — roadmap checkbox is never toggled by doctor (reconciliation removed)", async (t) => {
const tmp = makeTmp("no-roadmap-toggle");
try {
buildScaffold(tmp);
t.after(() => rmSync(tmp, { recursive: true, force: true }));
const report = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" });
buildScaffold(tmp);
// Roadmap must remain unchecked — doctor no longer touches checkboxes
const roadmapContent = readFileSync(join(tmp, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "utf8");
assert.ok(
roadmapContent.includes("- [ ] **S01"),
"roadmap should remain unchecked — doctor no longer toggles checkboxes"
);
const report = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" });
// No summary or UAT stubs created
const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md");
assert.ok(!existsSync(sliceSummaryPath), "summary should NOT be created");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
// Roadmap must remain unchecked — doctor no longer touches checkboxes
const roadmapContent = readFileSync(join(tmp, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "utf8");
assert.ok(
roadmapContent.includes("- [ ] **S01"),
"roadmap should remain unchecked — doctor no longer toggles checkboxes"
);
// No summary or UAT stubs created
const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md");
assert.ok(!existsSync(sliceSummaryPath), "summary should NOT be created");
});
test("fixLevel:all — roadmap checkbox is never toggled by doctor (reconciliation removed)", async () => {
test("fixLevel:all — roadmap checkbox is never toggled by doctor (reconciliation removed)", async (t) => {
const tmp = makeTmp("all-no-toggle");
try {
buildScaffold(tmp);
t.after(() => rmSync(tmp, { recursive: true, force: true }));
const report = await runGSDDoctor(tmp, { fix: true });
buildScaffold(tmp);
// Even at fixLevel:all, doctor no longer creates stubs or toggles checkboxes
const roadmapContent = readFileSync(join(tmp, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "utf8");
assert.ok(
roadmapContent.includes("- [ ] **S01"),
"roadmap should remain unchecked — reconciliation removed"
);
const report = await runGSDDoctor(tmp, { fix: true });
const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md");
assert.ok(!existsSync(sliceSummaryPath), "summary should NOT be created");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
// Even at fixLevel:all, doctor no longer creates stubs or toggles checkboxes
const roadmapContent = readFileSync(join(tmp, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "utf8");
assert.ok(
roadmapContent.includes("- [ ] **S01"),
"roadmap should remain unchecked — reconciliation removed"
);
const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md");
assert.ok(!existsSync(sliceSummaryPath), "summary should NOT be created");
});
test("consecutive doctor runs produce no reconciliation codes", async () => {
test("consecutive doctor runs produce no reconciliation codes", async (t) => {
const tmp = makeTmp("consecutive-clean");
try {
buildScaffold(tmp);
t.after(() => rmSync(tmp, { recursive: true, force: true }));
await runGSDDoctor(tmp, { fix: true, fixLevel: "task" });
const report2 = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" });
buildScaffold(tmp);
const REMOVED_CODES = [
"task_done_missing_summary",
"task_summary_without_done_checkbox",
"all_tasks_done_missing_slice_summary",
"all_tasks_done_missing_slice_uat",
"all_tasks_done_roadmap_not_checked",
"slice_checked_missing_summary",
"slice_checked_missing_uat",
];
await runGSDDoctor(tmp, { fix: true, fixLevel: "task" });
const report2 = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" });
const codes = report2.issues.map(i => i.code);
for (const removed of REMOVED_CODES) {
assert.ok(!codes.includes(removed as any), `should NOT report removed code: ${removed}`);
}
} finally {
rmSync(tmp, { recursive: true, force: true });
const REMOVED_CODES = [
"task_done_missing_summary",
"task_summary_without_done_checkbox",
"all_tasks_done_missing_slice_summary",
"all_tasks_done_missing_slice_uat",
"all_tasks_done_roadmap_not_checked",
"slice_checked_missing_summary",
"slice_checked_missing_uat",
];
const codes = report2.issues.map(i => i.code);
for (const removed of REMOVED_CODES) {
assert.ok(!codes.includes(removed as any), `should NOT report removed code: ${removed}`);
}
});