gsd-pi 2.31.2 → 2.32.0-dev.d792ba5
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/README.md +27 -20
- package/dist/cli.js +5 -5
- package/dist/resources/extensions/gsd/auto-constants.ts +6 -0
- package/dist/resources/extensions/gsd/auto-dashboard.ts +20 -26
- package/dist/resources/extensions/gsd/auto-direct-dispatch.ts +1 -6
- package/dist/resources/extensions/gsd/auto-dispatch.ts +4 -8
- package/dist/resources/extensions/gsd/auto-post-unit.ts +27 -32
- package/dist/resources/extensions/gsd/auto-prompts.ts +38 -34
- package/dist/resources/extensions/gsd/auto-start.ts +8 -6
- package/dist/resources/extensions/gsd/auto.ts +54 -33
- package/dist/resources/extensions/gsd/commands-workflow-templates.ts +3 -5
- package/dist/resources/extensions/gsd/commands.ts +19 -0
- package/dist/resources/extensions/gsd/crash-recovery.ts +15 -2
- package/dist/resources/extensions/gsd/dashboard-overlay.ts +28 -0
- package/dist/resources/extensions/gsd/doctor-environment.ts +497 -0
- package/dist/resources/extensions/gsd/doctor-providers.ts +343 -0
- package/dist/resources/extensions/gsd/doctor-types.ts +14 -1
- package/dist/resources/extensions/gsd/doctor.ts +6 -0
- package/dist/resources/extensions/gsd/git-service.ts +9 -0
- package/dist/resources/extensions/gsd/guided-flow-queue.ts +1 -8
- package/dist/resources/extensions/gsd/health-widget.ts +167 -0
- package/dist/resources/extensions/gsd/index.ts +12 -0
- package/dist/resources/extensions/gsd/migrate-external.ts +18 -2
- package/dist/resources/extensions/gsd/preferences-types.ts +8 -0
- package/dist/resources/extensions/gsd/preferences-validation.ts +3 -10
- package/dist/resources/extensions/gsd/progress-score.ts +273 -0
- package/dist/resources/extensions/gsd/prompts/run-uat.md +1 -42
- package/dist/resources/extensions/gsd/quick.ts +59 -7
- package/dist/resources/extensions/gsd/repo-identity.ts +22 -1
- package/dist/resources/extensions/gsd/tests/auto-reentrancy-guard.test.ts +127 -0
- package/dist/resources/extensions/gsd/tests/doctor-environment.test.ts +314 -0
- package/dist/resources/extensions/gsd/tests/doctor-providers.test.ts +298 -0
- package/dist/resources/extensions/gsd/tests/export-html-enhancements.test.ts +3 -0
- package/dist/resources/extensions/gsd/tests/memory-leak-guards.test.ts +7 -3
- package/dist/resources/extensions/gsd/tests/progress-score.test.ts +206 -0
- package/dist/resources/extensions/gsd/tests/run-uat.test.ts +56 -7
- package/dist/resources/extensions/gsd/tests/visualizer-views.test.ts +12 -0
- package/dist/resources/extensions/gsd/visualizer-data.ts +60 -2
- package/dist/resources/extensions/gsd/visualizer-views.ts +54 -0
- package/dist/worktree-cli.d.ts +42 -6
- package/dist/worktree-cli.js +88 -48
- package/package.json +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/pkg/package.json +1 -1
- package/src/resources/extensions/gsd/auto-constants.ts +6 -0
- package/src/resources/extensions/gsd/auto-dashboard.ts +20 -26
- package/src/resources/extensions/gsd/auto-direct-dispatch.ts +1 -6
- package/src/resources/extensions/gsd/auto-dispatch.ts +4 -8
- package/src/resources/extensions/gsd/auto-post-unit.ts +27 -32
- package/src/resources/extensions/gsd/auto-prompts.ts +38 -34
- package/src/resources/extensions/gsd/auto-start.ts +8 -6
- package/src/resources/extensions/gsd/auto.ts +54 -33
- package/src/resources/extensions/gsd/commands-workflow-templates.ts +3 -5
- package/src/resources/extensions/gsd/commands.ts +19 -0
- package/src/resources/extensions/gsd/crash-recovery.ts +15 -2
- package/src/resources/extensions/gsd/dashboard-overlay.ts +28 -0
- package/src/resources/extensions/gsd/doctor-environment.ts +497 -0
- package/src/resources/extensions/gsd/doctor-providers.ts +343 -0
- package/src/resources/extensions/gsd/doctor-types.ts +14 -1
- package/src/resources/extensions/gsd/doctor.ts +6 -0
- package/src/resources/extensions/gsd/git-service.ts +9 -0
- package/src/resources/extensions/gsd/guided-flow-queue.ts +1 -8
- package/src/resources/extensions/gsd/health-widget.ts +167 -0
- package/src/resources/extensions/gsd/index.ts +12 -0
- package/src/resources/extensions/gsd/migrate-external.ts +18 -2
- package/src/resources/extensions/gsd/preferences-types.ts +8 -0
- package/src/resources/extensions/gsd/preferences-validation.ts +3 -10
- package/src/resources/extensions/gsd/progress-score.ts +273 -0
- package/src/resources/extensions/gsd/prompts/run-uat.md +1 -42
- package/src/resources/extensions/gsd/quick.ts +59 -7
- package/src/resources/extensions/gsd/repo-identity.ts +22 -1
- package/src/resources/extensions/gsd/tests/auto-reentrancy-guard.test.ts +127 -0
- package/src/resources/extensions/gsd/tests/doctor-environment.test.ts +314 -0
- package/src/resources/extensions/gsd/tests/doctor-providers.test.ts +298 -0
- package/src/resources/extensions/gsd/tests/export-html-enhancements.test.ts +3 -0
- package/src/resources/extensions/gsd/tests/memory-leak-guards.test.ts +7 -3
- package/src/resources/extensions/gsd/tests/progress-score.test.ts +206 -0
- package/src/resources/extensions/gsd/tests/run-uat.test.ts +56 -7
- package/src/resources/extensions/gsd/tests/visualizer-views.test.ts +12 -0
- package/src/resources/extensions/gsd/visualizer-data.ts +60 -2
- package/src/resources/extensions/gsd/visualizer-views.ts +54 -0
|
@@ -14,6 +14,7 @@ import { normalizeStringArray } from "../shared/mod.js";
|
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
16
|
KNOWN_PREFERENCE_KEYS,
|
|
17
|
+
KNOWN_UNIT_TYPES,
|
|
17
18
|
SKILL_ACTIONS,
|
|
18
19
|
type WorkflowMode,
|
|
19
20
|
type GSDPreferences,
|
|
@@ -239,11 +240,7 @@ export function validatePreferences(preferences: GSDPreferences): {
|
|
|
239
240
|
if (preferences.post_unit_hooks && Array.isArray(preferences.post_unit_hooks)) {
|
|
240
241
|
const validHooks: PostUnitHookConfig[] = [];
|
|
241
242
|
const seenNames = new Set<string>();
|
|
242
|
-
const knownUnitTypes = new Set(
|
|
243
|
-
"research-milestone", "plan-milestone", "research-slice", "plan-slice",
|
|
244
|
-
"execute-task", "complete-slice", "replan-slice", "reassess-roadmap",
|
|
245
|
-
"run-uat", "complete-milestone",
|
|
246
|
-
]);
|
|
243
|
+
const knownUnitTypes = new Set<string>(KNOWN_UNIT_TYPES);
|
|
247
244
|
for (const hook of preferences.post_unit_hooks) {
|
|
248
245
|
if (!hook || typeof hook !== "object") {
|
|
249
246
|
errors.push("post_unit_hooks entry must be an object");
|
|
@@ -305,11 +302,7 @@ export function validatePreferences(preferences: GSDPreferences): {
|
|
|
305
302
|
if (preferences.pre_dispatch_hooks && Array.isArray(preferences.pre_dispatch_hooks)) {
|
|
306
303
|
const validPreHooks: PreDispatchHookConfig[] = [];
|
|
307
304
|
const seenPreNames = new Set<string>();
|
|
308
|
-
const knownUnitTypes = new Set(
|
|
309
|
-
"research-milestone", "plan-milestone", "research-slice", "plan-slice",
|
|
310
|
-
"execute-task", "complete-slice", "replan-slice", "reassess-roadmap",
|
|
311
|
-
"run-uat", "complete-milestone",
|
|
312
|
-
]);
|
|
305
|
+
const knownUnitTypes = new Set<string>(KNOWN_UNIT_TYPES);
|
|
313
306
|
const validActions = new Set(["modify", "skip", "replace"]);
|
|
314
307
|
for (const hook of preferences.pre_dispatch_hooks) {
|
|
315
308
|
if (!hook || typeof hook !== "object") {
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GSD Progress Score — Traffic Light Status Indicator (#1221)
|
|
3
|
+
*
|
|
4
|
+
* Combines existing health signals into a single at-a-glance status:
|
|
5
|
+
* - Green: progressing well
|
|
6
|
+
* - Yellow: struggling (retries, warnings)
|
|
7
|
+
* - Red: stuck (loops, persistent errors, no activity)
|
|
8
|
+
*
|
|
9
|
+
* Purely derived — no stored state. Reads from doctor-proactive health
|
|
10
|
+
* tracking, stuck detection counters, and working-tree activity.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
getHealthTrend,
|
|
15
|
+
getConsecutiveErrorUnits,
|
|
16
|
+
getHealthHistory,
|
|
17
|
+
type HealthSnapshot,
|
|
18
|
+
} from "./doctor-proactive.js";
|
|
19
|
+
|
|
20
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export type ProgressLevel = "green" | "yellow" | "red";
|
|
23
|
+
|
|
24
|
+
export interface ProgressScore {
|
|
25
|
+
level: ProgressLevel;
|
|
26
|
+
summary: string;
|
|
27
|
+
signals: ProgressSignal[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ProgressSignal {
|
|
31
|
+
name: string;
|
|
32
|
+
level: ProgressLevel;
|
|
33
|
+
detail: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── Signal Evaluators ──────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
function evaluateHealthTrend(): ProgressSignal {
|
|
39
|
+
const trend = getHealthTrend();
|
|
40
|
+
|
|
41
|
+
switch (trend) {
|
|
42
|
+
case "improving":
|
|
43
|
+
return { name: "health_trend", level: "green", detail: "Health improving" };
|
|
44
|
+
case "stable":
|
|
45
|
+
return { name: "health_trend", level: "green", detail: "Health stable" };
|
|
46
|
+
case "degrading":
|
|
47
|
+
return { name: "health_trend", level: "red", detail: "Health degrading" };
|
|
48
|
+
case "unknown":
|
|
49
|
+
return { name: "health_trend", level: "green", detail: "Insufficient data" };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function evaluateErrorStreak(): ProgressSignal {
|
|
54
|
+
const streak = getConsecutiveErrorUnits();
|
|
55
|
+
|
|
56
|
+
if (streak === 0) {
|
|
57
|
+
return { name: "error_streak", level: "green", detail: "No consecutive errors" };
|
|
58
|
+
}
|
|
59
|
+
if (streak <= 2) {
|
|
60
|
+
return { name: "error_streak", level: "yellow", detail: `${streak} consecutive error unit(s)` };
|
|
61
|
+
}
|
|
62
|
+
return { name: "error_streak", level: "red", detail: `${streak} consecutive error units` };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function evaluateRecentErrors(): ProgressSignal {
|
|
66
|
+
const history = getHealthHistory();
|
|
67
|
+
if (history.length === 0) {
|
|
68
|
+
return { name: "recent_errors", level: "green", detail: "No health data yet" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const latest = history[history.length - 1]!;
|
|
72
|
+
|
|
73
|
+
if (latest.errors === 0 && latest.warnings <= 1) {
|
|
74
|
+
return { name: "recent_errors", level: "green", detail: `${latest.errors}E/${latest.warnings}W` };
|
|
75
|
+
}
|
|
76
|
+
if (latest.errors === 0) {
|
|
77
|
+
return { name: "recent_errors", level: "yellow", detail: `${latest.warnings} warning(s)` };
|
|
78
|
+
}
|
|
79
|
+
if (latest.errors <= 2) {
|
|
80
|
+
return { name: "recent_errors", level: "yellow", detail: `${latest.errors} error(s), ${latest.warnings} warning(s)` };
|
|
81
|
+
}
|
|
82
|
+
return { name: "recent_errors", level: "red", detail: `${latest.errors} error(s), ${latest.warnings} warning(s)` };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function evaluateArtifactProduction(): ProgressSignal {
|
|
86
|
+
const history = getHealthHistory();
|
|
87
|
+
if (history.length < 2) {
|
|
88
|
+
return { name: "artifact_production", level: "green", detail: "Insufficient data" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const totalFixes = history.reduce((sum, s) => sum + s.fixesApplied, 0);
|
|
92
|
+
const recent = history.slice(-3);
|
|
93
|
+
const recentFixes = recent.reduce((sum, s) => sum + s.fixesApplied, 0);
|
|
94
|
+
|
|
95
|
+
// If recent units are all producing fixes but errors aren't decreasing,
|
|
96
|
+
// doctor is fighting fires but not making headway
|
|
97
|
+
if (recentFixes > 3 && recent.every(s => s.errors > 0)) {
|
|
98
|
+
return { name: "artifact_production", level: "yellow", detail: "Doctor applying fixes but errors persist" };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { name: "artifact_production", level: "green", detail: `${totalFixes} total fixes applied` };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function evaluateDispatchVelocity(): ProgressSignal {
|
|
105
|
+
const history = getHealthHistory();
|
|
106
|
+
if (history.length < 3) {
|
|
107
|
+
return { name: "dispatch_velocity", level: "green", detail: "Insufficient data" };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Check time between recent snapshots — are units completing at a reasonable rate?
|
|
111
|
+
const recent = history.slice(-5);
|
|
112
|
+
if (recent.length < 2) {
|
|
113
|
+
return { name: "dispatch_velocity", level: "green", detail: "Insufficient data" };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const timeDiffs: number[] = [];
|
|
117
|
+
for (let i = 1; i < recent.length; i++) {
|
|
118
|
+
timeDiffs.push(recent[i]!.timestamp - recent[i - 1]!.timestamp);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const avgTimeMs = timeDiffs.reduce((a, b) => a + b, 0) / timeDiffs.length;
|
|
122
|
+
const avgTimeMins = Math.round(avgTimeMs / 60_000);
|
|
123
|
+
|
|
124
|
+
// If average unit time is > 15 minutes, something might be wrong
|
|
125
|
+
if (avgTimeMins > 15) {
|
|
126
|
+
return { name: "dispatch_velocity", level: "yellow", detail: `Units averaging ${avgTimeMins}min each` };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { name: "dispatch_velocity", level: "green", detail: `Units averaging ${avgTimeMins || "<1"}min each` };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Main API ───────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Compute the current progress score by evaluating all available signals.
|
|
136
|
+
* Returns a composite score with individual signal details.
|
|
137
|
+
*/
|
|
138
|
+
export function computeProgressScore(): ProgressScore {
|
|
139
|
+
const signals: ProgressSignal[] = [
|
|
140
|
+
evaluateHealthTrend(),
|
|
141
|
+
evaluateErrorStreak(),
|
|
142
|
+
evaluateRecentErrors(),
|
|
143
|
+
evaluateArtifactProduction(),
|
|
144
|
+
evaluateDispatchVelocity(),
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
// Overall level: worst of all signals
|
|
148
|
+
const level = signals.some(s => s.level === "red")
|
|
149
|
+
? "red"
|
|
150
|
+
: signals.some(s => s.level === "yellow")
|
|
151
|
+
? "yellow"
|
|
152
|
+
: "green";
|
|
153
|
+
|
|
154
|
+
// Build summary from the most important signals
|
|
155
|
+
const summary = buildSummary(level, signals);
|
|
156
|
+
|
|
157
|
+
return { level, summary, signals };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Compute progress score with additional context from the current unit.
|
|
162
|
+
*/
|
|
163
|
+
export function computeProgressScoreWithContext(context: {
|
|
164
|
+
currentUnitType?: string;
|
|
165
|
+
currentUnitId?: string;
|
|
166
|
+
completedUnits?: number;
|
|
167
|
+
totalUnits?: number;
|
|
168
|
+
retryCount?: number;
|
|
169
|
+
maxRetries?: number;
|
|
170
|
+
}): ProgressScore {
|
|
171
|
+
const base = computeProgressScore();
|
|
172
|
+
|
|
173
|
+
// Add retry signal if available
|
|
174
|
+
if (context.retryCount !== undefined && context.maxRetries !== undefined) {
|
|
175
|
+
const retrySignal: ProgressSignal = context.retryCount === 0
|
|
176
|
+
? { name: "retry_count", level: "green", detail: "No retries" }
|
|
177
|
+
: context.retryCount <= 2
|
|
178
|
+
? { name: "retry_count", level: "yellow", detail: `Retry ${context.retryCount}/${context.maxRetries}` }
|
|
179
|
+
: { name: "retry_count", level: "red", detail: `Retry ${context.retryCount}/${context.maxRetries} — looping` };
|
|
180
|
+
|
|
181
|
+
base.signals.push(retrySignal);
|
|
182
|
+
|
|
183
|
+
// Re-evaluate level
|
|
184
|
+
if (retrySignal.level === "red") base.level = "red";
|
|
185
|
+
else if (retrySignal.level === "yellow" && base.level === "green") base.level = "yellow";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Build richer summary with context
|
|
189
|
+
base.summary = buildSummaryWithContext(base.level, base.signals, context);
|
|
190
|
+
|
|
191
|
+
return base;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Formatting ─────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
function buildSummary(level: ProgressLevel, signals: ProgressSignal[]): string {
|
|
197
|
+
switch (level) {
|
|
198
|
+
case "green":
|
|
199
|
+
return "Progressing well";
|
|
200
|
+
case "yellow": {
|
|
201
|
+
const issues = signals.filter(s => s.level === "yellow").map(s => s.detail);
|
|
202
|
+
return `Struggling — ${issues[0] ?? "minor issues detected"}`;
|
|
203
|
+
}
|
|
204
|
+
case "red": {
|
|
205
|
+
const issues = signals.filter(s => s.level === "red").map(s => s.detail);
|
|
206
|
+
return `Stuck — ${issues[0] ?? "critical issues detected"}`;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function buildSummaryWithContext(
|
|
212
|
+
level: ProgressLevel,
|
|
213
|
+
signals: ProgressSignal[],
|
|
214
|
+
context: {
|
|
215
|
+
currentUnitType?: string;
|
|
216
|
+
currentUnitId?: string;
|
|
217
|
+
completedUnits?: number;
|
|
218
|
+
totalUnits?: number;
|
|
219
|
+
retryCount?: number;
|
|
220
|
+
maxRetries?: number;
|
|
221
|
+
},
|
|
222
|
+
): string {
|
|
223
|
+
const unitLabel = context.currentUnitId
|
|
224
|
+
? ` ${context.currentUnitId}`
|
|
225
|
+
: "";
|
|
226
|
+
const progressLabel = context.completedUnits !== undefined && context.totalUnits !== undefined
|
|
227
|
+
? ` (${context.completedUnits} of ${context.totalUnits} done)`
|
|
228
|
+
: "";
|
|
229
|
+
|
|
230
|
+
switch (level) {
|
|
231
|
+
case "green":
|
|
232
|
+
return `Progressing well —${unitLabel}${progressLabel}`;
|
|
233
|
+
case "yellow": {
|
|
234
|
+
const issues = signals.filter(s => s.level === "yellow").map(s => s.detail);
|
|
235
|
+
const retryInfo = context.retryCount ? `, attempt ${context.retryCount}/${context.maxRetries}` : "";
|
|
236
|
+
return `Struggling —${unitLabel}${retryInfo}${progressLabel ? ` ${progressLabel}` : ""}, ${issues[0] ?? "issues detected"}`;
|
|
237
|
+
}
|
|
238
|
+
case "red": {
|
|
239
|
+
const issues = signals.filter(s => s.level === "red").map(s => s.detail);
|
|
240
|
+
return `Stuck —${unitLabel}${progressLabel ? ` ${progressLabel}` : ""}, ${issues[0] ?? "critical issues"}`;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Format progress score as a single-line traffic light for TUI display.
|
|
247
|
+
*/
|
|
248
|
+
export function formatProgressLine(score: ProgressScore): string {
|
|
249
|
+
const icon = score.level === "green" ? "\uD83D\uDFE2"
|
|
250
|
+
: score.level === "yellow" ? "\uD83D\uDFE1"
|
|
251
|
+
: "\uD83D\uDD34";
|
|
252
|
+
return `${icon} ${score.summary}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Format a detailed progress report showing all signals.
|
|
257
|
+
*/
|
|
258
|
+
export function formatProgressReport(score: ProgressScore): string {
|
|
259
|
+
const lines: string[] = [];
|
|
260
|
+
|
|
261
|
+
lines.push(formatProgressLine(score));
|
|
262
|
+
lines.push("");
|
|
263
|
+
lines.push("Signals:");
|
|
264
|
+
|
|
265
|
+
for (const signal of score.signals) {
|
|
266
|
+
const icon = signal.level === "green" ? "\u2705"
|
|
267
|
+
: signal.level === "yellow" ? "\u26A0\uFE0F"
|
|
268
|
+
: "\uD83D\uDED1";
|
|
269
|
+
lines.push(` ${icon} ${signal.name}: ${signal.detail}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return lines.join("\n");
|
|
273
|
+
}
|
|
@@ -17,11 +17,8 @@ If a `GSD Skill Preferences` block is present in system context, use it to decid
|
|
|
17
17
|
## UAT Instructions
|
|
18
18
|
|
|
19
19
|
**UAT file:** `{{uatPath}}`
|
|
20
|
-
**UAT type:** `{{uatType}}`
|
|
21
20
|
**Result file to write:** `{{uatResultPath}}`
|
|
22
21
|
|
|
23
|
-
### If UAT type is `artifact-driven`
|
|
24
|
-
|
|
25
22
|
You are the test runner. Execute every check defined in `{{uatPath}}` directly:
|
|
26
23
|
|
|
27
24
|
- Run shell commands with `bash`
|
|
@@ -46,7 +43,7 @@ Write `{{uatResultPath}}` with:
|
|
|
46
43
|
```markdown
|
|
47
44
|
---
|
|
48
45
|
sliceId: {{sliceId}}
|
|
49
|
-
uatType:
|
|
46
|
+
uatType: artifact-driven
|
|
50
47
|
verdict: PASS | FAIL | PARTIAL
|
|
51
48
|
date: <ISO 8601 timestamp>
|
|
52
49
|
---
|
|
@@ -68,44 +65,6 @@ date: <ISO 8601 timestamp>
|
|
|
68
65
|
<any additional context, errors encountered, or follow-up items>
|
|
69
66
|
```
|
|
70
67
|
|
|
71
|
-
### If UAT type is NOT `artifact-driven` (type is `{{uatType}}`)
|
|
72
|
-
|
|
73
|
-
This UAT type requires human execution or live-runtime observation that you cannot perform mechanically. Your role is to surface it clearly for review.
|
|
74
|
-
|
|
75
|
-
Write `{{uatResultPath}}` with:
|
|
76
|
-
|
|
77
|
-
```markdown
|
|
78
|
-
---
|
|
79
|
-
sliceId: {{sliceId}}
|
|
80
|
-
uatType: {{uatType}}
|
|
81
|
-
verdict: surfaced-for-human-review
|
|
82
|
-
date: <ISO 8601 timestamp>
|
|
83
|
-
---
|
|
84
|
-
|
|
85
|
-
# UAT Result — {{sliceId}}
|
|
86
|
-
|
|
87
|
-
## UAT Type
|
|
88
|
-
|
|
89
|
-
`{{uatType}}` — requires human execution or live-runtime verification.
|
|
90
|
-
|
|
91
|
-
## Status
|
|
92
|
-
|
|
93
|
-
Surfaced for human review. Auto-mode will pause after this unit so the UAT can be performed manually.
|
|
94
|
-
|
|
95
|
-
## UAT File
|
|
96
|
-
|
|
97
|
-
See `{{uatPath}}` for the full UAT specification and acceptance criteria.
|
|
98
|
-
|
|
99
|
-
## Instructions for Human Reviewer
|
|
100
|
-
|
|
101
|
-
Review `{{uatPath}}`, perform the described UAT steps, then update this file with:
|
|
102
|
-
- The actual verdict (PASS / FAIL / PARTIAL)
|
|
103
|
-
- Results for each check
|
|
104
|
-
- Date completed
|
|
105
|
-
|
|
106
|
-
Once updated, run `/gsd auto` to resume auto-mode.
|
|
107
|
-
```
|
|
108
|
-
|
|
109
68
|
---
|
|
110
69
|
|
|
111
70
|
**You MUST write `{{uatResultPath}}` before finishing.**
|
|
@@ -14,8 +14,7 @@ import { existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { loadPrompt } from "./prompt-loader.js";
|
|
16
16
|
import { gsdRoot } from "./paths.js";
|
|
17
|
-
import {
|
|
18
|
-
import { loadEffectiveGSDPreferences } from "./preferences.js";
|
|
17
|
+
import { createGitService, runGit } from "./git-service.js";
|
|
19
18
|
|
|
20
19
|
// ─── Quick Task Helpers ───────────────────────────────────────────────────────
|
|
21
20
|
|
|
@@ -103,16 +102,16 @@ export async function handleQuick(
|
|
|
103
102
|
const date = new Date().toISOString().split("T")[0];
|
|
104
103
|
|
|
105
104
|
// Create git branch for the quick task (unless isolation: none)
|
|
106
|
-
const
|
|
107
|
-
const git = new GitServiceImpl(basePath, gitPrefs);
|
|
105
|
+
const git = createGitService(basePath);
|
|
108
106
|
const branchName = `gsd/quick/${taskNum}-${slug}`;
|
|
109
|
-
const skipBranch =
|
|
107
|
+
const skipBranch = git.prefs.isolation === "none";
|
|
110
108
|
|
|
111
109
|
let branchCreated = false;
|
|
110
|
+
let originalBranch: string | undefined;
|
|
112
111
|
if (!skipBranch) {
|
|
113
112
|
try {
|
|
114
|
-
|
|
115
|
-
if (
|
|
113
|
+
originalBranch = git.getCurrentBranch();
|
|
114
|
+
if (originalBranch !== branchName) {
|
|
116
115
|
// Auto-commit any dirty state before switching
|
|
117
116
|
try {
|
|
118
117
|
git.autoCommit("quick-task", `Q${taskNum}`, []);
|
|
@@ -156,4 +155,57 @@ export async function handleQuick(
|
|
|
156
155
|
},
|
|
157
156
|
{ triggerTurn: true },
|
|
158
157
|
);
|
|
158
|
+
|
|
159
|
+
// Schedule branch merge-back after the quick task agent session ends.
|
|
160
|
+
// Without this, auto-mode resumes on the quick-task branch (#1269).
|
|
161
|
+
if (branchCreated && originalBranch) {
|
|
162
|
+
_pendingQuickBranchReturn = {
|
|
163
|
+
basePath,
|
|
164
|
+
originalBranch,
|
|
165
|
+
quickBranch: branchName,
|
|
166
|
+
taskNum,
|
|
167
|
+
slug,
|
|
168
|
+
description,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Pending quick-task branch return — consumed by cleanupQuickBranch(). */
|
|
174
|
+
let _pendingQuickBranchReturn: {
|
|
175
|
+
basePath: string;
|
|
176
|
+
originalBranch: string;
|
|
177
|
+
quickBranch: string;
|
|
178
|
+
taskNum: number;
|
|
179
|
+
slug: string;
|
|
180
|
+
description: string;
|
|
181
|
+
} | null = null;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Merge the quick-task branch back to the original branch and switch.
|
|
185
|
+
* Called from the agent_end handler after a quick task completes.
|
|
186
|
+
* Returns true if a branch return was performed.
|
|
187
|
+
*/
|
|
188
|
+
export function cleanupQuickBranch(): boolean {
|
|
189
|
+
if (!_pendingQuickBranchReturn) return false;
|
|
190
|
+
const { basePath, originalBranch, quickBranch, taskNum, slug, description } = _pendingQuickBranchReturn;
|
|
191
|
+
_pendingQuickBranchReturn = null;
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
// Auto-commit any remaining work
|
|
195
|
+
try { runGit(basePath, ["add", "-A"]); } catch {}
|
|
196
|
+
try { runGit(basePath, ["commit", "-m", `quick(Q${taskNum}): ${slug}`]); } catch {}
|
|
197
|
+
|
|
198
|
+
// Switch back and merge
|
|
199
|
+
runGit(basePath, ["checkout", originalBranch]);
|
|
200
|
+
try {
|
|
201
|
+
runGit(basePath, ["merge", "--squash", quickBranch]);
|
|
202
|
+
runGit(basePath, ["commit", "-m", `quick(Q${taskNum}): ${description.slice(0, 72)}`]);
|
|
203
|
+
} catch { /* merge conflict or nothing — non-fatal */ }
|
|
204
|
+
|
|
205
|
+
// Clean up quick branch
|
|
206
|
+
try { runGit(basePath, ["branch", "-D", quickBranch]); } catch {}
|
|
207
|
+
return true;
|
|
208
|
+
} catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
159
211
|
}
|
|
@@ -10,7 +10,7 @@ import { createHash } from "node:crypto";
|
|
|
10
10
|
import { execFileSync } from "node:child_process";
|
|
11
11
|
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, symlinkSync } from "node:fs";
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
|
-
import { join, resolve } from "node:path";
|
|
13
|
+
import { join, resolve, sep } from "node:path";
|
|
14
14
|
|
|
15
15
|
// ─── Repo Identity ──────────────────────────────────────────────────────────
|
|
16
16
|
|
|
@@ -37,6 +37,27 @@ function getRemoteUrl(basePath: string): string {
|
|
|
37
37
|
*/
|
|
38
38
|
function resolveGitRoot(basePath: string): string {
|
|
39
39
|
try {
|
|
40
|
+
// In a worktree, --show-toplevel returns the worktree path, not the main
|
|
41
|
+
// repo root. Use --git-common-dir to find the shared .git directory,
|
|
42
|
+
// then derive the main repo root from it (#1288).
|
|
43
|
+
const commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
|
|
44
|
+
cwd: basePath,
|
|
45
|
+
encoding: "utf-8",
|
|
46
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
47
|
+
timeout: 5_000,
|
|
48
|
+
}).trim();
|
|
49
|
+
|
|
50
|
+
// If commonDir ends with .git/worktrees/<name>, the main repo is two
|
|
51
|
+
// levels up from the worktrees dir. If it's just .git, resolve normally.
|
|
52
|
+
if (commonDir.includes(`${sep}worktrees${sep}`) || commonDir.includes("/worktrees/")) {
|
|
53
|
+
// e.g., /path/to/project/.gsd/worktrees/M001/.git → /path/to/project
|
|
54
|
+
// or /path/to/project/.git/worktrees/M001 → /path/to/project
|
|
55
|
+
const gitDir = commonDir.replace(/[/\\]worktrees[/\\][^/\\]+$/, "");
|
|
56
|
+
const mainRoot = resolve(gitDir, "..");
|
|
57
|
+
return mainRoot;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Not in a worktree — use --show-toplevel as usual
|
|
40
61
|
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
41
62
|
cwd: basePath,
|
|
42
63
|
encoding: "utf-8",
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auto-reentrancy-guard.test.ts — Tests for the unconditional reentrancy guard.
|
|
3
|
+
*
|
|
4
|
+
* Regression for #1272: auto-mode stuck-loop where gap watchdog or
|
|
5
|
+
* pendingAgentEndRetry could enter dispatchNextUnit concurrently during
|
|
6
|
+
* recursive skip chains because the reentrancy guard was bypassed when
|
|
7
|
+
* skipDepth > 0.
|
|
8
|
+
*
|
|
9
|
+
* The fix makes the guard unconditional (`if (s.dispatching)` without
|
|
10
|
+
* `&& s.skipDepth === 0`), and defers recursive re-dispatch via
|
|
11
|
+
* setImmediate/setTimeout so s.dispatching is released first.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
_getDispatching,
|
|
16
|
+
_setDispatching,
|
|
17
|
+
_getSkipDepth,
|
|
18
|
+
_setSkipDepth,
|
|
19
|
+
} from "../auto.ts";
|
|
20
|
+
import { createTestContext } from "./test-helpers.ts";
|
|
21
|
+
|
|
22
|
+
const { assertEq, assertTrue, report } = createTestContext();
|
|
23
|
+
|
|
24
|
+
async function main(): Promise<void> {
|
|
25
|
+
// ─── Test-only accessors work ───────────────────────────────────────────
|
|
26
|
+
console.log("\n=== reentrancy guard: test accessors round-trip ===");
|
|
27
|
+
{
|
|
28
|
+
_setDispatching(false);
|
|
29
|
+
assertEq(_getDispatching(), false, "dispatching starts false");
|
|
30
|
+
|
|
31
|
+
_setDispatching(true);
|
|
32
|
+
assertEq(_getDispatching(), true, "dispatching set to true");
|
|
33
|
+
|
|
34
|
+
_setDispatching(false);
|
|
35
|
+
assertEq(_getDispatching(), false, "dispatching reset to false");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── skipDepth accessors ────────────────────────────────────────────────
|
|
39
|
+
console.log("\n=== reentrancy guard: skipDepth accessors round-trip ===");
|
|
40
|
+
{
|
|
41
|
+
_setSkipDepth(0);
|
|
42
|
+
assertEq(_getSkipDepth(), 0, "skipDepth starts at 0");
|
|
43
|
+
|
|
44
|
+
_setSkipDepth(3);
|
|
45
|
+
assertEq(_getSkipDepth(), 3, "skipDepth set to 3");
|
|
46
|
+
|
|
47
|
+
_setSkipDepth(0);
|
|
48
|
+
assertEq(_getSkipDepth(), 0, "skipDepth reset to 0");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── Guard blocks even when skipDepth > 0 (#1272 regression) ───────────
|
|
52
|
+
console.log("\n=== reentrancy guard: blocks when dispatching=true regardless of skipDepth ===");
|
|
53
|
+
{
|
|
54
|
+
// Simulate the scenario from #1272: dispatching=true + skipDepth>0
|
|
55
|
+
// The old guard (`if (s.dispatching && s.skipDepth === 0)`) would allow
|
|
56
|
+
// concurrent entry when skipDepth > 0. The fix makes the check
|
|
57
|
+
// unconditional on skipDepth.
|
|
58
|
+
_setDispatching(true);
|
|
59
|
+
_setSkipDepth(2);
|
|
60
|
+
|
|
61
|
+
// Verify dispatching is true — guard should block regardless of skipDepth
|
|
62
|
+
assertTrue(
|
|
63
|
+
_getDispatching() === true,
|
|
64
|
+
"dispatching flag is true during skip chain"
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// The actual reentrancy guard in dispatchNextUnit checks:
|
|
68
|
+
// if (s.dispatching) { return; }
|
|
69
|
+
// We verify the state that would trigger the guard:
|
|
70
|
+
const wouldBlock = _getDispatching(); // unconditional check
|
|
71
|
+
const wouldBlockOld = _getDispatching() && _getSkipDepth() === 0; // old check
|
|
72
|
+
|
|
73
|
+
assertTrue(wouldBlock === true, "new guard blocks when dispatching=true, skipDepth=2");
|
|
74
|
+
assertTrue(wouldBlockOld === false, "old guard WOULD NOT block when dispatching=true, skipDepth=2 (the bug)");
|
|
75
|
+
|
|
76
|
+
// Clean up
|
|
77
|
+
_setDispatching(false);
|
|
78
|
+
_setSkipDepth(0);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Guard allows entry when dispatching=false ──────────────────────────
|
|
82
|
+
console.log("\n=== reentrancy guard: allows entry when dispatching=false ===");
|
|
83
|
+
{
|
|
84
|
+
_setDispatching(false);
|
|
85
|
+
_setSkipDepth(0);
|
|
86
|
+
assertTrue(!_getDispatching(), "guard allows entry when dispatching=false, skipDepth=0");
|
|
87
|
+
|
|
88
|
+
_setDispatching(false);
|
|
89
|
+
_setSkipDepth(3);
|
|
90
|
+
assertTrue(!_getDispatching(), "guard allows entry when dispatching=false, skipDepth=3");
|
|
91
|
+
|
|
92
|
+
_setSkipDepth(0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── skipDepth does not affect guard decision (the fix) ─────────────────
|
|
96
|
+
console.log("\n=== reentrancy guard: skipDepth is irrelevant to guard decision ===");
|
|
97
|
+
{
|
|
98
|
+
for (const depth of [0, 1, 2, 5]) {
|
|
99
|
+
_setDispatching(true);
|
|
100
|
+
_setSkipDepth(depth);
|
|
101
|
+
assertTrue(
|
|
102
|
+
_getDispatching() === true,
|
|
103
|
+
`guard blocks at skipDepth=${depth} when dispatching=true`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const depth of [0, 1, 2, 5]) {
|
|
108
|
+
_setDispatching(false);
|
|
109
|
+
_setSkipDepth(depth);
|
|
110
|
+
assertTrue(
|
|
111
|
+
_getDispatching() === false,
|
|
112
|
+
`guard allows at skipDepth=${depth} when dispatching=false`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Clean up
|
|
117
|
+
_setDispatching(false);
|
|
118
|
+
_setSkipDepth(0);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
report();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
main().catch((err) => {
|
|
125
|
+
console.error(err);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
});
|