gsd-pi 2.33.1-dev.ee47f1b → 2.34.0-dev.bbb5216

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.
Files changed (135) hide show
  1. package/dist/bundled-resource-path.d.ts +8 -0
  2. package/dist/bundled-resource-path.js +14 -0
  3. package/dist/headless-query.js +6 -6
  4. package/dist/resources/extensions/gsd/auto/session.js +27 -32
  5. package/dist/resources/extensions/gsd/auto-dashboard.js +29 -109
  6. package/dist/resources/extensions/gsd/auto-direct-dispatch.js +6 -1
  7. package/dist/resources/extensions/gsd/auto-dispatch.js +52 -81
  8. package/dist/resources/extensions/gsd/auto-loop.js +956 -0
  9. package/dist/resources/extensions/gsd/auto-observability.js +4 -2
  10. package/dist/resources/extensions/gsd/auto-post-unit.js +75 -185
  11. package/dist/resources/extensions/gsd/auto-prompts.js +133 -101
  12. package/dist/resources/extensions/gsd/auto-recovery.js +59 -97
  13. package/dist/resources/extensions/gsd/auto-start.js +330 -309
  14. package/dist/resources/extensions/gsd/auto-supervisor.js +5 -11
  15. package/dist/resources/extensions/gsd/auto-timeout-recovery.js +7 -7
  16. package/dist/resources/extensions/gsd/auto-timers.js +3 -4
  17. package/dist/resources/extensions/gsd/auto-verification.js +35 -73
  18. package/dist/resources/extensions/gsd/auto-worktree-sync.js +167 -0
  19. package/dist/resources/extensions/gsd/auto-worktree.js +291 -126
  20. package/dist/resources/extensions/gsd/auto.js +283 -1013
  21. package/dist/resources/extensions/gsd/captures.js +10 -4
  22. package/dist/resources/extensions/gsd/dispatch-guard.js +7 -8
  23. package/dist/resources/extensions/gsd/docs/preferences-reference.md +25 -18
  24. package/dist/resources/extensions/gsd/doctor-checks.js +3 -4
  25. package/dist/resources/extensions/gsd/git-service.js +1 -1
  26. package/dist/resources/extensions/gsd/gsd-db.js +296 -151
  27. package/dist/resources/extensions/gsd/index.js +92 -228
  28. package/dist/resources/extensions/gsd/post-unit-hooks.js +13 -13
  29. package/dist/resources/extensions/gsd/progress-score.js +61 -156
  30. package/dist/resources/extensions/gsd/quick.js +98 -122
  31. package/dist/resources/extensions/gsd/session-lock.js +13 -0
  32. package/dist/resources/extensions/gsd/templates/preferences.md +1 -0
  33. package/dist/resources/extensions/gsd/undo.js +43 -48
  34. package/dist/resources/extensions/gsd/unit-runtime.js +16 -15
  35. package/dist/resources/extensions/gsd/verification-evidence.js +0 -1
  36. package/dist/resources/extensions/gsd/verification-gate.js +6 -35
  37. package/dist/resources/extensions/gsd/worktree-command.js +30 -24
  38. package/dist/resources/extensions/gsd/worktree-manager.js +2 -3
  39. package/dist/resources/extensions/gsd/worktree-resolver.js +344 -0
  40. package/dist/resources/extensions/gsd/worktree.js +7 -44
  41. package/dist/tool-bootstrap.js +59 -11
  42. package/dist/worktree-cli.js +7 -7
  43. package/package.json +1 -1
  44. package/packages/pi-ai/dist/models.generated.d.ts +3630 -5483
  45. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  46. package/packages/pi-ai/dist/models.generated.js +735 -2588
  47. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  48. package/packages/pi-ai/src/models.generated.ts +1039 -2892
  49. package/packages/pi-coding-agent/package.json +1 -1
  50. package/pkg/package.json +1 -1
  51. package/src/resources/extensions/gsd/auto/session.ts +47 -30
  52. package/src/resources/extensions/gsd/auto-dashboard.ts +28 -131
  53. package/src/resources/extensions/gsd/auto-direct-dispatch.ts +6 -1
  54. package/src/resources/extensions/gsd/auto-dispatch.ts +135 -91
  55. package/src/resources/extensions/gsd/auto-loop.ts +1665 -0
  56. package/src/resources/extensions/gsd/auto-observability.ts +4 -2
  57. package/src/resources/extensions/gsd/auto-post-unit.ts +85 -228
  58. package/src/resources/extensions/gsd/auto-prompts.ts +138 -109
  59. package/src/resources/extensions/gsd/auto-recovery.ts +124 -118
  60. package/src/resources/extensions/gsd/auto-start.ts +440 -354
  61. package/src/resources/extensions/gsd/auto-supervisor.ts +5 -12
  62. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +8 -8
  63. package/src/resources/extensions/gsd/auto-timers.ts +3 -4
  64. package/src/resources/extensions/gsd/auto-verification.ts +76 -90
  65. package/src/resources/extensions/gsd/auto-worktree-sync.ts +204 -0
  66. package/src/resources/extensions/gsd/auto-worktree.ts +389 -141
  67. package/src/resources/extensions/gsd/auto.ts +515 -1199
  68. package/src/resources/extensions/gsd/captures.ts +10 -4
  69. package/src/resources/extensions/gsd/dispatch-guard.ts +13 -9
  70. package/src/resources/extensions/gsd/docs/preferences-reference.md +25 -18
  71. package/src/resources/extensions/gsd/doctor-checks.ts +3 -4
  72. package/src/resources/extensions/gsd/git-service.ts +8 -1
  73. package/src/resources/extensions/gsd/gitignore.ts +4 -2
  74. package/src/resources/extensions/gsd/gsd-db.ts +375 -180
  75. package/src/resources/extensions/gsd/index.ts +104 -263
  76. package/src/resources/extensions/gsd/post-unit-hooks.ts +13 -13
  77. package/src/resources/extensions/gsd/progress-score.ts +65 -200
  78. package/src/resources/extensions/gsd/quick.ts +121 -125
  79. package/src/resources/extensions/gsd/session-lock.ts +11 -0
  80. package/src/resources/extensions/gsd/templates/preferences.md +1 -0
  81. package/src/resources/extensions/gsd/tests/agent-end-retry.test.ts +32 -59
  82. package/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts +75 -27
  83. package/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +1 -1
  84. package/src/resources/extensions/gsd/tests/auto-lock-creation.test.ts +37 -0
  85. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +1458 -0
  86. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +8 -162
  87. package/src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts +2 -108
  88. package/src/resources/extensions/gsd/tests/auto-session-encapsulation.test.ts +1 -3
  89. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +0 -3
  90. package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +58 -0
  91. package/src/resources/extensions/gsd/tests/dispatch-guard.test.ts +0 -55
  92. package/src/resources/extensions/gsd/tests/headless-query.test.ts +22 -0
  93. package/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +8 -11
  94. package/src/resources/extensions/gsd/tests/provider-errors.test.ts +4 -6
  95. package/src/resources/extensions/gsd/tests/run-uat.test.ts +3 -3
  96. package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +64 -0
  97. package/src/resources/extensions/gsd/tests/sidecar-queue.test.ts +181 -0
  98. package/src/resources/extensions/gsd/tests/stale-worktree-cwd.test.ts +0 -3
  99. package/src/resources/extensions/gsd/tests/token-profile.test.ts +6 -6
  100. package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +6 -6
  101. package/src/resources/extensions/gsd/tests/undo.test.ts +6 -0
  102. package/src/resources/extensions/gsd/tests/verification-evidence.test.ts +24 -26
  103. package/src/resources/extensions/gsd/tests/verification-gate.test.ts +7 -201
  104. package/src/resources/extensions/gsd/tests/worktree-db-integration.test.ts +205 -0
  105. package/src/resources/extensions/gsd/tests/worktree-db.test.ts +442 -0
  106. package/src/resources/extensions/gsd/tests/worktree-e2e.test.ts +0 -3
  107. package/src/resources/extensions/gsd/tests/worktree-resolver.test.ts +705 -0
  108. package/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +57 -106
  109. package/src/resources/extensions/gsd/tests/worktree.test.ts +5 -1
  110. package/src/resources/extensions/gsd/tests/write-gate.test.ts +43 -132
  111. package/src/resources/extensions/gsd/types.ts +90 -81
  112. package/src/resources/extensions/gsd/undo.ts +42 -46
  113. package/src/resources/extensions/gsd/unit-runtime.ts +14 -18
  114. package/src/resources/extensions/gsd/verification-evidence.ts +1 -3
  115. package/src/resources/extensions/gsd/verification-gate.ts +6 -39
  116. package/src/resources/extensions/gsd/worktree-command.ts +36 -24
  117. package/src/resources/extensions/gsd/worktree-manager.ts +2 -3
  118. package/src/resources/extensions/gsd/worktree-resolver.ts +485 -0
  119. package/src/resources/extensions/gsd/worktree.ts +7 -44
  120. package/dist/resources/extensions/gsd/auto-constants.js +0 -5
  121. package/dist/resources/extensions/gsd/auto-idempotency.js +0 -106
  122. package/dist/resources/extensions/gsd/auto-stuck-detection.js +0 -165
  123. package/dist/resources/extensions/gsd/mechanical-completion.js +0 -351
  124. package/src/resources/extensions/gsd/auto-constants.ts +0 -6
  125. package/src/resources/extensions/gsd/auto-idempotency.ts +0 -151
  126. package/src/resources/extensions/gsd/auto-stuck-detection.ts +0 -221
  127. package/src/resources/extensions/gsd/mechanical-completion.ts +0 -430
  128. package/src/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +0 -691
  129. package/src/resources/extensions/gsd/tests/auto-reentrancy-guard.test.ts +0 -127
  130. package/src/resources/extensions/gsd/tests/auto-skip-loop.test.ts +0 -123
  131. package/src/resources/extensions/gsd/tests/dispatch-stall-guard.test.ts +0 -126
  132. package/src/resources/extensions/gsd/tests/loop-regression.test.ts +0 -874
  133. package/src/resources/extensions/gsd/tests/mechanical-completion.test.ts +0 -356
  134. package/src/resources/extensions/gsd/tests/progress-score.test.ts +0 -206
  135. package/src/resources/extensions/gsd/tests/session-lock.test.ts +0 -434
@@ -14,8 +14,6 @@ import type {
14
14
  import { resolvePostUnitHooks, resolvePreDispatchHooks } from "./preferences.js";
15
15
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
16
16
  import { join } from "node:path";
17
- import { gsdRoot } from "./paths.js";
18
- import { parseUnitId } from "./unit-id.js";
19
17
 
20
18
  // ─── Hook Queue State ──────────────────────────────────────────────────────
21
19
 
@@ -150,7 +148,7 @@ function dequeueNextHook(basePath: string): HookDispatchResult | null {
150
148
  };
151
149
 
152
150
  // Build the prompt with variable substitution
153
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(triggerUnitId);
151
+ const [mid, sid, tid] = triggerUnitId.split("/");
154
152
  const prompt = config.prompt
155
153
  .replace(/\{milestoneId\}/g, mid ?? "")
156
154
  .replace(/\{sliceId\}/g, sid ?? "")
@@ -209,14 +207,16 @@ function handleHookCompletion(basePath: string): HookDispatchResult | null {
209
207
  * - Milestone-level (M001): .gsd/M001/{artifact}
210
208
  */
211
209
  export function resolveHookArtifactPath(basePath: string, unitId: string, artifactName: string): string {
212
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
213
- if (mid && sid && tid) {
214
- return join(gsdRoot(basePath), mid, "slices", sid, "tasks", `${tid}-${artifactName}`);
210
+ const parts = unitId.split("/");
211
+ if (parts.length === 3) {
212
+ const [mid, sid, tid] = parts;
213
+ return join(basePath, ".gsd", mid, "slices", sid, "tasks", `${tid}-${artifactName}`);
215
214
  }
216
- if (mid && sid) {
217
- return join(gsdRoot(basePath), mid, "slices", sid, artifactName);
215
+ if (parts.length === 2) {
216
+ const [mid, sid] = parts;
217
+ return join(basePath, ".gsd", mid, "slices", sid, artifactName);
218
218
  }
219
- return join(gsdRoot(basePath), mid, artifactName);
219
+ return join(basePath, ".gsd", parts[0], artifactName);
220
220
  }
221
221
 
222
222
  // ═══════════════════════════════════════════════════════════════════════════
@@ -252,7 +252,7 @@ export function runPreDispatchHooks(
252
252
  return { action: "proceed", prompt, firedHooks: [] };
253
253
  }
254
254
 
255
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
255
+ const [mid, sid, tid] = unitId.split("/");
256
256
  const substitute = (text: string): string =>
257
257
  text
258
258
  .replace(/\{milestoneId\}/g, mid ?? "")
@@ -310,7 +310,7 @@ export function runPreDispatchHooks(
310
310
  const HOOK_STATE_FILE = "hook-state.json";
311
311
 
312
312
  function hookStatePath(basePath: string): string {
313
- return join(gsdRoot(basePath), HOOK_STATE_FILE);
313
+ return join(basePath, ".gsd", HOOK_STATE_FILE);
314
314
  }
315
315
 
316
316
  /**
@@ -323,7 +323,7 @@ export function persistHookState(basePath: string): void {
323
323
  savedAt: new Date().toISOString(),
324
324
  };
325
325
  try {
326
- const dir = gsdRoot(basePath);
326
+ const dir = join(basePath, ".gsd");
327
327
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
328
328
  writeFileSync(hookStatePath(basePath), JSON.stringify(state, null, 2), "utf-8");
329
329
  } catch {
@@ -465,7 +465,7 @@ export function triggerHookManually(
465
465
  activeHook.cycle = currentCycle;
466
466
 
467
467
  // Build the prompt with variable substitution
468
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
468
+ const [mid, sid, tid] = unitId.split("/");
469
469
  const prompt = hook.prompt
470
470
  .replace(/\{milestoneId\}/g, mid ?? "")
471
471
  .replace(/\{sliceId\}/g, sid ?? "")
@@ -28,246 +28,111 @@ export interface ProgressScore {
28
28
  }
29
29
 
30
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
- }
31
+ kind: "positive" | "negative" | "neutral";
32
+ label: string;
51
33
  }
52
34
 
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` };
35
+ function escalateLevel(level: ProgressLevel, next: ProgressLevel): ProgressLevel {
36
+ const ranks: Record<ProgressLevel, number> = {
37
+ green: 0,
38
+ yellow: 1,
39
+ red: 2,
40
+ };
41
+ return ranks[next] > ranks[level] ? next : level;
63
42
  }
64
43
 
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]!;
44
+ // ── Public API ──────────────────────────────────────────────────────────────
72
45
 
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
- }
46
+ /**
47
+ * Compute the current progress score from health signals.
48
+ */
49
+ export function computeProgressScore(): ProgressScore {
50
+ const signals: ProgressSignal[] = [];
51
+ let level: ProgressLevel = "green";
84
52
 
85
- function evaluateArtifactProduction(): ProgressSignal {
86
- const history = getHealthHistory();
87
- if (history.length < 2) {
88
- return { name: "artifact_production", level: "green", detail: "Insufficient data" };
53
+ // Check consecutive errors
54
+ const consecutiveErrors = getConsecutiveErrorUnits();
55
+ if (consecutiveErrors >= 3) {
56
+ signals.push({ kind: "negative", label: `${consecutiveErrors} consecutive error units` });
57
+ level = escalateLevel(level, "red");
58
+ } else if (consecutiveErrors >= 1) {
59
+ signals.push({ kind: "negative", label: `${consecutiveErrors} consecutive error unit(s)` });
60
+ level = escalateLevel(level, "yellow");
89
61
  }
90
62
 
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" };
63
+ // Check health trend
64
+ const trend = getHealthTrend();
65
+ if (trend === "degrading") {
66
+ signals.push({ kind: "negative", label: "Health trend declining" });
67
+ level = escalateLevel(level, "yellow");
68
+ } else if (trend === "improving") {
69
+ signals.push({ kind: "positive", label: "Health trend improving" });
70
+ } else if (trend === "stable") {
71
+ signals.push({ kind: "neutral", label: "Health trend stable" });
99
72
  }
100
73
 
101
- return { name: "artifact_production", level: "green", detail: `${totalFixes} total fixes applied` };
102
- }
103
-
104
- function evaluateDispatchVelocity(): ProgressSignal {
74
+ // Check recent history
105
75
  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` };
76
+ if (history.length === 0) {
77
+ signals.push({ kind: "neutral", label: "No health data yet" });
127
78
  }
128
79
 
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);
80
+ const summary = level === "green"
81
+ ? "Progressing well"
82
+ : level === "yellow"
83
+ ? "Some issues detected"
84
+ : "Stuck or erroring";
156
85
 
157
86
  return { level, summary, signals };
158
87
  }
159
88
 
160
89
  /**
161
- * Compute progress score with additional context from the current unit.
90
+ * Compute progress score with additional context for dashboard display.
162
91
  */
163
92
  export function computeProgressScoreWithContext(context: {
164
- currentUnitType?: string;
165
- currentUnitId?: string;
166
- completedUnits?: number;
167
- totalUnits?: number;
168
- retryCount?: number;
169
- maxRetries?: number;
93
+ sameUnitCount?: number;
94
+ recoveryCount?: number;
95
+ completedCount?: number;
170
96
  }): ProgressScore {
171
97
  const base = computeProgressScore();
172
98
 
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";
99
+ if (context.sameUnitCount && context.sameUnitCount >= 3) {
100
+ base.signals.push({ kind: "negative", label: `Same unit dispatched ${context.sameUnitCount}× consecutively` });
101
+ base.level = escalateLevel(base.level, "red");
102
+ base.summary = "Stuck on same unit";
103
+ } else if (context.sameUnitCount && context.sameUnitCount >= 2) {
104
+ base.signals.push({ kind: "negative", label: `Same unit dispatched ${context.sameUnitCount}×` });
105
+ base.level = escalateLevel(base.level, "yellow");
186
106
  }
187
107
 
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
- }
108
+ if (context.recoveryCount && context.recoveryCount > 0) {
109
+ base.signals.push({ kind: "negative", label: `${context.recoveryCount} recovery attempts` });
110
+ base.level = escalateLevel(base.level, "yellow");
208
111
  }
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
112
 
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
- }
113
+ if (context.completedCount && context.completedCount > 0) {
114
+ base.signals.push({ kind: "positive", label: `${context.completedCount} units completed` });
242
115
  }
116
+
117
+ return base;
243
118
  }
244
119
 
245
120
  /**
246
- * Format progress score as a single-line traffic light for TUI display.
121
+ * Format a one-line progress indicator for dashboard/status display.
247
122
  */
248
123
  export function formatProgressLine(score: ProgressScore): string {
249
- const icon = score.level === "green" ? "\uD83D\uDFE2"
250
- : score.level === "yellow" ? "\uD83D\uDFE1"
251
- : "\uD83D\uDD34";
124
+ const icon = score.level === "green" ? "" : score.level === "yellow" ? "◐" : "○";
252
125
  return `${icon} ${score.summary}`;
253
126
  }
254
127
 
255
128
  /**
256
- * Format a detailed progress report showing all signals.
129
+ * Format a multi-line progress report.
257
130
  */
258
131
  export function formatProgressReport(score: ProgressScore): string {
259
- const lines: string[] = [];
260
-
261
- lines.push(formatProgressLine(score));
262
- lines.push("");
263
- lines.push("Signals:");
264
-
132
+ const lines = [formatProgressLine(score)];
265
133
  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}`);
134
+ const prefix = signal.kind === "positive" ? "" : signal.kind === "negative" ? " ✗" : " ·";
135
+ lines.push(`${prefix} ${signal.label}`);
270
136
  }
271
-
272
137
  return lines.join("\n");
273
138
  }
@@ -10,12 +10,24 @@
10
10
  */
11
11
 
12
12
  import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent";
13
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
13
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } 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 { createGitService, runGit } from "./git-service.js";
18
- import { getErrorMessage } from "./error-utils.js";
17
+ import { GitServiceImpl, runGit } from "./git-service.js";
18
+ import { loadEffectiveGSDPreferences } from "./preferences.js";
19
+ import { nativeHasStagedChanges } from "./native-git-bridge.js";
20
+
21
+ interface QuickReturnState {
22
+ basePath: string;
23
+ originalBranch: string;
24
+ quickBranch: string;
25
+ taskNum: number;
26
+ slug: string;
27
+ description: string;
28
+ }
29
+
30
+ let pendingQuickReturn: QuickReturnState | null = null;
19
31
 
20
32
  // ─── Quick Task Helpers ───────────────────────────────────────────────────────
21
33
 
@@ -65,6 +77,84 @@ function ensureQuickDir(basePath: string, taskNum: number, slug: string): string
65
77
  return taskDir;
66
78
  }
67
79
 
80
+ function quickReturnStatePath(basePath: string): string {
81
+ return join(gsdRoot(basePath), "runtime", "quick-return.json");
82
+ }
83
+
84
+ function persistPendingReturn(state: QuickReturnState): void {
85
+ pendingQuickReturn = state;
86
+ mkdirSync(join(gsdRoot(state.basePath), "runtime"), { recursive: true });
87
+ writeFileSync(quickReturnStatePath(state.basePath), JSON.stringify(state) + "\n", "utf-8");
88
+ }
89
+
90
+ function readPendingReturn(basePath: string): QuickReturnState | null {
91
+ if (pendingQuickReturn && pendingQuickReturn.basePath === basePath) {
92
+ return pendingQuickReturn;
93
+ }
94
+
95
+ try {
96
+ const raw = readFileSync(quickReturnStatePath(basePath), "utf-8");
97
+ const parsed = JSON.parse(raw) as Partial<QuickReturnState>;
98
+ if (
99
+ typeof parsed.basePath === "string"
100
+ && typeof parsed.originalBranch === "string"
101
+ && typeof parsed.quickBranch === "string"
102
+ && typeof parsed.taskNum === "number"
103
+ && typeof parsed.slug === "string"
104
+ && typeof parsed.description === "string"
105
+ ) {
106
+ pendingQuickReturn = parsed as QuickReturnState;
107
+ return pendingQuickReturn;
108
+ }
109
+ } catch {
110
+ // No persisted quick-return state
111
+ }
112
+
113
+ return null;
114
+ }
115
+
116
+ function clearPendingReturn(basePath: string): void {
117
+ if (pendingQuickReturn?.basePath === basePath) {
118
+ pendingQuickReturn = null;
119
+ }
120
+ rmSync(quickReturnStatePath(basePath), { force: true });
121
+ }
122
+
123
+ function hasStagedChanges(basePath: string): boolean {
124
+ return nativeHasStagedChanges(basePath);
125
+ }
126
+
127
+ export function cleanupQuickBranch(basePath = process.cwd()): boolean {
128
+ const state = readPendingReturn(basePath);
129
+ if (!state) return false;
130
+
131
+ const repoPath = state.basePath;
132
+ const gitPrefs = loadEffectiveGSDPreferences()?.preferences?.git ?? {};
133
+ const git = new GitServiceImpl(repoPath, gitPrefs);
134
+
135
+ if (git.getCurrentBranch() === state.quickBranch) {
136
+ try {
137
+ git.autoCommit("quick-task", `Q${state.taskNum}`, []);
138
+ } catch {
139
+ // Best-effort: quick work may already be committed.
140
+ }
141
+ }
142
+
143
+ if (git.getCurrentBranch() !== state.originalBranch) {
144
+ runGit(repoPath, ["checkout", state.originalBranch]);
145
+ }
146
+
147
+ runGit(repoPath, ["merge", "--squash", state.quickBranch]);
148
+
149
+ if (hasStagedChanges(repoPath)) {
150
+ runGit(repoPath, ["commit", "-m", `quick(Q${state.taskNum}): ${state.slug}`]);
151
+ }
152
+
153
+ runGit(repoPath, ["branch", "-D", state.quickBranch], { allowFailure: true });
154
+ clearPendingReturn(repoPath);
155
+ return true;
156
+ }
157
+
68
158
  // ─── Main Handler ─────────────────────────────────────────────────────────────
69
159
 
70
160
  export async function handleQuick(
@@ -102,33 +192,41 @@ export async function handleQuick(
102
192
  const taskDirRel = `.gsd/quick/${taskNum}-${slug}`;
103
193
  const date = new Date().toISOString().split("T")[0];
104
194
 
105
- // Create git branch for the quick task (unless isolation: none)
106
- const git = createGitService(basePath);
195
+ // Create git branch for the quick task
196
+ const gitPrefs = loadEffectiveGSDPreferences()?.preferences?.git ?? {};
197
+ const git = new GitServiceImpl(basePath, gitPrefs);
107
198
  const branchName = `gsd/quick/${taskNum}-${slug}`;
108
- const skipBranch = git.prefs.isolation === "none";
199
+ let originalBranch = git.getCurrentBranch();
109
200
 
110
201
  let branchCreated = false;
111
- let originalBranch: string | undefined;
112
- if (!skipBranch) {
113
- try {
114
- originalBranch = git.getCurrentBranch();
115
- if (originalBranch !== branchName) {
116
- // Auto-commit any dirty state before switching
117
- try {
118
- git.autoCommit("quick-task", `Q${taskNum}`, []);
119
- } catch { /* nothing to commit — fine */ }
120
-
121
- runGit(basePath, ["checkout", "-b", branchName]);
122
- branchCreated = true;
123
- }
124
- } catch (err) {
125
- // Branch creation failed — continue on current branch
126
- const message = getErrorMessage(err);
127
- ctx.ui.notify(`Could not create branch ${branchName}: ${message}. Working on current branch.`, "warning");
202
+ try {
203
+ const current = originalBranch;
204
+ if (current !== branchName) {
205
+ // Auto-commit any dirty state before switching
206
+ try {
207
+ git.autoCommit("quick-task", `Q${taskNum}`, []);
208
+ } catch { /* nothing to commit — fine */ }
209
+
210
+ runGit(basePath, ["checkout", "-b", branchName]);
211
+ branchCreated = true;
128
212
  }
213
+ } catch (err) {
214
+ // Branch creation failed — continue on current branch
215
+ const message = err instanceof Error ? err.message : String(err);
216
+ ctx.ui.notify(`Could not create branch ${branchName}: ${message}. Working on current branch.`, "warning");
129
217
  }
130
218
 
131
219
  const actualBranch = branchCreated ? branchName : git.getCurrentBranch();
220
+ if (actualBranch === branchName && originalBranch !== branchName) {
221
+ persistPendingReturn({
222
+ basePath,
223
+ originalBranch,
224
+ quickBranch: branchName,
225
+ taskNum,
226
+ slug,
227
+ description,
228
+ });
229
+ }
132
230
 
133
231
  // Notify user
134
232
  ctx.ui.notify(
@@ -156,106 +254,4 @@ export async function handleQuick(
156
254
  },
157
255
  { triggerTurn: true },
158
256
  );
159
-
160
- // Schedule branch merge-back after the quick task agent session ends.
161
- // Without this, auto-mode resumes on the quick-task branch (#1269).
162
- if (branchCreated && originalBranch) {
163
- _pendingQuickBranchReturn = {
164
- basePath,
165
- originalBranch,
166
- quickBranch: branchName,
167
- taskNum,
168
- slug,
169
- description,
170
- };
171
- // Persist to disk so recovery works across session crashes (#1293).
172
- persistPendingReturn(_pendingQuickBranchReturn, basePath);
173
- }
174
- }
175
-
176
- /** Pending quick-task branch return — consumed by cleanupQuickBranch(). */
177
- let _pendingQuickBranchReturn: {
178
- basePath: string;
179
- originalBranch: string;
180
- quickBranch: string;
181
- taskNum: number;
182
- slug: string;
183
- description: string;
184
- } | null = null;
185
-
186
- // ─── Disk Persistence ─────────────────────────────────────────────────────
187
-
188
- /** Path to the pending quick-task return file. */
189
- function pendingReturnPath(basePath: string): string {
190
- return join(gsdRoot(basePath), "runtime", "quick-return.json");
191
- }
192
-
193
- /** Write pending return state to disk. */
194
- function persistPendingReturn(state: NonNullable<typeof _pendingQuickBranchReturn>, basePath: string): void {
195
- const filePath = pendingReturnPath(basePath);
196
- mkdirSync(join(gsdRoot(basePath), "runtime"), { recursive: true });
197
- writeFileSync(filePath, JSON.stringify(state, null, 2) + "\n", "utf-8");
198
- }
199
-
200
- /** Remove pending return file from disk. */
201
- function clearPendingReturn(basePath: string): void {
202
- try { unlinkSync(pendingReturnPath(basePath)); } catch { /* already gone */ }
203
- }
204
-
205
- /** Load pending return from disk (cross-session recovery). */
206
- function loadPendingReturn(basePath: string): NonNullable<typeof _pendingQuickBranchReturn> | null {
207
- const filePath = pendingReturnPath(basePath);
208
- if (!existsSync(filePath)) return null;
209
- try {
210
- return JSON.parse(readFileSync(filePath, "utf-8"));
211
- } catch {
212
- return null;
213
- }
214
- }
215
-
216
- /**
217
- * Merge the quick-task branch back to the original branch and switch.
218
- * Called from the agent_end handler after a quick task completes.
219
- *
220
- * Checks both in-memory state (same session) and disk state (cross-session
221
- * recovery for crashed/interrupted sessions).
222
- *
223
- * Returns true if a branch return was performed.
224
- */
225
- export function cleanupQuickBranch(): boolean {
226
- // Prefer in-memory state; fall back to disk for cross-session recovery
227
- let state = _pendingQuickBranchReturn;
228
- if (!state) {
229
- // Try loading from disk — handles the case where the session that
230
- // started the quick task crashed before agent_end could run (#1293).
231
- const basePath = process.cwd();
232
- state = loadPendingReturn(basePath);
233
- }
234
- if (!state) return false;
235
-
236
- _pendingQuickBranchReturn = null;
237
- const { basePath, originalBranch, quickBranch, taskNum, slug, description } = state;
238
-
239
- try {
240
- // Auto-commit any remaining work
241
- try { runGit(basePath, ["add", "-A"]); } catch {}
242
- try { runGit(basePath, ["commit", "-m", `quick(Q${taskNum}): ${slug}`]); } catch {}
243
-
244
- // Switch back and merge
245
- runGit(basePath, ["checkout", originalBranch]);
246
- try {
247
- runGit(basePath, ["merge", "--squash", quickBranch]);
248
- runGit(basePath, ["commit", "-m", `quick(Q${taskNum}): ${description.slice(0, 72)}`]);
249
- } catch { /* merge conflict or nothing — non-fatal */ }
250
-
251
- // Clean up quick branch
252
- try { runGit(basePath, ["branch", "-D", quickBranch]); } catch {}
253
-
254
- // Clean up disk state
255
- clearPendingReturn(basePath);
256
- return true;
257
- } catch {
258
- // Cleanup failed — leave disk state for next attempt
259
- return false;
260
- }
261
257
  }