fix: add missing milestones/ segment in resolveHookArtifactPath (#1779)

resolveHookArtifactPath() built paths as .gsd/<MID>/slices/... instead
of .gsd/milestones/<MID>/slices/..., causing artifact idempotency checks,
retry_on detection, and skip_if in pre-dispatch hooks to all fail silently.

Closes #1721

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-21 09:46:20 -06:00 committed by GitHub
parent dda01fa648
commit 33caef89d0
3 changed files with 15 additions and 17 deletions

View file

@ -206,21 +206,21 @@ function handleHookCompletion(basePath: string): HookDispatchResult | null {
/**
* Resolve the path where a hook artifact is expected to be written.
* Uses the trigger unit's directory context:
* - Task-level (M001/S01/T01): .gsd/M001/slices/S01/tasks/T01-{artifact}
* - Slice-level (M001/S01): .gsd/M001/slices/S01/{artifact}
* - Milestone-level (M001): .gsd/M001/{artifact}
* - Task-level (M001/S01/T01): .gsd/milestones/M001/slices/S01/tasks/T01-{artifact}
* - Slice-level (M001/S01): .gsd/milestones/M001/slices/S01/{artifact}
* - Milestone-level (M001): .gsd/milestones/M001/{artifact}
*/
export function resolveHookArtifactPath(basePath: string, unitId: string, artifactName: string): string {
const parts = unitId.split("/");
if (parts.length === 3) {
const [mid, sid, tid] = parts;
return join(basePath, ".gsd", mid, "slices", sid, "tasks", `${tid}-${artifactName}`);
return join(basePath, ".gsd", "milestones", mid, "slices", sid, "tasks", `${tid}-${artifactName}`);
}
if (parts.length === 2) {
const [mid, sid] = parts;
return join(basePath, ".gsd", mid, "slices", sid, artifactName);
return join(basePath, ".gsd", "milestones", mid, "slices", sid, artifactName);
}
return join(basePath, ".gsd", parts[0], artifactName);
return join(basePath, ".gsd", "milestones", parts[0], artifactName);
}
// ═══════════════════════════════════════════════════════════════════════════

View file

@ -26,7 +26,7 @@ const { assertEq, assertTrue, assertMatch, report } = createTestContext();
function createFixtureBase(): string {
const base = mkdtempSync(join(tmpdir(), "gsd-hook-test-"));
mkdirSync(join(base, ".gsd", "M001", "slices", "S01", "tasks"), { recursive: true });
mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true });
return base;
}
@ -45,7 +45,7 @@ console.log("\n=== resolveHookArtifactPath ===");
const taskPath = resolveHookArtifactPath(base, "M001/S01/T01", "REVIEW-PASS.md");
assertEq(
taskPath,
join(base, ".gsd", "M001", "slices", "S01", "tasks", "T01-REVIEW-PASS.md"),
join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks", "T01-REVIEW-PASS.md"),
"task-level artifact path",
);
@ -53,7 +53,7 @@ console.log("\n=== resolveHookArtifactPath ===");
const slicePath = resolveHookArtifactPath(base, "M001/S01", "REVIEW-PASS.md");
assertEq(
slicePath,
join(base, ".gsd", "M001", "slices", "S01", "REVIEW-PASS.md"),
join(base, ".gsd", "milestones", "M001", "slices", "S01", "REVIEW-PASS.md"),
"slice-level artifact path",
);
@ -61,7 +61,7 @@ console.log("\n=== resolveHookArtifactPath ===");
const milestonePath = resolveHookArtifactPath(base, "M001", "REVIEW-PASS.md");
assertEq(
milestonePath,
join(base, ".gsd", "M001", "REVIEW-PASS.md"),
join(base, ".gsd", "milestones", "M001", "REVIEW-PASS.md"),
"milestone-level artifact path",
);
}
@ -129,15 +129,18 @@ console.log("\n=== Variable substitution ===");
assertTrue(path3.includes("M002"), "3-part ID extracts milestoneId");
assertTrue(path3.includes("S03"), "3-part ID extracts sliceId");
assertTrue(path3.includes("T05"), "3-part ID extracts taskId");
assertTrue(path3.includes("milestones"), "3-part ID includes milestones/ segment");
// 2-part ID
const path2 = resolveHookArtifactPath(base, "M002/S03", "result.md");
assertTrue(path2.includes("M002"), "2-part ID extracts milestoneId");
assertTrue(path2.includes("S03"), "2-part ID extracts sliceId");
assertTrue(path2.includes("milestones"), "2-part ID includes milestones/ segment");
// 1-part ID
const path1 = resolveHookArtifactPath(base, "M002", "result.md");
assertTrue(path1.includes("M002"), "1-part ID extracts milestoneId");
assertTrue(path1.includes("milestones"), "1-part ID includes milestones/ segment");
}
// ═══════════════════════════════════════════════════════════════════════════

View file

@ -24,14 +24,9 @@ function createRetryFixture(): { base: string; cleanup: () => void } {
const base = mkdtempSync(join(tmpdir(), "gsd-retry-reset-"));
// Create the .gsd structure for M001/S01/T01
// Plan/Summary resolution uses .gsd/milestones/M001/slices/S01/...
const milestonesTasksDir = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
mkdirSync(milestonesTasksDir, { recursive: true });
// Hook artifact resolution uses .gsd/M001/slices/S01/tasks/...
const hookTasksDir = join(base, ".gsd", "M001", "slices", "S01", "tasks");
mkdirSync(hookTasksDir, { recursive: true });
// Write a PLAN.md with T01 checked [x] (as doctor would do)
const planFile = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md");
writeFileSync(planFile, [
@ -57,7 +52,7 @@ function createRetryFixture(): { base: string; cleanup: () => void } {
);
// Write the retry_on artifact in the hook artifact path
const retryArtifact = join(hookTasksDir, "T01-NEEDS-REWORK.md");
const retryArtifact = join(milestonesTasksDir, "T01-NEEDS-REWORK.md");
writeFileSync(retryArtifact, "Rework needed: test coverage insufficient.", "utf-8");
return {
@ -325,7 +320,7 @@ console.log("\n=== resolveHookArtifactPath: correct path for retry artifacts ===
const path = resolveHookArtifactPath(base, "M001/S01/T01", "NEEDS-REWORK.md");
assertEq(
path,
join(base, ".gsd", "M001", "slices", "S01", "tasks", "T01-NEEDS-REWORK.md"),
join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks", "T01-NEEDS-REWORK.md"),
"retry artifact path resolves to task directory with task prefix",
);
}