gsd-pi 2.73.0-dev.e1c09f2 → 2.73.1-dev.06e4302

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 (166) hide show
  1. package/dist/cli-web-branch.d.ts +4 -3
  2. package/dist/cli-web-branch.js +10 -7
  3. package/dist/cli.js +99 -206
  4. package/dist/logo.d.ts +1 -1
  5. package/dist/logo.js +1 -1
  6. package/dist/onboarding.js +59 -53
  7. package/dist/resource-loader.js +2 -2
  8. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +68 -4
  9. package/dist/resources/extensions/gsd/auto/phases.js +15 -9
  10. package/dist/resources/extensions/gsd/auto-dispatch.js +11 -3
  11. package/dist/resources/extensions/gsd/auto-model-selection.js +54 -11
  12. package/dist/resources/extensions/gsd/auto-post-unit.js +41 -1
  13. package/dist/resources/extensions/gsd/auto-start.js +23 -6
  14. package/dist/resources/extensions/gsd/auto-timeout-recovery.js +13 -0
  15. package/dist/resources/extensions/gsd/auto-verification.js +88 -3
  16. package/dist/resources/extensions/gsd/auto.js +34 -9
  17. package/dist/resources/extensions/gsd/bootstrap/crash-log.js +31 -0
  18. package/dist/resources/extensions/gsd/bootstrap/register-extension.js +18 -7
  19. package/dist/resources/extensions/gsd/commands-handlers.js +8 -2
  20. package/dist/resources/extensions/gsd/crash-recovery.js +51 -0
  21. package/dist/resources/extensions/gsd/docs/preferences-reference.md +1 -1
  22. package/dist/resources/extensions/gsd/gsd-db.js +36 -2
  23. package/dist/resources/extensions/gsd/milestone-actions.js +19 -1
  24. package/dist/resources/extensions/gsd/notification-widget.js +2 -2
  25. package/dist/resources/extensions/gsd/preferences-models.js +43 -0
  26. package/dist/resources/extensions/gsd/preferences-types.js +1 -0
  27. package/dist/resources/extensions/gsd/preferences-validation.js +22 -0
  28. package/dist/resources/extensions/gsd/state.js +61 -14
  29. package/dist/update-check.d.ts +1 -0
  30. package/dist/update-check.js +13 -5
  31. package/dist/update-cmd.js +4 -3
  32. package/dist/web/standalone/.next/BUILD_ID +1 -1
  33. package/dist/web/standalone/.next/app-path-routes-manifest.json +12 -12
  34. package/dist/web/standalone/.next/build-manifest.json +2 -2
  35. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  36. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  37. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  45. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/index.html +1 -1
  53. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app-paths-manifest.json +12 -12
  60. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  61. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  62. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  63. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  64. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  65. package/package.json +1 -2
  66. package/packages/pi-ai/dist/index.d.ts +1 -0
  67. package/packages/pi-ai/dist/index.d.ts.map +1 -1
  68. package/packages/pi-ai/dist/index.js +1 -0
  69. package/packages/pi-ai/dist/index.js.map +1 -1
  70. package/packages/pi-ai/dist/utils/overflow.d.ts.map +1 -1
  71. package/packages/pi-ai/dist/utils/overflow.js +12 -0
  72. package/packages/pi-ai/dist/utils/overflow.js.map +1 -1
  73. package/packages/pi-ai/dist/utils/tests/overflow.test.d.ts +2 -0
  74. package/packages/pi-ai/dist/utils/tests/overflow.test.d.ts.map +1 -0
  75. package/packages/pi-ai/dist/utils/tests/overflow.test.js +50 -0
  76. package/packages/pi-ai/dist/utils/tests/overflow.test.js.map +1 -0
  77. package/packages/pi-ai/src/index.ts +4 -0
  78. package/packages/pi-ai/src/utils/overflow.ts +14 -1
  79. package/packages/pi-ai/src/utils/tests/overflow.test.ts +58 -0
  80. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js +313 -8
  81. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js.map +1 -1
  82. package/packages/pi-coding-agent/dist/core/compaction/utils.js +5 -5
  83. package/packages/pi-coding-agent/dist/core/compaction/utils.js.map +1 -1
  84. package/packages/pi-coding-agent/dist/core/compaction-utils.test.d.ts +2 -0
  85. package/packages/pi-coding-agent/dist/core/compaction-utils.test.d.ts.map +1 -0
  86. package/packages/pi-coding-agent/dist/core/compaction-utils.test.js +45 -0
  87. package/packages/pi-coding-agent/dist/core/compaction-utils.test.js.map +1 -0
  88. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts +12 -2
  89. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  90. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js +61 -28
  91. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js.map +1 -1
  92. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.d.ts +2 -1
  93. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.d.ts.map +1 -1
  94. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.js +9 -3
  95. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.js.map +1 -1
  96. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.test.d.ts +2 -0
  97. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.test.d.ts.map +1 -0
  98. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.test.js +52 -0
  99. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.test.js.map +1 -0
  100. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  101. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +94 -16
  102. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  103. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  104. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +11 -3
  105. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  106. package/packages/pi-coding-agent/package.json +1 -1
  107. package/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts +355 -8
  108. package/packages/pi-coding-agent/src/core/compaction/utils.ts +5 -5
  109. package/packages/pi-coding-agent/src/core/compaction-utils.test.ts +50 -0
  110. package/packages/pi-coding-agent/src/modes/interactive/components/assistant-message.ts +74 -32
  111. package/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.test.ts +73 -0
  112. package/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.ts +9 -3
  113. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +113 -21
  114. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +11 -3
  115. package/packages/pi-tui/dist/__tests__/tui.test.js +60 -1
  116. package/packages/pi-tui/dist/__tests__/tui.test.js.map +1 -1
  117. package/packages/pi-tui/dist/tui.d.ts +8 -0
  118. package/packages/pi-tui/dist/tui.d.ts.map +1 -1
  119. package/packages/pi-tui/dist/tui.js +32 -3
  120. package/packages/pi-tui/dist/tui.js.map +1 -1
  121. package/packages/pi-tui/src/__tests__/tui.test.ts +76 -1
  122. package/packages/pi-tui/src/tui.ts +31 -3
  123. package/pkg/package.json +1 -1
  124. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +107 -5
  125. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +111 -2
  126. package/src/resources/extensions/gsd/auto/phases.ts +22 -9
  127. package/src/resources/extensions/gsd/auto-dispatch.ts +10 -4
  128. package/src/resources/extensions/gsd/auto-model-selection.ts +85 -11
  129. package/src/resources/extensions/gsd/auto-post-unit.ts +47 -1
  130. package/src/resources/extensions/gsd/auto-start.ts +30 -6
  131. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +17 -0
  132. package/src/resources/extensions/gsd/auto-verification.ts +98 -3
  133. package/src/resources/extensions/gsd/auto.ts +36 -14
  134. package/src/resources/extensions/gsd/bootstrap/crash-log.ts +32 -0
  135. package/src/resources/extensions/gsd/bootstrap/register-extension.ts +19 -7
  136. package/src/resources/extensions/gsd/commands-handlers.ts +8 -2
  137. package/src/resources/extensions/gsd/crash-recovery.ts +59 -0
  138. package/src/resources/extensions/gsd/docs/preferences-reference.md +1 -1
  139. package/src/resources/extensions/gsd/gsd-db.ts +52 -2
  140. package/src/resources/extensions/gsd/milestone-actions.ts +19 -1
  141. package/src/resources/extensions/gsd/notification-widget.ts +2 -2
  142. package/src/resources/extensions/gsd/preferences-models.ts +41 -0
  143. package/src/resources/extensions/gsd/preferences-types.ts +12 -0
  144. package/src/resources/extensions/gsd/preferences-validation.ts +23 -0
  145. package/src/resources/extensions/gsd/state.ts +71 -15
  146. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +2 -2
  147. package/src/resources/extensions/gsd/tests/auto-post-unit-step-message.test.ts +53 -0
  148. package/src/resources/extensions/gsd/tests/auto-start-model-capture.test.ts +51 -2
  149. package/src/resources/extensions/gsd/tests/complete-milestone-false-merge.test.ts +142 -0
  150. package/src/resources/extensions/gsd/tests/completed-at-reconcile.test.ts +42 -0
  151. package/src/resources/extensions/gsd/tests/crash-handler-secondary.test.ts +235 -0
  152. package/src/resources/extensions/gsd/tests/derive-state-crossval.test.ts +3 -2
  153. package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +3 -2
  154. package/src/resources/extensions/gsd/tests/derive-state-helpers.test.ts +68 -8
  155. package/src/resources/extensions/gsd/tests/derive-state.test.ts +3 -3
  156. package/src/resources/extensions/gsd/tests/flat-rate-routing-guard.test.ts +137 -1
  157. package/src/resources/extensions/gsd/tests/gsd-db.test.ts +59 -1
  158. package/src/resources/extensions/gsd/tests/integration/state-machine-edge-cases.test.ts +4 -2
  159. package/src/resources/extensions/gsd/tests/model-isolation.test.ts +91 -2
  160. package/src/resources/extensions/gsd/tests/park-milestone.test.ts +64 -0
  161. package/src/resources/extensions/gsd/tests/preferences.test.ts +47 -0
  162. package/src/resources/extensions/gsd/tests/state-machine-full-walkthrough.test.ts +5 -7
  163. package/src/resources/extensions/gsd/tests/token-profile.test.ts +1 -1
  164. package/src/resources/extensions/gsd/tests/validate-milestone-stuck-guard.test.ts +179 -0
  165. /package/dist/web/standalone/.next/static/{_XD_gUDcZNBbWV5rI8RgS → RXD20AQgB9BHSQJ07MDdd}/_buildManifest.js +0 -0
  166. /package/dist/web/standalone/.next/static/{_XD_gUDcZNBbWV5rI8RgS → RXD20AQgB9BHSQJ07MDdd}/_ssgManifest.js +0 -0
@@ -83,7 +83,11 @@ import { join } from "node:path";
83
83
  import { sep as pathSep } from "node:path";
84
84
 
85
85
  import { resolveProjectRootDbPath } from "./bootstrap/dynamic-tools.js";
86
- import { resolveDefaultSessionModel, resolveDynamicRoutingConfig } from "./preferences-models.js";
86
+ import {
87
+ isCustomProvider,
88
+ resolveDefaultSessionModel,
89
+ resolveDynamicRoutingConfig,
90
+ } from "./preferences-models.js";
87
91
  import type { WorktreeResolver } from "./worktree-resolver.js";
88
92
  import { getSessionModelOverride } from "./session-model-override.js";
89
93
 
@@ -274,8 +278,18 @@ export async function bootstrapAutoSession(
274
278
  //
275
279
  // This preserves #3517 defaults while honoring explicit runtime model
276
280
  // selection for subsequent /gsd runs in the same session.
281
+ //
282
+ // Exception (#4122): when the session provider is a custom provider declared
283
+ // in ~/.gsd/agent/models.json (Ollama, vLLM, OpenAI-compatible proxy, etc.),
284
+ // PREFERENCES.md is skipped entirely. PREFERENCES.md cannot reference custom
285
+ // providers, so honoring it would silently reroute auto-mode to a built-in
286
+ // provider the user is not logged into and surface as "Not logged in · Please
287
+ // run /login" before pausing and resetting to claude-code/claude-sonnet-4-6.
277
288
  const manualSessionOverride = getSessionModelOverride(ctx.sessionManager.getSessionId());
278
- const preferredModel = resolveDefaultSessionModel(ctx.model?.provider);
289
+ const sessionProviderIsCustom = isCustomProvider(ctx.model?.provider);
290
+ const preferredModel = sessionProviderIsCustom
291
+ ? null
292
+ : resolveDefaultSessionModel(ctx.model?.provider);
279
293
  // Validate the preferred model against the live registry + provider auth so
280
294
  // an unconfigured PREFERENCES.md entry (no API key / OAuth) can't become the
281
295
  // start-model snapshot. Without this, every subsequent unit would try to
@@ -792,6 +806,9 @@ export async function bootstrapAutoSession(
792
806
 
793
807
  ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto");
794
808
  ctx.ui.setFooter(hideFooter);
809
+ // Hide gsd-health during AUTO — gsd-progress is the single source of truth
810
+ // for last-commit / cost / health signal while auto is running.
811
+ ctx.ui.setWidget("gsd-health", undefined);
795
812
  const modeLabel = s.stepMode ? "Step-mode" : "Auto-mode";
796
813
  const pendingCount = (state.registry ?? []).filter(
797
814
  (m) => m.status !== "complete" && m.status !== "parked",
@@ -811,12 +828,19 @@ export async function bootstrapAutoSession(
811
828
  ? `${s.autoModeStartModel.provider}/${s.autoModeStartModel.id}`
812
829
  : ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "default";
813
830
 
814
- // Flat-rate providers (e.g. GitHub Copilot, claude-code) suppress routing
815
- // at dispatch time (#3453) reflect that in the banner.
816
- const { isFlatRateProvider } = await import("./auto-model-selection.js");
831
+ // Flat-rate providers (e.g. GitHub Copilot, claude-code, user-declared
832
+ // subscription proxies, externalCli CLIs) suppress routing at dispatch
833
+ // time (#3453) reflect that in the banner. Thread the same
834
+ // FlatRateContext used by selectAndApplyModel so user-declared
835
+ // flat-rate providers and externalCli auto-detection are respected.
836
+ const { isFlatRateProvider, buildFlatRateContext } = await import("./auto-model-selection.js");
837
+ const bannerPrefs = loadEffectiveGSDPreferences()?.preferences;
817
838
  const effectiveProvider = s.autoModeStartModel?.provider ?? ctx.model?.provider;
818
839
  const effectivelyEnabled = routingConfig.enabled
819
- && !(effectiveProvider && isFlatRateProvider(effectiveProvider));
840
+ && !(effectiveProvider && isFlatRateProvider(
841
+ effectiveProvider,
842
+ buildFlatRateContext(effectiveProvider, ctx, bannerPrefs),
843
+ ));
820
844
 
821
845
  // The actual ceiling may come from tier_models.heavy, not the start model.
822
846
  const effectiveCeiling = (routingConfig.enabled && routingConfig.tier_models?.heavy)
@@ -230,6 +230,23 @@ export async function recoverTimedOutUnit(
230
230
  return "recovered";
231
231
  }
232
232
 
233
+ // #4175: For complete-milestone, never write a blocker placeholder — a stub
234
+ // SUMMARY has no recovery value (milestone is terminal), it does not update
235
+ // DB status, and downstream merge paths can treat the stub as a legitimate
236
+ // completion signal. Pause instead so the worktree branch is preserved.
237
+ if (unitType === "complete-milestone") {
238
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, {
239
+ phase: "paused",
240
+ recoveryAttempts: recoveryAttempts + 1,
241
+ lastRecoveryReason: reason,
242
+ });
243
+ ctx.ui.notify(
244
+ `Milestone ${unitId} ${reason}-recovery exhausted ${maxRecoveryAttempts} attempt(s) — worktree branch preserved. Re-run /gsd auto once blockers are resolved.`,
245
+ "error",
246
+ );
247
+ return "paused";
248
+ }
249
+
233
250
  // Retries exhausted — write a blocker placeholder and advance the pipeline
234
251
  // instead of silently stalling.
235
252
  const placeholder = writeBlockerPlaceholder(
@@ -12,10 +12,15 @@
12
12
 
13
13
  import type { ExtensionContext, ExtensionAPI } from "@gsd/pi-coding-agent";
14
14
  import { mkdirSync, writeFileSync } from "node:fs";
15
- import { resolveSliceFile, resolveSlicePath } from "./paths.js";
15
+ import { resolveSliceFile, resolveSlicePath, resolveMilestoneFile } from "./paths.js";
16
16
  import { parseUnitId } from "./unit-id.js";
17
- import { isDbAvailable, getTask, getSliceTasks, type TaskRow } from "./gsd-db.js";
17
+ import { isDbAvailable, getTask, getSliceTasks, getMilestoneSlices, type TaskRow } from "./gsd-db.js";
18
18
  import { loadEffectiveGSDPreferences } from "./preferences.js";
19
+ import { extractVerdict } from "./verdict-parser.js";
20
+ import { isClosedStatus } from "./status-guards.js";
21
+ import { loadFile } from "./files.js";
22
+ import { parseRoadmap } from "./parsers-legacy.js";
23
+ import { isMilestoneComplete } from "./state.js";
19
24
  import {
20
25
  runVerificationGate,
21
26
  formatFailureContext,
@@ -43,6 +48,88 @@ function isInfraVerificationFailure(stderr: string): boolean {
43
48
  );
44
49
  }
45
50
 
51
+ /**
52
+ * Post-unit guard for `validate-milestone` units (#4094).
53
+ *
54
+ * When validate-milestone writes verdict=needs-remediation, the agent is
55
+ * expected to also call gsd_reassess_roadmap in the same turn to add
56
+ * remediation slices. If they don't, the state machine re-derives
57
+ * `phase: validating-milestone` indefinitely (all slices still complete +
58
+ * verdict still needs-remediation), wasting ~3 dispatches before the stuck
59
+ * detector fires.
60
+ *
61
+ * This guard fires immediately on the first occurrence: if VALIDATION.md
62
+ * verdict is needs-remediation and no incomplete slices exist for the
63
+ * milestone, pause the auto-loop with a clear blocker.
64
+ */
65
+ async function runValidateMilestonePostCheck(
66
+ vctx: VerificationContext,
67
+ pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise<void>,
68
+ ): Promise<VerificationResult> {
69
+ const { s, ctx, pi } = vctx;
70
+ if (!s.currentUnit) return "continue";
71
+
72
+ const { milestone: mid } = parseUnitId(s.currentUnit.id);
73
+ if (!mid) return "continue";
74
+
75
+ const validationFile = resolveMilestoneFile(s.basePath, mid, "VALIDATION");
76
+ if (!validationFile) return "continue";
77
+
78
+ const validationContent = await loadFile(validationFile);
79
+ if (!validationContent) return "continue";
80
+
81
+ const verdict = extractVerdict(validationContent);
82
+ if (verdict !== "needs-remediation") return "continue";
83
+
84
+ const incompleteSliceCount = await countIncompleteSlices(s.basePath, mid);
85
+
86
+ // If any non-closed slices exist, the agent successfully queued remediation
87
+ // work — proceed normally. The state machine will execute those slices and
88
+ // re-validate per the #3596/#3670 fix.
89
+ if (incompleteSliceCount > 0) return "continue";
90
+
91
+ ctx.ui.notify(
92
+ `Milestone ${mid} validation returned verdict=needs-remediation but no remediation slices were added. Pausing for human review.`,
93
+ "error",
94
+ );
95
+ process.stderr.write(
96
+ `validate-milestone: pausing — verdict=needs-remediation with no incomplete slices for ${mid}. ` +
97
+ `The agent must call gsd_reassess_roadmap to add remediation slices before re-validation.\n`,
98
+ );
99
+ await pauseAuto(ctx, pi);
100
+ return "pause";
101
+ }
102
+
103
+ /**
104
+ * Count slices for a milestone that are not in a closed status.
105
+ * DB-backed projects are authoritative (#4094 peer review); falls back to
106
+ * roadmap parsing only when the DB is unavailable.
107
+ */
108
+ async function countIncompleteSlices(basePath: string, milestoneId: string): Promise<number> {
109
+ if (isDbAvailable()) {
110
+ const slices = getMilestoneSlices(milestoneId);
111
+ if (slices.length === 0) {
112
+ // No DB rows — treat as "unknown", do not pause.
113
+ return 1;
114
+ }
115
+ return slices.filter((slice) => !isClosedStatus(slice.status)).length;
116
+ }
117
+
118
+ // Filesystem fallback: parse the roadmap markdown.
119
+ try {
120
+ const roadmapFile = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
121
+ if (!roadmapFile) return 1;
122
+ const roadmapContent = await loadFile(roadmapFile);
123
+ if (!roadmapContent) return 1;
124
+ const roadmap = parseRoadmap(roadmapContent);
125
+ if (roadmap.slices.length === 0) return 1;
126
+ return isMilestoneComplete(roadmap) ? 0 : 1;
127
+ } catch {
128
+ // Parsing failures should not cause false-positive pauses.
129
+ return 1;
130
+ }
131
+ }
132
+
46
133
  /**
47
134
  * Run the verification gate for the current execute-task unit.
48
135
  * Returns:
@@ -56,7 +143,15 @@ export async function runPostUnitVerification(
56
143
  ): Promise<VerificationResult> {
57
144
  const { s, ctx, pi } = vctx;
58
145
 
59
- if (!s.currentUnit || s.currentUnit.type !== "execute-task") {
146
+ if (!s.currentUnit) {
147
+ return "continue";
148
+ }
149
+
150
+ if (s.currentUnit.type === "validate-milestone") {
151
+ return await runValidateMilestonePostCheck(vctx, pauseAuto);
152
+ }
153
+
154
+ if (s.currentUnit.type !== "execute-task") {
60
155
  return "continue";
61
156
  }
62
157
 
@@ -52,6 +52,7 @@ import {
52
52
  readCrashLock,
53
53
  isLockProcessAlive,
54
54
  formatCrashInfo,
55
+ emitCrashRecoveredUnitEnd,
55
56
  } from "./crash-recovery.js";
56
57
  import {
57
58
  acquireSessionLock,
@@ -186,7 +187,7 @@ import {
186
187
  deregisterSigtermHandler as _deregisterSigtermHandler,
187
188
  detectWorkingTreeActivity,
188
189
  } from "./auto-supervisor.js";
189
- import { isDbAvailable } from "./gsd-db.js";
190
+ import { isDbAvailable, getMilestone } from "./gsd-db.js";
190
191
  import { countPendingCaptures } from "./captures.js";
191
192
  import { clearCmuxSidebar, logCmuxEvent, syncCmuxSidebar } from "../cmux/index.js";
192
193
 
@@ -198,6 +199,7 @@ import {
198
199
  postUnitPostVerification,
199
200
  } from "./auto-post-unit.js";
200
201
  import { bootstrapAutoSession, openProjectDbIfPresent, type BootstrapDeps } from "./auto-start.js";
202
+ import { initHealthWidget } from "./health-widget.js";
201
203
  import { autoLoop, resolveAgentEnd, resolveAgentEndCancelled, _resetPendingResolve, isSessionSwitchInFlight, type LoopDeps, type ErrorContext } from "./auto-loop.js";
202
204
  // Slice-level parallelism (#2340)
203
205
  import { getEligibleSlices } from "./slice-parallel-eligibility.js";
@@ -649,6 +651,7 @@ function handleLostSessionLock(
649
651
  ctx?.ui.setStatus("gsd-auto", undefined);
650
652
  ctx?.ui.setWidget("gsd-progress", undefined);
651
653
  ctx?.ui.setFooter(undefined);
654
+ if (ctx) initHealthWidget(ctx);
652
655
  }
653
656
 
654
657
  /**
@@ -683,6 +686,7 @@ function cleanupAfterLoopExit(ctx: ExtensionContext): void {
683
686
  ctx.ui.setStatus("gsd-auto", undefined);
684
687
  ctx.ui.setWidget("gsd-progress", undefined);
685
688
  ctx.ui.setFooter(undefined);
689
+ initHealthWidget(ctx);
686
690
  }
687
691
 
688
692
  // Restore CWD out of worktree back to original project root
@@ -757,24 +761,36 @@ export async function stopAuto(
757
761
  : { notify: () => {} };
758
762
  const resolver = buildResolver();
759
763
 
760
- // Check if the milestone is complete SUMMARY file is the authoritative signal.
764
+ // Check if the milestone is complete. DB status is the authoritative
765
+ // signal — only a successful gsd_complete_milestone call flips it to
766
+ // "complete" (tools/complete-milestone.ts). SUMMARY file presence is
767
+ // NOT sufficient: a blocker placeholder stub or a partial write can
768
+ // leave a file behind without the milestone actually being done,
769
+ // which previously caused stopAuto to merge a failed milestone and
770
+ // emit a misleading metadata-only merge warning (#4175).
771
+ // DB-unavailable projects fall back to SUMMARY-file presence.
761
772
  let milestoneComplete = false;
762
773
  try {
763
- const summaryPath = resolveMilestoneFile(
764
- s.originalBasePath || s.basePath,
765
- s.currentMilestoneId,
766
- "SUMMARY",
767
- );
768
- if (!summaryPath) {
769
- // Also check in the worktree path (SUMMARY may not be synced yet)
770
- const wtSummaryPath = resolveMilestoneFile(
771
- s.basePath,
774
+ if (isDbAvailable()) {
775
+ const dbRow = getMilestone(s.currentMilestoneId);
776
+ milestoneComplete = dbRow?.status === "complete";
777
+ } else {
778
+ const summaryPath = resolveMilestoneFile(
779
+ s.originalBasePath || s.basePath,
772
780
  s.currentMilestoneId,
773
781
  "SUMMARY",
774
782
  );
775
- milestoneComplete = wtSummaryPath !== null;
776
- } else {
777
- milestoneComplete = true;
783
+ if (!summaryPath) {
784
+ // Also check in the worktree path (SUMMARY may not be synced yet)
785
+ const wtSummaryPath = resolveMilestoneFile(
786
+ s.basePath,
787
+ s.currentMilestoneId,
788
+ "SUMMARY",
789
+ );
790
+ milestoneComplete = wtSummaryPath !== null;
791
+ } else {
792
+ milestoneComplete = true;
793
+ }
778
794
  }
779
795
  } catch (err) {
780
796
  // Non-fatal — fall through to preserveBranch path
@@ -942,6 +958,7 @@ export async function stopAuto(
942
958
  ctx?.ui.setStatus("gsd-auto", undefined);
943
959
  ctx?.ui.setWidget("gsd-progress", undefined);
944
960
  ctx?.ui.setFooter(undefined);
961
+ if (ctx) initHealthWidget(ctx);
945
962
  restoreProjectRootEnv();
946
963
  restoreMilestoneLockEnv();
947
964
 
@@ -1043,6 +1060,7 @@ export async function pauseAuto(
1043
1060
  ctx?.ui.setStatus("gsd-auto", "paused");
1044
1061
  ctx?.ui.setWidget("gsd-progress", undefined);
1045
1062
  ctx?.ui.setFooter(undefined);
1063
+ if (ctx) initHealthWidget(ctx);
1046
1064
  const resumeCmd = s.stepMode ? "/gsd next" : "/gsd auto";
1047
1065
  ctx?.ui.notify(
1048
1066
  `${s.stepMode ? "Step" : "Auto"}-mode paused (Escape). Type to interact, or ${resumeCmd} to resume.`,
@@ -1332,6 +1350,10 @@ export async function startAuto(
1332
1350
  }
1333
1351
 
1334
1352
  if (freshStartAssessment.lock) {
1353
+ // Emit a synthetic unit-end for any unit-start that has no closing event.
1354
+ // This closes the journal gap reported in #3348 where the worker wrote side
1355
+ // effects (SUMMARY.md, DB updates) but died before emitting unit-end.
1356
+ emitCrashRecoveredUnitEnd(base, freshStartAssessment.lock);
1335
1357
  clearLock(base);
1336
1358
  }
1337
1359
 
@@ -0,0 +1,32 @@
1
+ /**
2
+ * crash-log.ts — Write crash diagnostics to ~/.gsd/crash/<timestamp>.log
3
+ *
4
+ * Zero cross-dependencies: only uses Node.js built-ins so it can be imported
5
+ * safely from uncaughtException / unhandledRejection handlers and from tests
6
+ * without pulling in the full extension dependency tree.
7
+ */
8
+
9
+ import { appendFileSync, mkdirSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ /**
14
+ * Write a crash log to ~/.gsd/crash/<timestamp>.log (or $GSD_HOME/crash/).
15
+ * Never throws — must be safe to call from any error handler.
16
+ */
17
+ export function writeCrashLog(err: Error, source: string): void {
18
+ try {
19
+ const crashDir = join(process.env.GSD_HOME ?? join(homedir(), ".gsd"), "crash");
20
+ mkdirSync(crashDir, { recursive: true });
21
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
22
+ const logPath = join(crashDir, `${ts}.log`);
23
+ const lines = [
24
+ `[gsd] ${source}: ${err.message}`,
25
+ `timestamp: ${new Date().toISOString()}`,
26
+ `pid: ${process.pid}`,
27
+ err.stack ?? "(no stack trace available)",
28
+ "",
29
+ ];
30
+ appendFileSync(logPath, lines.join("\n"));
31
+ } catch { /* never throw from crash handler */ }
32
+ }
@@ -11,6 +11,9 @@ import { registerJournalTools } from "./journal-tools.js";
11
11
  import { registerQueryTools } from "./query-tools.js";
12
12
  import { registerHooks } from "./register-hooks.js";
13
13
  import { registerShortcuts } from "./register-shortcuts.js";
14
+ import { writeCrashLog } from "./crash-log.js";
15
+
16
+ export { writeCrashLog } from "./crash-log.js";
14
17
 
15
18
  export function handleRecoverableExtensionProcessError(err: Error): boolean {
16
19
  if ((err as NodeJS.ErrnoException).code === "EPIPE") {
@@ -33,16 +36,25 @@ export function handleRecoverableExtensionProcessError(err: Error): boolean {
33
36
  function installEpipeGuard(): void {
34
37
  if (!process.listeners("uncaughtException").some((listener) => listener.name === "_gsdEpipeGuard")) {
35
38
  const _gsdEpipeGuard = (err: Error): void => {
36
- if (handleRecoverableExtensionProcessError(err)) {
37
- return;
38
- }
39
- // Log unhandled errors instead of re-throwing throwing inside an
40
- // uncaughtException handler is a fatal double-fault in Node.js (#3163).
41
- process.stderr.write(`[gsd] uncaught extension error (non-fatal): ${err.message}\n`);
42
- if (err.stack) process.stderr.write(`${err.stack}\n`);
39
+ if (handleRecoverableExtensionProcessError(err)) return;
40
+ // Write crash log and exit cleanly for unrecoverable errors.
41
+ // Logging and continuing was the original double-fault fix (#3163), but
42
+ // continuing in an indeterminate state is worse than a clean exit (#3348).
43
+ writeCrashLog(err, "uncaughtException");
44
+ process.exit(1);
43
45
  };
44
46
  process.on("uncaughtException", _gsdEpipeGuard);
45
47
  }
48
+
49
+ if (!process.listeners("unhandledRejection").some((listener) => listener.name === "_gsdRejectionGuard")) {
50
+ const _gsdRejectionGuard = (reason: unknown, _promise: Promise<unknown>): void => {
51
+ const err = reason instanceof Error ? reason : new Error(String(reason));
52
+ if (handleRecoverableExtensionProcessError(err)) return;
53
+ writeCrashLog(err, "unhandledRejection");
54
+ process.exit(1);
55
+ };
56
+ process.on("unhandledRejection", _gsdRejectionGuard);
57
+ }
46
58
  }
47
59
 
48
60
  export function registerGsdExtension(pi: ExtensionAPI): void {
@@ -28,6 +28,11 @@ import { loadPrompt } from "./prompt-loader.js";
28
28
  const UPDATE_REGISTRY_URL = "https://registry.npmjs.org/gsd-pi/latest";
29
29
  const UPDATE_FETCH_TIMEOUT_MS = 5000;
30
30
 
31
+ function resolveInstallCommand(pkg: string): string {
32
+ if ('bun' in process.versions) return `bun add -g ${pkg}`;
33
+ return `npm install -g ${pkg}`;
34
+ }
35
+
31
36
  async function fetchLatestVersionForCommand(): Promise<string | null> {
32
37
  const controller = new AbortController();
33
38
  const timeout = setTimeout(() => controller.abort(), UPDATE_FETCH_TIMEOUT_MS);
@@ -431,8 +436,9 @@ export async function handleUpdate(ctx: ExtensionCommandContext): Promise<void>
431
436
 
432
437
  ctx.ui.notify(`Updating: v${current} → v${latest}...`, "info");
433
438
 
439
+ const installCmd = resolveInstallCommand(`${NPM_PACKAGE}@latest`);
434
440
  try {
435
- execSync(`npm install -g ${NPM_PACKAGE}@latest`, {
441
+ execSync(installCmd, {
436
442
  stdio: ["ignore", "pipe", "ignore"],
437
443
  });
438
444
  ctx.ui.notify(
@@ -441,7 +447,7 @@ export async function handleUpdate(ctx: ExtensionCommandContext): Promise<void>
441
447
  );
442
448
  } catch {
443
449
  ctx.ui.notify(
444
- `Update failed. Try manually: npm install -g ${NPM_PACKAGE}@latest`,
450
+ `Update failed. Try manually: ${installCmd}`,
445
451
  "error",
446
452
  );
447
453
  }
@@ -15,6 +15,7 @@ import { join } from "node:path";
15
15
  import { gsdRoot } from "./paths.js";
16
16
  import { atomicWriteSync } from "./atomic-write.js";
17
17
  import { effectiveLockFile } from "./session-lock.js";
18
+ import { emitJournalEvent, queryJournal } from "./journal.js";
18
19
 
19
20
  export interface LockData {
20
21
  pid: number;
@@ -118,3 +119,61 @@ export function formatCrashInfo(lock: LockData): string {
118
119
 
119
120
  return lines.join("\n");
120
121
  }
122
+
123
+ /**
124
+ * Emit a synthetic unit-end event for a unit that crashed without emitting its own.
125
+ *
126
+ * Queries the journal to find the most recent unit-start for the crashed unit.
127
+ * If a matching unit-end already exists (e.g. the hard timeout fired), this is a
128
+ * no-op. Called during crash recovery, before clearing the stale lock.
129
+ *
130
+ * Addresses the gap reported in #3348 where `unit-start` was emitted but no
131
+ * `unit-end` followed — side effects landed but the worker died before closeout.
132
+ */
133
+ export function emitCrashRecoveredUnitEnd(basePath: string, lock: LockData): void {
134
+ // Skip bootstrap / starting pseudo-units — they have no meaningful unit-start event.
135
+ if (!lock.unitType || !lock.unitId || lock.unitType === "starting") return;
136
+
137
+ try {
138
+ const all = queryJournal(basePath);
139
+
140
+ // Find the most recent unit-start for this unitId
141
+ const starts = all.filter(
142
+ (e) => e.eventType === "unit-start" && e.data?.unitId === lock.unitId,
143
+ );
144
+ if (starts.length === 0) return;
145
+
146
+ const lastStart = starts[starts.length - 1];
147
+
148
+ // Check if a unit-end was already emitted (e.g. hard timeout fired after the crash)
149
+ const alreadyClosed = all.some(
150
+ (e) =>
151
+ e.eventType === "unit-end" &&
152
+ e.data?.unitId === lock.unitId &&
153
+ e.causedBy?.flowId === lastStart.flowId &&
154
+ e.causedBy?.seq === lastStart.seq,
155
+ );
156
+ if (alreadyClosed) return;
157
+
158
+ // Find the highest seq in this flow for monotonic ordering
159
+ const maxSeq = all
160
+ .filter((e) => e.flowId === lastStart.flowId)
161
+ .reduce((max, e) => Math.max(max, e.seq), lastStart.seq);
162
+
163
+ emitJournalEvent(basePath, {
164
+ ts: new Date().toISOString(),
165
+ flowId: lastStart.flowId,
166
+ seq: maxSeq + 1,
167
+ eventType: "unit-end",
168
+ data: {
169
+ unitType: lock.unitType,
170
+ unitId: lock.unitId,
171
+ status: "crash-recovered",
172
+ artifactVerified: false,
173
+ },
174
+ causedBy: { flowId: lastStart.flowId, seq: lastStart.seq },
175
+ });
176
+ } catch {
177
+ // Never throw from crash recovery path — journal failure must not block recovery
178
+ }
179
+ }
@@ -157,7 +157,7 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea
157
157
 
158
158
  - `phases`: fine-grained control over which phases run. Usually set by `token_profile`, but can be overridden. Keys:
159
159
  - `skip_research`: boolean — skip milestone-level research. Default: `false`.
160
- - `reassess_after_slice`: boolean — run roadmap reassessment after each completed slice. Default: `false`.
160
+ - `reassess_after_slice`: boolean — run roadmap reassessment after each completed slice. Default: `true`.
161
161
  - `skip_reassess`: boolean — force-disable roadmap reassessment even if `reassess_after_slice` is enabled. Default: `false`.
162
162
  - `skip_slice_research`: boolean — skip per-slice research. Default: `false`.
163
163
 
@@ -1564,6 +1564,23 @@ export interface TaskRow {
1564
1564
  sequence: number;
1565
1565
  }
1566
1566
 
1567
+ function parseTaskArrayColumn(raw: unknown): string[] {
1568
+ if (typeof raw !== "string" || raw.trim() === "") return [];
1569
+
1570
+ try {
1571
+ const parsed = JSON.parse(raw);
1572
+ if (Array.isArray(parsed)) return parsed.map((value) => String(value));
1573
+ if (parsed === null || parsed === undefined || parsed === "") return [];
1574
+ return [String(parsed)];
1575
+ } catch {
1576
+ // Older/corrupt rows may contain comma-separated strings instead of JSON.
1577
+ return raw
1578
+ .split(",")
1579
+ .map((value) => value.trim())
1580
+ .filter(Boolean);
1581
+ }
1582
+ }
1583
+
1567
1584
  function rowToTask(row: Record<string, unknown>): TaskRow {
1568
1585
  const parseTaskArray = (value: unknown): string[] => {
1569
1586
  if (Array.isArray(value)) {
@@ -1603,8 +1620,8 @@ function rowToTask(row: Record<string, unknown>): TaskRow {
1603
1620
  blocker_discovered: (row["blocker_discovered"] as number) === 1,
1604
1621
  deviations: row["deviations"] as string,
1605
1622
  known_issues: row["known_issues"] as string,
1606
- key_files: JSON.parse((row["key_files"] as string) || "[]"),
1607
- key_decisions: JSON.parse((row["key_decisions"] as string) || "[]"),
1623
+ key_files: parseTaskArrayColumn(row["key_files"]),
1624
+ key_decisions: parseTaskArrayColumn(row["key_decisions"]),
1608
1625
  full_summary_md: row["full_summary_md"] as string,
1609
1626
  description: (row["description"] as string) ?? "",
1610
1627
  estimate: (row["estimate"] as string) ?? "",
@@ -2200,6 +2217,39 @@ export function deleteSlice(milestoneId: string, sliceId: string): void {
2200
2217
  });
2201
2218
  }
2202
2219
 
2220
+ export function deleteMilestone(milestoneId: string): void {
2221
+ if (!currentDb) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
2222
+ transaction(() => {
2223
+ currentDb!.prepare(
2224
+ `DELETE FROM verification_evidence WHERE milestone_id = :mid`,
2225
+ ).run({ ":mid": milestoneId });
2226
+ currentDb!.prepare(
2227
+ `DELETE FROM quality_gates WHERE milestone_id = :mid`,
2228
+ ).run({ ":mid": milestoneId });
2229
+ currentDb!.prepare(
2230
+ `DELETE FROM tasks WHERE milestone_id = :mid`,
2231
+ ).run({ ":mid": milestoneId });
2232
+ currentDb!.prepare(
2233
+ `DELETE FROM slice_dependencies WHERE milestone_id = :mid`,
2234
+ ).run({ ":mid": milestoneId });
2235
+ currentDb!.prepare(
2236
+ `DELETE FROM slices WHERE milestone_id = :mid`,
2237
+ ).run({ ":mid": milestoneId });
2238
+ currentDb!.prepare(
2239
+ `DELETE FROM replan_history WHERE milestone_id = :mid`,
2240
+ ).run({ ":mid": milestoneId });
2241
+ currentDb!.prepare(
2242
+ `DELETE FROM assessments WHERE milestone_id = :mid`,
2243
+ ).run({ ":mid": milestoneId });
2244
+ currentDb!.prepare(
2245
+ `DELETE FROM artifacts WHERE milestone_id = :mid`,
2246
+ ).run({ ":mid": milestoneId });
2247
+ currentDb!.prepare(
2248
+ `DELETE FROM milestones WHERE id = :mid`,
2249
+ ).run({ ":mid": milestoneId });
2250
+ });
2251
+ }
2252
+
2203
2253
  export function updateSliceFields(milestoneId: string, sliceId: string, fields: {
2204
2254
  title?: string;
2205
2255
  risk?: string;
@@ -20,7 +20,8 @@ import {
20
20
  } from "./paths.js";
21
21
  import { invalidateAllCaches } from "./cache.js";
22
22
  import { loadQueueOrder, saveQueueOrder } from "./queue-order.js";
23
- import { getMilestone, isDbAvailable, updateMilestoneStatus } from "./gsd-db.js";
23
+ import { deleteMilestone, getMilestone, isDbAvailable, updateMilestoneStatus } from "./gsd-db.js";
24
+ import { removeWorktree } from "./worktree-manager.js";
24
25
  import { logWarning } from "./workflow-logger.js";
25
26
 
26
27
  // ─── Park ──────────────────────────────────────────────────────────────────
@@ -110,6 +111,15 @@ export function discardMilestone(basePath: string, milestoneId: string): boolean
110
111
  const mDir = resolveMilestonePath(basePath, milestoneId);
111
112
  if (!mDir || !existsSync(mDir)) return false;
112
113
 
114
+ try {
115
+ removeWorktree(basePath, milestoneId, {
116
+ branch: `milestone/${milestoneId}`,
117
+ deleteBranch: true,
118
+ });
119
+ } catch (err) {
120
+ logWarning("engine", `discardMilestone worktree cleanup failed for ${milestoneId}: ${(err as Error).message}`);
121
+ }
122
+
113
123
  rmSync(mDir, { recursive: true, force: true });
114
124
 
115
125
  // Prune from queue order if present
@@ -118,6 +128,14 @@ export function discardMilestone(basePath: string, milestoneId: string): boolean
118
128
  saveQueueOrder(basePath, order.filter(id => id !== milestoneId));
119
129
  }
120
130
 
131
+ if (isDbAvailable()) {
132
+ try {
133
+ deleteMilestone(milestoneId);
134
+ } catch (err) {
135
+ logWarning("engine", `discardMilestone DB cleanup failed for ${milestoneId}: ${(err as Error).message}`);
136
+ }
137
+ }
138
+
121
139
  invalidateAllCaches();
122
140
  return true;
123
141
  }
@@ -1,6 +1,6 @@
1
1
  // GSD Extension — Notification Widget
2
2
  // Always-on ambient widget rendered belowEditor showing unread count and
3
- // the most recent notification message. Refreshes every 5 seconds.
3
+ // the most recent notification message. Refreshes every 30 seconds.
4
4
  // Widget key: "gsd-notifications", placement: "belowEditor"
5
5
 
6
6
  import type { ExtensionContext } from "@gsd/pi-coding-agent";
@@ -19,7 +19,7 @@ export function buildNotificationWidgetLines(): string[] {
19
19
 
20
20
  // ─── Widget init ────────────────────────────────────────────────────────
21
21
 
22
- const REFRESH_INTERVAL_MS = 5_000;
22
+ const REFRESH_INTERVAL_MS = 30_000;
23
23
 
24
24
  /**
25
25
  * Initialize the always-on notification widget (belowEditor).