gsd-pi 0.2.8 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/pkg/package.json +1 -1
- package/src/resources/extensions/gsd/auto.ts +216 -13
- package/src/resources/extensions/gsd/state.ts +24 -3
- package/src/resources/extensions/gsd/tests/derive-state.test.ts +52 -0
- package/src/resources/extensions/gsd/tests/idle-recovery.test.ts +308 -0
- package/src/resources/extensions/gsd/unit-runtime.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gsd-pi",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
4
4
|
"description": "GSD — Get Stuff Done coding agent",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
"test": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test 'src/resources/extensions/gsd/tests/*.test.ts' 'src/resources/extensions/gsd/tests/*.test.mjs' 'src/tests/*.test.ts'",
|
|
38
38
|
"dev": "tsc --watch",
|
|
39
39
|
"postinstall": "node scripts/postinstall.js",
|
|
40
|
-
"
|
|
40
|
+
"sync-pkg-version": "node scripts/sync-pkg-version.cjs",
|
|
41
|
+
"prepublishOnly": "npm run sync-pkg-version && npm run build"
|
|
41
42
|
},
|
|
42
43
|
"dependencies": {
|
|
43
44
|
"@mariozechner/pi-coding-agent": "^0.57.1",
|
package/pkg/package.json
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
resolveMilestonePath, resolveDir, resolveTasksDir, resolveTaskFiles, resolveTaskFile,
|
|
28
28
|
relMilestoneFile, relSliceFile, relTaskFile, relSlicePath, relMilestonePath,
|
|
29
29
|
milestonesDir, resolveGsdRootFile, relGsdRootFile,
|
|
30
|
+
buildMilestoneFileName, buildSliceFileName, buildTaskFileName,
|
|
30
31
|
} from "./paths.js";
|
|
31
32
|
import { saveActivityLog } from "./activity-log.js";
|
|
32
33
|
import { synthesizeCrashRecovery, getDeepDiagnostic } from "./session-forensics.js";
|
|
@@ -54,7 +55,7 @@ import {
|
|
|
54
55
|
getProjectTotals, formatCost, formatTokenCount,
|
|
55
56
|
} from "./metrics.js";
|
|
56
57
|
import { join } from "node:path";
|
|
57
|
-
import { readdirSync, readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
58
|
+
import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
58
59
|
import { execSync } from "node:child_process";
|
|
59
60
|
import {
|
|
60
61
|
autoCommitCurrentBranch,
|
|
@@ -1912,6 +1913,7 @@ async function recoverTimedOutUnit(
|
|
|
1912
1913
|
}
|
|
1913
1914
|
|
|
1914
1915
|
if (recoveryAttempts < maxRecoveryAttempts) {
|
|
1916
|
+
const isEscalation = recoveryAttempts > 0;
|
|
1915
1917
|
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
1916
1918
|
phase: "recovered",
|
|
1917
1919
|
recovery: status,
|
|
@@ -1921,11 +1923,19 @@ async function recoverTimedOutUnit(
|
|
|
1921
1923
|
progressCount: (runtime?.progressCount ?? 0) + 1,
|
|
1922
1924
|
lastProgressKind: reason === "idle" ? "idle-recovery-retry" : "hard-recovery-retry",
|
|
1923
1925
|
});
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1926
|
+
|
|
1927
|
+
const steeringLines = isEscalation
|
|
1928
|
+
? [
|
|
1929
|
+
`**FINAL ${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — last chance before this task is skipped.**`,
|
|
1930
|
+
`You are still executing ${unitType} ${unitId}.`,
|
|
1931
|
+
`Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`,
|
|
1932
|
+
`Current durability status: ${formatExecuteTaskRecoveryStatus(status)}.`,
|
|
1933
|
+
"You MUST finish the durable output NOW, even if incomplete.",
|
|
1934
|
+
"Write the task summary with whatever you have accomplished so far.",
|
|
1935
|
+
"Mark the task [x] in the plan. Commit your work.",
|
|
1936
|
+
"A partial summary is infinitely better than no summary.",
|
|
1937
|
+
]
|
|
1938
|
+
: [
|
|
1929
1939
|
`**${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — do not stop.**`,
|
|
1930
1940
|
`You are still executing ${unitType} ${unitId}.`,
|
|
1931
1941
|
`Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`,
|
|
@@ -1933,7 +1943,13 @@ async function recoverTimedOutUnit(
|
|
|
1933
1943
|
"Do not keep exploring.",
|
|
1934
1944
|
"Immediately finish the required durable output for this unit.",
|
|
1935
1945
|
"If full completion is impossible, write the partial artifact/state needed for recovery and make the blocker explicit.",
|
|
1936
|
-
]
|
|
1946
|
+
];
|
|
1947
|
+
|
|
1948
|
+
pi.sendMessage(
|
|
1949
|
+
{
|
|
1950
|
+
customType: "gsd-auto-timeout-recovery",
|
|
1951
|
+
display: verbose,
|
|
1952
|
+
content: steeringLines.join("\n"),
|
|
1937
1953
|
},
|
|
1938
1954
|
{ triggerTurn: true, deliverAs: "steer" },
|
|
1939
1955
|
);
|
|
@@ -1944,7 +1960,29 @@ async function recoverTimedOutUnit(
|
|
|
1944
1960
|
return "recovered";
|
|
1945
1961
|
}
|
|
1946
1962
|
|
|
1963
|
+
// Retries exhausted — write missing durable artifacts and advance.
|
|
1947
1964
|
const diagnostic = formatExecuteTaskRecoveryStatus(status);
|
|
1965
|
+
const [mid, sid, tid] = unitId.split("/");
|
|
1966
|
+
const skipped = mid && sid && tid
|
|
1967
|
+
? skipExecuteTask(basePath, mid, sid, tid, status, reason, maxRecoveryAttempts)
|
|
1968
|
+
: false;
|
|
1969
|
+
|
|
1970
|
+
if (skipped) {
|
|
1971
|
+
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
1972
|
+
phase: "skipped",
|
|
1973
|
+
recovery: status,
|
|
1974
|
+
recoveryAttempts: recoveryAttempts + 1,
|
|
1975
|
+
lastRecoveryReason: reason,
|
|
1976
|
+
});
|
|
1977
|
+
ctx.ui.notify(
|
|
1978
|
+
`${unitType} ${unitId} skipped after ${maxRecoveryAttempts} recovery attempts (${diagnostic}). Blocker artifacts written. Advancing pipeline.`,
|
|
1979
|
+
"warning",
|
|
1980
|
+
);
|
|
1981
|
+
await dispatchNextUnit(ctx, pi);
|
|
1982
|
+
return "recovered";
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
// Fallback: couldn't write skip artifacts — pause as before.
|
|
1948
1986
|
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
1949
1987
|
phase: "paused",
|
|
1950
1988
|
recovery: status,
|
|
@@ -1959,7 +1997,26 @@ async function recoverTimedOutUnit(
|
|
|
1959
1997
|
}
|
|
1960
1998
|
|
|
1961
1999
|
const expected = diagnoseExpectedArtifact(unitType, unitId, basePath) ?? "required durable artifact";
|
|
2000
|
+
|
|
2001
|
+
// Check if the artifact already exists on disk — agent may have written it
|
|
2002
|
+
// without signaling completion.
|
|
2003
|
+
const artifactPath = resolveExpectedArtifactPath(unitType, unitId, basePath);
|
|
2004
|
+
if (artifactPath && existsSync(artifactPath)) {
|
|
2005
|
+
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
2006
|
+
phase: "finalized",
|
|
2007
|
+
recoveryAttempts: recoveryAttempts + 1,
|
|
2008
|
+
lastRecoveryReason: reason,
|
|
2009
|
+
});
|
|
2010
|
+
ctx.ui.notify(
|
|
2011
|
+
`${reason === "idle" ? "Idle" : "Timeout"} recovery: ${unitType} ${unitId} artifact already exists on disk. Advancing.`,
|
|
2012
|
+
"info",
|
|
2013
|
+
);
|
|
2014
|
+
await dispatchNextUnit(ctx, pi);
|
|
2015
|
+
return "recovered";
|
|
2016
|
+
}
|
|
2017
|
+
|
|
1962
2018
|
if (recoveryAttempts < maxRecoveryAttempts) {
|
|
2019
|
+
const isEscalation = recoveryAttempts > 0;
|
|
1963
2020
|
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
1964
2021
|
phase: "recovered",
|
|
1965
2022
|
recoveryAttempts: recoveryAttempts + 1,
|
|
@@ -1968,11 +2025,19 @@ async function recoverTimedOutUnit(
|
|
|
1968
2025
|
progressCount: (runtime?.progressCount ?? 0) + 1,
|
|
1969
2026
|
lastProgressKind: reason === "idle" ? "idle-recovery-retry" : "hard-recovery-retry",
|
|
1970
2027
|
});
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
2028
|
+
|
|
2029
|
+
const steeringLines = isEscalation
|
|
2030
|
+
? [
|
|
2031
|
+
`**FINAL ${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — last chance before skip.**`,
|
|
2032
|
+
`You are still executing ${unitType} ${unitId}.`,
|
|
2033
|
+
`Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts} — next failure skips this unit.`,
|
|
2034
|
+
`Expected durable output: ${expected}.`,
|
|
2035
|
+
"You MUST write the artifact file NOW, even if incomplete.",
|
|
2036
|
+
"Write whatever you have — partial research, preliminary findings, best-effort analysis.",
|
|
2037
|
+
"A partial artifact is infinitely better than no artifact.",
|
|
2038
|
+
"If you are truly blocked, write the file with a BLOCKER section explaining why.",
|
|
2039
|
+
]
|
|
2040
|
+
: [
|
|
1976
2041
|
`**${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — stay in auto-mode.**`,
|
|
1977
2042
|
`You are still executing ${unitType} ${unitId}.`,
|
|
1978
2043
|
`Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`,
|
|
@@ -1980,7 +2045,13 @@ async function recoverTimedOutUnit(
|
|
|
1980
2045
|
"Stop broad exploration.",
|
|
1981
2046
|
"Write the required artifact now.",
|
|
1982
2047
|
"If blocked, write the partial artifact and explicitly record the blocker instead of going silent.",
|
|
1983
|
-
]
|
|
2048
|
+
];
|
|
2049
|
+
|
|
2050
|
+
pi.sendMessage(
|
|
2051
|
+
{
|
|
2052
|
+
customType: "gsd-auto-timeout-recovery",
|
|
2053
|
+
display: verbose,
|
|
2054
|
+
content: steeringLines.join("\n"),
|
|
1984
2055
|
},
|
|
1985
2056
|
{ triggerTurn: true, deliverAs: "steer" },
|
|
1986
2057
|
);
|
|
@@ -1991,6 +2062,28 @@ async function recoverTimedOutUnit(
|
|
|
1991
2062
|
return "recovered";
|
|
1992
2063
|
}
|
|
1993
2064
|
|
|
2065
|
+
// Retries exhausted — write a blocker placeholder and advance the pipeline
|
|
2066
|
+
// instead of silently stalling.
|
|
2067
|
+
const placeholder = writeBlockerPlaceholder(
|
|
2068
|
+
unitType, unitId, basePath,
|
|
2069
|
+
`${reason} recovery exhausted ${maxRecoveryAttempts} attempts without producing the artifact.`,
|
|
2070
|
+
);
|
|
2071
|
+
|
|
2072
|
+
if (placeholder) {
|
|
2073
|
+
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
2074
|
+
phase: "skipped",
|
|
2075
|
+
recoveryAttempts: recoveryAttempts + 1,
|
|
2076
|
+
lastRecoveryReason: reason,
|
|
2077
|
+
});
|
|
2078
|
+
ctx.ui.notify(
|
|
2079
|
+
`${unitType} ${unitId} skipped after ${maxRecoveryAttempts} recovery attempts. Blocker placeholder written to ${placeholder}. Advancing pipeline.`,
|
|
2080
|
+
"warning",
|
|
2081
|
+
);
|
|
2082
|
+
await dispatchNextUnit(ctx, pi);
|
|
2083
|
+
return "recovered";
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
// Fallback: couldn't resolve artifact path — pause as before.
|
|
1994
2087
|
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
1995
2088
|
phase: "paused",
|
|
1996
2089
|
recoveryAttempts: recoveryAttempts + 1,
|
|
@@ -1999,6 +2092,116 @@ async function recoverTimedOutUnit(
|
|
|
1999
2092
|
return "paused";
|
|
2000
2093
|
}
|
|
2001
2094
|
|
|
2095
|
+
/**
|
|
2096
|
+
* Write skip artifacts for a stuck execute-task: a blocker task summary and
|
|
2097
|
+
* the [x] checkbox in the slice plan. Returns true if artifacts were written.
|
|
2098
|
+
*/
|
|
2099
|
+
export function skipExecuteTask(
|
|
2100
|
+
base: string, mid: string, sid: string, tid: string,
|
|
2101
|
+
status: { summaryExists: boolean; taskChecked: boolean },
|
|
2102
|
+
reason: string, maxAttempts: number,
|
|
2103
|
+
): boolean {
|
|
2104
|
+
// Write a blocker task summary if missing.
|
|
2105
|
+
if (!status.summaryExists) {
|
|
2106
|
+
const tasksDir = resolveTasksDir(base, mid, sid);
|
|
2107
|
+
const sDir = resolveSlicePath(base, mid, sid);
|
|
2108
|
+
const targetDir = tasksDir ?? (sDir ? join(sDir, "tasks") : null);
|
|
2109
|
+
if (!targetDir) return false;
|
|
2110
|
+
if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
|
|
2111
|
+
const summaryPath = join(targetDir, buildTaskFileName(tid, "SUMMARY"));
|
|
2112
|
+
const content = [
|
|
2113
|
+
`# BLOCKER — task skipped by auto-mode recovery`,
|
|
2114
|
+
``,
|
|
2115
|
+
`Task \`${tid}\` in slice \`${sid}\` (milestone \`${mid}\`) failed to complete after ${reason} recovery exhausted ${maxAttempts} attempts.`,
|
|
2116
|
+
``,
|
|
2117
|
+
`This placeholder was written by auto-mode so the pipeline can advance.`,
|
|
2118
|
+
`Review this task manually and replace this file with a real summary.`,
|
|
2119
|
+
].join("\n");
|
|
2120
|
+
writeFileSync(summaryPath, content, "utf-8");
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
// Mark [x] in the slice plan if not already checked.
|
|
2124
|
+
if (!status.taskChecked) {
|
|
2125
|
+
const planAbs = resolveSliceFile(base, mid, sid, "PLAN");
|
|
2126
|
+
if (planAbs && existsSync(planAbs)) {
|
|
2127
|
+
const planContent = readFileSync(planAbs, "utf-8");
|
|
2128
|
+
const escapedTid = tid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2129
|
+
const re = new RegExp(`^(- \\[) \\] (\\*\\*${escapedTid}:)`, "m");
|
|
2130
|
+
if (re.test(planContent)) {
|
|
2131
|
+
writeFileSync(planAbs, planContent.replace(re, "$1x] $2"), "utf-8");
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
return true;
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
/**
|
|
2140
|
+
* Resolve the expected artifact for a non-execute-task unit to an absolute path.
|
|
2141
|
+
* Returns null for unit types that don't produce a single file (execute-task,
|
|
2142
|
+
* complete-slice, replan-slice).
|
|
2143
|
+
*/
|
|
2144
|
+
export function resolveExpectedArtifactPath(unitType: string, unitId: string, base: string): string | null {
|
|
2145
|
+
const parts = unitId.split("/");
|
|
2146
|
+
const mid = parts[0]!;
|
|
2147
|
+
const sid = parts[1];
|
|
2148
|
+
switch (unitType) {
|
|
2149
|
+
case "research-milestone": {
|
|
2150
|
+
const dir = resolveMilestonePath(base, mid);
|
|
2151
|
+
return dir ? join(dir, buildMilestoneFileName(mid, "RESEARCH")) : null;
|
|
2152
|
+
}
|
|
2153
|
+
case "plan-milestone": {
|
|
2154
|
+
const dir = resolveMilestonePath(base, mid);
|
|
2155
|
+
return dir ? join(dir, buildMilestoneFileName(mid, "ROADMAP")) : null;
|
|
2156
|
+
}
|
|
2157
|
+
case "research-slice": {
|
|
2158
|
+
const dir = resolveSlicePath(base, mid, sid!);
|
|
2159
|
+
return dir ? join(dir, buildSliceFileName(sid!, "RESEARCH")) : null;
|
|
2160
|
+
}
|
|
2161
|
+
case "plan-slice": {
|
|
2162
|
+
const dir = resolveSlicePath(base, mid, sid!);
|
|
2163
|
+
return dir ? join(dir, buildSliceFileName(sid!, "PLAN")) : null;
|
|
2164
|
+
}
|
|
2165
|
+
case "reassess-roadmap": {
|
|
2166
|
+
const dir = resolveSlicePath(base, mid, sid!);
|
|
2167
|
+
return dir ? join(dir, buildSliceFileName(sid!, "ASSESSMENT")) : null;
|
|
2168
|
+
}
|
|
2169
|
+
case "run-uat": {
|
|
2170
|
+
const dir = resolveSlicePath(base, mid, sid!);
|
|
2171
|
+
return dir ? join(dir, buildSliceFileName(sid!, "UAT-RESULT")) : null;
|
|
2172
|
+
}
|
|
2173
|
+
case "complete-milestone": {
|
|
2174
|
+
const dir = resolveMilestonePath(base, mid);
|
|
2175
|
+
return dir ? join(dir, buildMilestoneFileName(mid, "SUMMARY")) : null;
|
|
2176
|
+
}
|
|
2177
|
+
default:
|
|
2178
|
+
return null;
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
/**
|
|
2183
|
+
* Write a placeholder artifact so the pipeline can advance past a stuck unit.
|
|
2184
|
+
* Returns the relative path written, or null if the path couldn't be resolved.
|
|
2185
|
+
*/
|
|
2186
|
+
export function writeBlockerPlaceholder(unitType: string, unitId: string, base: string, reason: string): string | null {
|
|
2187
|
+
const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
|
|
2188
|
+
if (!absPath) return null;
|
|
2189
|
+
const dir = absPath.substring(0, absPath.lastIndexOf("/"));
|
|
2190
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
2191
|
+
const content = [
|
|
2192
|
+
`# BLOCKER — auto-mode recovery failed`,
|
|
2193
|
+
``,
|
|
2194
|
+
`Unit \`${unitType}\` for \`${unitId}\` failed to produce this artifact after idle recovery exhausted all retries.`,
|
|
2195
|
+
``,
|
|
2196
|
+
`**Reason**: ${reason}`,
|
|
2197
|
+
``,
|
|
2198
|
+
`This placeholder was written by auto-mode so the pipeline can advance.`,
|
|
2199
|
+
`Review and replace this file before relying on downstream artifacts.`,
|
|
2200
|
+
].join("\n");
|
|
2201
|
+
writeFileSync(absPath, content, "utf-8");
|
|
2202
|
+
return diagnoseExpectedArtifact(unitType, unitId, base);
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2002
2205
|
function diagnoseExpectedArtifact(unitType: string, unitId: string, base: string): string | null {
|
|
2003
2206
|
const parts = unitId.split("/");
|
|
2004
2207
|
const mid = parts[0];
|
|
@@ -79,7 +79,12 @@ export async function getActiveMilestoneId(basePath: string): Promise<string | n
|
|
|
79
79
|
for (const mid of milestoneIds) {
|
|
80
80
|
const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
|
|
81
81
|
const content = roadmapFile ? await loadFile(roadmapFile) : null;
|
|
82
|
-
if (!content)
|
|
82
|
+
if (!content) {
|
|
83
|
+
// No roadmap — but if a summary exists, the milestone is already complete
|
|
84
|
+
const summaryFile = resolveMilestoneFile(basePath, mid, "SUMMARY");
|
|
85
|
+
if (summaryFile) continue; // completed milestone, skip
|
|
86
|
+
return mid; // No roadmap and no summary — milestone is incomplete
|
|
87
|
+
}
|
|
83
88
|
const roadmap = parseRoadmap(content);
|
|
84
89
|
if (!isMilestoneComplete(roadmap)) return mid;
|
|
85
90
|
}
|
|
@@ -117,7 +122,12 @@ export async function deriveState(basePath: string): Promise<GSDState> {
|
|
|
117
122
|
for (const mid of milestoneIds) {
|
|
118
123
|
const rf = resolveMilestoneFile(basePath, mid, "ROADMAP");
|
|
119
124
|
const rc = rf ? await loadFile(rf) : null;
|
|
120
|
-
if (!rc)
|
|
125
|
+
if (!rc) {
|
|
126
|
+
// No roadmap — milestone is complete if it has a summary
|
|
127
|
+
const sf = resolveMilestoneFile(basePath, mid, "SUMMARY");
|
|
128
|
+
if (sf) completeMilestoneIds.add(mid);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
121
131
|
const rmap = parseRoadmap(rc);
|
|
122
132
|
if (!isMilestoneComplete(rmap)) continue;
|
|
123
133
|
const sf = resolveMilestoneFile(basePath, mid, "SUMMARY");
|
|
@@ -134,7 +144,18 @@ export async function deriveState(basePath: string): Promise<GSDState> {
|
|
|
134
144
|
const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
|
|
135
145
|
const content = roadmapFile ? await loadFile(roadmapFile) : null;
|
|
136
146
|
if (!content) {
|
|
137
|
-
// No roadmap
|
|
147
|
+
// No roadmap — check if a summary exists (completed milestone without roadmap)
|
|
148
|
+
const summaryFile = resolveMilestoneFile(basePath, mid, "SUMMARY");
|
|
149
|
+
if (summaryFile) {
|
|
150
|
+
const summaryContent = await loadFile(summaryFile);
|
|
151
|
+
const summaryTitle = summaryContent
|
|
152
|
+
? (parseSummary(summaryContent).title || mid)
|
|
153
|
+
: mid;
|
|
154
|
+
registry.push({ id: mid, title: summaryTitle, status: 'complete' });
|
|
155
|
+
completeMilestoneIds.add(mid);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
// No roadmap and no summary — treat as incomplete/active
|
|
138
159
|
if (!activeMilestoneFound) {
|
|
139
160
|
activeMilestone = { id: mid, title: mid };
|
|
140
161
|
activeMilestoneFound = true;
|
|
@@ -618,6 +618,58 @@ Continue from step 2.
|
|
|
618
618
|
}
|
|
619
619
|
}
|
|
620
620
|
|
|
621
|
+
// ═══ Milestone with summary but no roadmap → complete ═══════════════════
|
|
622
|
+
{
|
|
623
|
+
console.log('\n=== milestone with summary and no roadmap → complete ===');
|
|
624
|
+
const base = createFixtureBase();
|
|
625
|
+
try {
|
|
626
|
+
// M001, M002: completed milestones with summaries but no roadmaps
|
|
627
|
+
const m1dir = join(base, '.gsd', 'milestones', 'M001');
|
|
628
|
+
mkdirSync(m1dir, { recursive: true });
|
|
629
|
+
writeFileSync(join(m1dir, 'M001-SUMMARY.md'), '---\nid: M001\n---\n# Bootstrap\nDone.');
|
|
630
|
+
|
|
631
|
+
const m2dir = join(base, '.gsd', 'milestones', 'M002');
|
|
632
|
+
mkdirSync(m2dir, { recursive: true });
|
|
633
|
+
writeFileSync(join(m2dir, 'M002-SUMMARY.md'), '---\nid: M002\n---\n# Core Features\nDone.');
|
|
634
|
+
|
|
635
|
+
// M003: active milestone with a roadmap
|
|
636
|
+
writeRoadmap(base, 'M003', '# M003: Polish\n## Slices\n- [ ] **S01: Cleanup**');
|
|
637
|
+
|
|
638
|
+
const state = await deriveState(base);
|
|
639
|
+
|
|
640
|
+
assertEq(state.phase, 'planning', 'summary-no-roadmap: phase is planning (active is M003)');
|
|
641
|
+
assertEq(state.activeMilestone?.id, 'M003', 'summary-no-roadmap: active milestone is M003');
|
|
642
|
+
assertEq(state.activeMilestone?.title, 'Polish', 'summary-no-roadmap: active title is Polish');
|
|
643
|
+
assertEq(state.registry.length, 3, 'summary-no-roadmap: registry has 3 entries');
|
|
644
|
+
assertEq(state.registry[0]?.status, 'complete', 'summary-no-roadmap: M001 is complete');
|
|
645
|
+
assertEq(state.registry[0]?.title, 'Bootstrap', 'summary-no-roadmap: M001 title from summary');
|
|
646
|
+
assertEq(state.registry[1]?.status, 'complete', 'summary-no-roadmap: M002 is complete');
|
|
647
|
+
assertEq(state.registry[1]?.title, 'Core Features', 'summary-no-roadmap: M002 title from summary');
|
|
648
|
+
assertEq(state.registry[2]?.status, 'active', 'summary-no-roadmap: M003 is active');
|
|
649
|
+
assertEq(state.progress?.milestones?.done, 2, 'summary-no-roadmap: milestones done = 2');
|
|
650
|
+
assertEq(state.progress?.milestones?.total, 3, 'summary-no-roadmap: milestones total = 3');
|
|
651
|
+
} finally {
|
|
652
|
+
cleanup(base);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ═══ All milestones have summary but no roadmap → complete ═════════════
|
|
657
|
+
{
|
|
658
|
+
console.log('\n=== all milestones summary-only → complete ===');
|
|
659
|
+
const base = createFixtureBase();
|
|
660
|
+
try {
|
|
661
|
+
const m1dir = join(base, '.gsd', 'milestones', 'M001');
|
|
662
|
+
mkdirSync(m1dir, { recursive: true });
|
|
663
|
+
writeFileSync(join(m1dir, 'M001-SUMMARY.md'), '---\ntitle: Done\n---\nAll done.');
|
|
664
|
+
|
|
665
|
+
const state = await deriveState(base);
|
|
666
|
+
assertEq(state.phase, 'complete', 'all-summary-only: phase is complete');
|
|
667
|
+
assertEq(state.registry[0]?.status, 'complete', 'all-summary-only: M001 is complete');
|
|
668
|
+
} finally {
|
|
669
|
+
cleanup(base);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
621
673
|
// ═════════════════════════════════════════════════════════════════════════
|
|
622
674
|
// Results
|
|
623
675
|
// ═════════════════════════════════════════════════════════════════════════
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import {
|
|
5
|
+
resolveExpectedArtifactPath,
|
|
6
|
+
writeBlockerPlaceholder,
|
|
7
|
+
skipExecuteTask,
|
|
8
|
+
} from "../auto.ts";
|
|
9
|
+
|
|
10
|
+
let passed = 0;
|
|
11
|
+
let failed = 0;
|
|
12
|
+
|
|
13
|
+
function assert(condition: boolean, message: string): void {
|
|
14
|
+
if (condition) passed++;
|
|
15
|
+
else {
|
|
16
|
+
failed++;
|
|
17
|
+
console.error(` FAIL: ${message}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function assertEq<T>(actual: T, expected: T, message: string): void {
|
|
22
|
+
if (JSON.stringify(actual) === JSON.stringify(expected)) passed++;
|
|
23
|
+
else {
|
|
24
|
+
failed++;
|
|
25
|
+
console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createFixtureBase(): string {
|
|
30
|
+
const base = mkdtempSync(join(tmpdir(), "gsd-idle-recovery-test-"));
|
|
31
|
+
mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true });
|
|
32
|
+
return base;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function cleanup(base: string): void {
|
|
36
|
+
rmSync(base, { recursive: true, force: true });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ═══ resolveExpectedArtifactPath ═════════════════════════════════════════════
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
console.log("\n=== resolveExpectedArtifactPath: research-milestone ===");
|
|
43
|
+
const base = createFixtureBase();
|
|
44
|
+
try {
|
|
45
|
+
const result = resolveExpectedArtifactPath("research-milestone", "M001", base);
|
|
46
|
+
assert(result !== null, "should resolve a path");
|
|
47
|
+
assert(result!.endsWith("M001-RESEARCH.md"), `path should end with M001-RESEARCH.md, got ${result}`);
|
|
48
|
+
} finally {
|
|
49
|
+
cleanup(base);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
{
|
|
54
|
+
console.log("\n=== resolveExpectedArtifactPath: plan-milestone ===");
|
|
55
|
+
const base = createFixtureBase();
|
|
56
|
+
try {
|
|
57
|
+
const result = resolveExpectedArtifactPath("plan-milestone", "M001", base);
|
|
58
|
+
assert(result !== null, "should resolve a path");
|
|
59
|
+
assert(result!.endsWith("M001-ROADMAP.md"), `path should end with M001-ROADMAP.md, got ${result}`);
|
|
60
|
+
} finally {
|
|
61
|
+
cleanup(base);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
{
|
|
66
|
+
console.log("\n=== resolveExpectedArtifactPath: research-slice ===");
|
|
67
|
+
const base = createFixtureBase();
|
|
68
|
+
try {
|
|
69
|
+
const result = resolveExpectedArtifactPath("research-slice", "M001/S01", base);
|
|
70
|
+
assert(result !== null, "should resolve a path");
|
|
71
|
+
assert(result!.endsWith("S01-RESEARCH.md"), `path should end with S01-RESEARCH.md, got ${result}`);
|
|
72
|
+
} finally {
|
|
73
|
+
cleanup(base);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
{
|
|
78
|
+
console.log("\n=== resolveExpectedArtifactPath: plan-slice ===");
|
|
79
|
+
const base = createFixtureBase();
|
|
80
|
+
try {
|
|
81
|
+
const result = resolveExpectedArtifactPath("plan-slice", "M001/S01", base);
|
|
82
|
+
assert(result !== null, "should resolve a path");
|
|
83
|
+
assert(result!.endsWith("S01-PLAN.md"), `path should end with S01-PLAN.md, got ${result}`);
|
|
84
|
+
} finally {
|
|
85
|
+
cleanup(base);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
{
|
|
90
|
+
console.log("\n=== resolveExpectedArtifactPath: complete-milestone ===");
|
|
91
|
+
const base = createFixtureBase();
|
|
92
|
+
try {
|
|
93
|
+
const result = resolveExpectedArtifactPath("complete-milestone", "M001", base);
|
|
94
|
+
assert(result !== null, "should resolve a path");
|
|
95
|
+
assert(result!.endsWith("M001-SUMMARY.md"), `path should end with M001-SUMMARY.md, got ${result}`);
|
|
96
|
+
} finally {
|
|
97
|
+
cleanup(base);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
{
|
|
102
|
+
console.log("\n=== resolveExpectedArtifactPath: unknown unit type → null ===");
|
|
103
|
+
const base = createFixtureBase();
|
|
104
|
+
try {
|
|
105
|
+
const result = resolveExpectedArtifactPath("unknown-type", "M001/S01", base);
|
|
106
|
+
assertEq(result, null, "unknown type returns null");
|
|
107
|
+
} finally {
|
|
108
|
+
cleanup(base);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ═══ writeBlockerPlaceholder ═════════════════════════════════════════════════
|
|
113
|
+
|
|
114
|
+
{
|
|
115
|
+
console.log("\n=== writeBlockerPlaceholder: writes file for research-slice ===");
|
|
116
|
+
const base = createFixtureBase();
|
|
117
|
+
try {
|
|
118
|
+
const result = writeBlockerPlaceholder("research-slice", "M001/S01", base, "idle recovery exhausted 2 attempts");
|
|
119
|
+
assert(result !== null, "should return relative path");
|
|
120
|
+
const absPath = resolveExpectedArtifactPath("research-slice", "M001/S01", base)!;
|
|
121
|
+
assert(existsSync(absPath), "file should exist on disk");
|
|
122
|
+
const content = readFileSync(absPath, "utf-8");
|
|
123
|
+
assert(content.includes("BLOCKER"), "should contain BLOCKER heading");
|
|
124
|
+
assert(content.includes("idle recovery exhausted 2 attempts"), "should contain the reason");
|
|
125
|
+
assert(content.includes("research-slice"), "should mention the unit type");
|
|
126
|
+
assert(content.includes("M001/S01"), "should mention the unit ID");
|
|
127
|
+
} finally {
|
|
128
|
+
cleanup(base);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
{
|
|
133
|
+
console.log("\n=== writeBlockerPlaceholder: creates directory if missing ===");
|
|
134
|
+
const base = mkdtempSync(join(tmpdir(), "gsd-idle-recovery-test-"));
|
|
135
|
+
try {
|
|
136
|
+
// Only create milestone dir, not slice dir
|
|
137
|
+
mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
|
|
138
|
+
// resolveSlicePath needs the slice dir to exist to resolve, so this should return null
|
|
139
|
+
const result = writeBlockerPlaceholder("research-slice", "M001/S01", base, "test reason");
|
|
140
|
+
// Since the slice dir doesn't exist, resolveExpectedArtifactPath returns null
|
|
141
|
+
assertEq(result, null, "returns null when directory structure doesn't exist");
|
|
142
|
+
} finally {
|
|
143
|
+
cleanup(base);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
{
|
|
148
|
+
console.log("\n=== writeBlockerPlaceholder: writes file for research-milestone ===");
|
|
149
|
+
const base = createFixtureBase();
|
|
150
|
+
try {
|
|
151
|
+
const result = writeBlockerPlaceholder("research-milestone", "M001", base, "hard timeout");
|
|
152
|
+
assert(result !== null, "should return relative path");
|
|
153
|
+
const absPath = resolveExpectedArtifactPath("research-milestone", "M001", base)!;
|
|
154
|
+
assert(existsSync(absPath), "file should exist on disk");
|
|
155
|
+
const content = readFileSync(absPath, "utf-8");
|
|
156
|
+
assert(content.includes("BLOCKER"), "should contain BLOCKER heading");
|
|
157
|
+
assert(content.includes("hard timeout"), "should contain the reason");
|
|
158
|
+
} finally {
|
|
159
|
+
cleanup(base);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
{
|
|
164
|
+
console.log("\n=== writeBlockerPlaceholder: unknown type → null ===");
|
|
165
|
+
const base = createFixtureBase();
|
|
166
|
+
try {
|
|
167
|
+
const result = writeBlockerPlaceholder("execute-task", "M001/S01/T01", base, "test");
|
|
168
|
+
assertEq(result, null, "execute-task has no single artifact path, returns null");
|
|
169
|
+
} finally {
|
|
170
|
+
cleanup(base);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ═══ skipExecuteTask ═════════════════════════════════════════════════════════
|
|
175
|
+
|
|
176
|
+
{
|
|
177
|
+
console.log("\n=== skipExecuteTask: writes summary and checks plan checkbox ===");
|
|
178
|
+
const base = createFixtureBase();
|
|
179
|
+
try {
|
|
180
|
+
const planPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md");
|
|
181
|
+
writeFileSync(planPath, [
|
|
182
|
+
"# S01: Test Slice",
|
|
183
|
+
"",
|
|
184
|
+
"## Tasks",
|
|
185
|
+
"",
|
|
186
|
+
"- [ ] **T01: First task** `est:10m`",
|
|
187
|
+
" Do the first thing.",
|
|
188
|
+
"- [ ] **T02: Second task** `est:15m`",
|
|
189
|
+
" Do the second thing.",
|
|
190
|
+
].join("\n"), "utf-8");
|
|
191
|
+
|
|
192
|
+
const result = skipExecuteTask(
|
|
193
|
+
base, "M001", "S01", "T01",
|
|
194
|
+
{ summaryExists: false, taskChecked: false },
|
|
195
|
+
"idle", 2,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
assert(result === true, "should return true");
|
|
199
|
+
|
|
200
|
+
// Check summary was written
|
|
201
|
+
const summaryPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks", "T01-SUMMARY.md");
|
|
202
|
+
assert(existsSync(summaryPath), "task summary should exist");
|
|
203
|
+
const summaryContent = readFileSync(summaryPath, "utf-8");
|
|
204
|
+
assert(summaryContent.includes("BLOCKER"), "summary should contain BLOCKER");
|
|
205
|
+
assert(summaryContent.includes("T01"), "summary should mention task ID");
|
|
206
|
+
|
|
207
|
+
// Check plan checkbox was marked
|
|
208
|
+
const planContent = readFileSync(planPath, "utf-8");
|
|
209
|
+
assert(planContent.includes("- [x] **T01:"), "T01 should be checked");
|
|
210
|
+
assert(planContent.includes("- [ ] **T02:"), "T02 should remain unchecked");
|
|
211
|
+
} finally {
|
|
212
|
+
cleanup(base);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
{
|
|
217
|
+
console.log("\n=== skipExecuteTask: skips summary if already exists ===");
|
|
218
|
+
const base = createFixtureBase();
|
|
219
|
+
try {
|
|
220
|
+
const planPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md");
|
|
221
|
+
writeFileSync(planPath, "- [ ] **T01: Task** `est:10m`\n", "utf-8");
|
|
222
|
+
|
|
223
|
+
// Pre-write a summary
|
|
224
|
+
const summaryPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks", "T01-SUMMARY.md");
|
|
225
|
+
writeFileSync(summaryPath, "# Real summary\nActual work done.", "utf-8");
|
|
226
|
+
|
|
227
|
+
const result = skipExecuteTask(
|
|
228
|
+
base, "M001", "S01", "T01",
|
|
229
|
+
{ summaryExists: true, taskChecked: false },
|
|
230
|
+
"idle", 2,
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
assert(result === true, "should return true");
|
|
234
|
+
|
|
235
|
+
// Summary should be untouched (not overwritten with blocker)
|
|
236
|
+
const content = readFileSync(summaryPath, "utf-8");
|
|
237
|
+
assert(content.includes("Real summary"), "original summary should be preserved");
|
|
238
|
+
assert(!content.includes("BLOCKER"), "should not contain BLOCKER");
|
|
239
|
+
|
|
240
|
+
// Plan checkbox should still be marked
|
|
241
|
+
const planContent = readFileSync(planPath, "utf-8");
|
|
242
|
+
assert(planContent.includes("- [x] **T01:"), "T01 should be checked");
|
|
243
|
+
} finally {
|
|
244
|
+
cleanup(base);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
{
|
|
249
|
+
console.log("\n=== skipExecuteTask: skips checkbox if already checked ===");
|
|
250
|
+
const base = createFixtureBase();
|
|
251
|
+
try {
|
|
252
|
+
const planPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md");
|
|
253
|
+
writeFileSync(planPath, "- [x] **T01: Task** `est:10m`\n", "utf-8");
|
|
254
|
+
|
|
255
|
+
const result = skipExecuteTask(
|
|
256
|
+
base, "M001", "S01", "T01",
|
|
257
|
+
{ summaryExists: false, taskChecked: true },
|
|
258
|
+
"idle", 2,
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
assert(result === true, "should return true");
|
|
262
|
+
|
|
263
|
+
// Summary should be written (since summaryExists was false)
|
|
264
|
+
const summaryPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks", "T01-SUMMARY.md");
|
|
265
|
+
assert(existsSync(summaryPath), "task summary should exist");
|
|
266
|
+
|
|
267
|
+
// Plan checkbox should be untouched
|
|
268
|
+
const planContent = readFileSync(planPath, "utf-8");
|
|
269
|
+
assert(planContent.includes("- [x] **T01:"), "T01 should remain checked");
|
|
270
|
+
} finally {
|
|
271
|
+
cleanup(base);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
{
|
|
276
|
+
console.log("\n=== skipExecuteTask: handles special regex chars in task ID ===");
|
|
277
|
+
const base = createFixtureBase();
|
|
278
|
+
try {
|
|
279
|
+
const planPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md");
|
|
280
|
+
writeFileSync(planPath, "- [ ] **T01.1: Sub-task** `est:10m`\n", "utf-8");
|
|
281
|
+
|
|
282
|
+
const result = skipExecuteTask(
|
|
283
|
+
base, "M001", "S01", "T01.1",
|
|
284
|
+
{ summaryExists: false, taskChecked: false },
|
|
285
|
+
"idle", 2,
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
assert(result === true, "should return true");
|
|
289
|
+
|
|
290
|
+
const planContent = readFileSync(planPath, "utf-8");
|
|
291
|
+
assert(planContent.includes("- [x] **T01.1:"), "T01.1 should be checked (regex chars escaped)");
|
|
292
|
+
} finally {
|
|
293
|
+
cleanup(base);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
298
|
+
// Results
|
|
299
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
300
|
+
|
|
301
|
+
console.log(`\n${"=".repeat(40)}`);
|
|
302
|
+
if (failed > 0) {
|
|
303
|
+
console.log(`Results: ${passed} passed, ${failed} failed`);
|
|
304
|
+
process.exit(1);
|
|
305
|
+
} else {
|
|
306
|
+
console.log(`Results: ${passed} passed, ${failed} failed`);
|
|
307
|
+
console.log("All tests passed ✓");
|
|
308
|
+
}
|