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
@@ -7,6 +7,8 @@
7
7
  */
8
8
 
9
9
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
10
12
  import type { DynamicRoutingConfig } from "./model-router.js";
11
13
  import { defaultRoutingConfig } from "./model-router.js";
12
14
  import type { TokenProfile, InlineLevel } from "./types.js";
@@ -185,6 +187,45 @@ export function resolveDefaultSessionModel(
185
187
  return undefined;
186
188
  }
187
189
 
190
+ /**
191
+ * Returns true if `provider` is defined as a custom provider in the user's
192
+ * `~/.gsd/agent/models.json` (Ollama, vLLM, LM Studio, OpenAI-compatible
193
+ * proxies, etc.).
194
+ *
195
+ * Used by auto-mode bootstrap to decide whether the session model
196
+ * (set via `/gsd model`) should override `PREFERENCES.md`. Custom providers
197
+ * are never reachable from `PREFERENCES.md` (which only knows built-in
198
+ * providers), so when the user has explicitly selected one, it must take
199
+ * priority — otherwise auto-mode tries to start the built-in provider from
200
+ * PREFERENCES.md and fails with "Not logged in · Please run /login" (#4122).
201
+ *
202
+ * Reads models.json directly with a lightweight JSON parse to avoid
203
+ * pulling in the full model-registry at this call site. Falls back to
204
+ * `~/.pi/agent/models.json` for parity with `resolveModelsJsonPath()`.
205
+ * Any read or parse error yields `false` (treat as not-custom) so a
206
+ * malformed models.json never breaks the session bootstrap.
207
+ */
208
+ export function isCustomProvider(provider: string | undefined): boolean {
209
+ if (!provider) return false;
210
+ const candidates = [
211
+ join(homedir(), ".gsd", "agent", "models.json"),
212
+ join(homedir(), ".pi", "agent", "models.json"),
213
+ ];
214
+ for (const path of candidates) {
215
+ if (!existsSync(path)) continue;
216
+ try {
217
+ const raw = readFileSync(path, "utf-8");
218
+ const parsed = JSON.parse(raw) as { providers?: Record<string, unknown> };
219
+ if (parsed?.providers && Object.prototype.hasOwnProperty.call(parsed.providers, provider)) {
220
+ return true;
221
+ }
222
+ } catch {
223
+ // Ignore — malformed models.json must not break bootstrap.
224
+ }
225
+ }
226
+ return false;
227
+ }
228
+
188
229
  /**
189
230
  * Determines the next fallback model to try when the current model fails.
190
231
  * If the current model is not in the configured list, returns the primary model.
@@ -113,6 +113,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
113
113
  "discuss_preparation",
114
114
  "discuss_web_research",
115
115
  "discuss_depth",
116
+ "flat_rate_providers",
116
117
  ]);
117
118
 
118
119
  /** Canonical list of all dispatch unit types. */
@@ -359,6 +360,17 @@ export interface GSDPreferences {
359
360
  * Default: "standard".
360
361
  */
361
362
  discuss_depth?: "quick" | "standard" | "thorough";
363
+ /**
364
+ * Extra provider IDs to treat as flat-rate (no cost benefit from dynamic
365
+ * routing). Dynamic routing is suppressed for any provider listed here,
366
+ * in addition to the built-in list (github-copilot, copilot, claude-code)
367
+ * and any provider auto-detected via `authMode: "externalCli"`.
368
+ *
369
+ * Intended for private subscription-backed proxies, enterprise-gated
370
+ * deployments, and custom CLI wrappers where every request costs the
371
+ * same regardless of model. Case-insensitive.
372
+ */
373
+ flat_rate_providers?: string[];
362
374
  }
363
375
 
364
376
  export interface LoadedGSDPreferences {
@@ -180,6 +180,29 @@ export function validatePreferences(preferences: GSDPreferences): {
180
180
  }
181
181
  }
182
182
 
183
+ // ─── Flat-rate Providers ────────────────────────────────────────────
184
+ // User-declared flat-rate providers for dynamic routing suppression.
185
+ // Built-in providers (github-copilot, copilot, claude-code) and any
186
+ // externalCli provider are already auto-detected; this list layers on
187
+ // top for private subscription proxies and custom CLI wrappers.
188
+ if (preferences.flat_rate_providers !== undefined) {
189
+ if (Array.isArray(preferences.flat_rate_providers)) {
190
+ const allStrings = preferences.flat_rate_providers.every(
191
+ (item: unknown) => typeof item === "string",
192
+ );
193
+ if (allStrings) {
194
+ // Strip empty/whitespace-only entries to avoid false matches.
195
+ validated.flat_rate_providers = preferences.flat_rate_providers
196
+ .map((s: string) => s.trim())
197
+ .filter((s: string) => s.length > 0);
198
+ } else {
199
+ errors.push("flat_rate_providers must be an array of strings");
200
+ }
201
+ } else {
202
+ errors.push("flat_rate_providers must be an array of strings");
203
+ }
204
+ }
205
+
183
206
  // ─── Phase Skip Preferences ─────────────────────────────────────────
184
207
  if (preferences.phases !== undefined) {
185
208
  if (typeof preferences.phases === "object" && preferences.phases !== null) {
@@ -386,6 +386,10 @@ function buildCompletenessSet(basePath: string, milestones: MilestoneRow[]) {
386
386
  const completeMilestoneIds = new Set<string>();
387
387
  const parkedMilestoneIds = new Set<string>();
388
388
 
389
+ // DB-authoritative: a milestone is only "complete" when its DB row says so.
390
+ // SUMMARY-file presence is NOT a completion signal here — an orphan SUMMARY
391
+ // (crashed complete-milestone turn, partial merge, manual edit) must not
392
+ // flip derived state to complete and cascade into a false auto-merge (#4179).
389
393
  for (const m of milestones) {
390
394
  const parkedFile = resolveMilestoneFile(basePath, m.id, "PARKED");
391
395
  if (parkedFile || m.status === 'parked') {
@@ -396,11 +400,6 @@ function buildCompletenessSet(basePath: string, milestones: MilestoneRow[]) {
396
400
  completeMilestoneIds.add(m.id);
397
401
  continue;
398
402
  }
399
- const summaryFile = resolveMilestoneFile(basePath, m.id, "SUMMARY");
400
- if (summaryFile) {
401
- completeMilestoneIds.add(m.id);
402
- continue;
403
- }
404
403
  }
405
404
  return { completeMilestoneIds, parkedMilestoneIds };
406
405
  }
@@ -429,18 +428,22 @@ async function buildRegistryAndFindActive(
429
428
  if (isGhostMilestone(basePath, m.id)) continue;
430
429
  }
431
430
 
432
- const summaryFile = resolveMilestoneFile(basePath, m.id, "SUMMARY");
433
-
434
- if (completeMilestoneIds.has(m.id) || (summaryFile !== null)) {
431
+ // DB-authoritative completeness (#4179): only trust completeMilestoneIds,
432
+ // which is itself derived from DB status. SUMMARY-file presence alone must
433
+ // not imply completion. The summary file may still be consulted below as a
434
+ // title source for legitimately-complete milestones whose DB row has no title.
435
+ if (completeMilestoneIds.has(m.id)) {
435
436
  let title = stripMilestonePrefix(m.title) || m.id;
436
- if (summaryFile && !m.title) {
437
- const summaryContent = await loadFile(summaryFile);
438
- if (summaryContent) {
439
- title = parseSummary(summaryContent).title || m.id;
437
+ if (!m.title) {
438
+ const summaryFile = resolveMilestoneFile(basePath, m.id, "SUMMARY");
439
+ if (summaryFile) {
440
+ const summaryContent = await loadFile(summaryFile);
441
+ if (summaryContent) {
442
+ title = parseSummary(summaryContent).title || m.id;
443
+ }
440
444
  }
441
445
  }
442
446
  registry.push({ id: m.id, title, status: 'complete' });
443
- completeMilestoneIds.add(m.id);
444
447
  continue;
445
448
  }
446
449
 
@@ -481,7 +484,14 @@ async function buildRegistryAndFindActive(
481
484
  const validationContent = validationFile ? await loadFile(validationFile) : null;
482
485
  const validationTerminal = validationContent ? isValidationTerminal(validationContent) : false;
483
486
 
484
- if (!validationTerminal || (validationTerminal && !summaryFile)) {
487
+ // DB-authoritative (#4179): completeness is already decided by
488
+ // completeMilestoneIds above. If we reached this branch, the DB says
489
+ // the milestone is NOT complete — so any SUMMARY file on disk is an
490
+ // orphan (crashed complete-milestone, partial merge, manual edit) and
491
+ // must not short-circuit this path. When validation is terminal, fall
492
+ // through to the default active-push below so `complete-milestone` can
493
+ // re-run idempotently.
494
+ if (!validationTerminal) {
485
495
  activeMilestone = { id: m.id, title };
486
496
  activeMilestoneSlices = slices;
487
497
  activeMilestoneFound = true;
@@ -630,13 +640,39 @@ function resolveSliceDependencies(activeMilestoneSlices: SliceRow[]): { activeSl
630
640
  }
631
641
  }
632
642
 
643
+ // First pass: find a slice with ALL dependencies satisfied (strict)
644
+ let bestFallback: SliceRow | null = null;
645
+ let bestFallbackSatisfied = -1;
646
+
633
647
  for (const s of activeMilestoneSlices) {
634
648
  if (isStatusDone(s.status)) continue;
635
649
  if (isDeferredStatus(s.status)) continue;
636
650
  if (s.depends.every(dep => doneSliceIds.has(dep))) {
637
651
  return { activeSlice: { id: s.id, title: s.title }, activeSliceRow: s };
638
652
  }
653
+ // Track the slice with the most satisfied dependencies as fallback
654
+ const satisfied = s.depends.filter(dep => doneSliceIds.has(dep)).length;
655
+ if (satisfied > bestFallbackSatisfied || (satisfied === bestFallbackSatisfied && !bestFallback)) {
656
+ bestFallback = s;
657
+ bestFallbackSatisfied = satisfied;
658
+ }
639
659
  }
660
+
661
+ // Fallback: if no slice has all deps met but there ARE incomplete non-deferred
662
+ // slices, pick the one with the most deps satisfied. This prevents hard-blocking
663
+ // when dependency metadata is stale (e.g. after reassessment added/removed slices)
664
+ // or when deps reference slices from previous milestones.
665
+ if (bestFallback) {
666
+ const unmet = bestFallback.depends.filter(dep => !doneSliceIds.has(dep));
667
+ logWarning("state",
668
+ `No slice has all deps satisfied — falling back to ${bestFallback.id} ` +
669
+ `(${bestFallbackSatisfied}/${bestFallback.depends.length} deps met, ` +
670
+ `unmet: ${unmet.join(", ")})`,
671
+ { mid: activeMilestoneSlices[0]?.milestone_id, sid: bestFallback.id },
672
+ );
673
+ return { activeSlice: { id: bestFallback.id, title: bestFallback.title }, activeSliceRow: bestFallback };
674
+ }
675
+
640
676
  return { activeSlice: null, activeSliceRow: null };
641
677
  }
642
678
 
@@ -684,7 +720,7 @@ async function reconcileSliceTasks(
684
720
  const summaryPath = resolveTaskFile(basePath, milestoneId, sliceId, t.id, "SUMMARY");
685
721
  if (summaryPath && existsSync(summaryPath)) {
686
722
  try {
687
- updateTaskStatus(milestoneId, sliceId, t.id, "complete");
723
+ updateTaskStatus(milestoneId, sliceId, t.id, "complete", new Date().toISOString());
688
724
  logWarning("reconcile", `task ${milestoneId}/${sliceId}/${t.id} status reconciled from "${t.status}" to "complete" (#2514)`, { mid: milestoneId, sid: sliceId, tid: t.id });
689
725
  reconciled = true;
690
726
  } catch (e) {
@@ -1431,12 +1467,32 @@ export async function _deriveStateImpl(basePath: string): Promise<GSDState> {
1431
1467
  };
1432
1468
  }
1433
1469
  } else {
1470
+ let bestFallbackLegacy: { id: string; title: string; depends: string[] } | null = null;
1471
+ let bestFallbackLegacySatisfied = -1;
1472
+
1434
1473
  for (const s of activeRoadmap.slices) {
1435
1474
  if (s.done) continue;
1436
1475
  if (s.depends.every(dep => doneSliceIds.has(dep))) {
1437
1476
  activeSlice = { id: s.id, title: s.title };
1438
1477
  break;
1439
1478
  }
1479
+ // Track best fallback
1480
+ const satisfied = s.depends.filter(dep => doneSliceIds.has(dep)).length;
1481
+ if (satisfied > bestFallbackLegacySatisfied) {
1482
+ bestFallbackLegacy = s;
1483
+ bestFallbackLegacySatisfied = satisfied;
1484
+ }
1485
+ }
1486
+
1487
+ // Fallback: if no slice has all deps met, pick the one with the most deps satisfied
1488
+ if (!activeSlice && bestFallbackLegacy) {
1489
+ const unmet = bestFallbackLegacy.depends.filter(dep => !doneSliceIds.has(dep));
1490
+ logWarning("state",
1491
+ `No slice has all deps satisfied — falling back to ${bestFallbackLegacy.id} ` +
1492
+ `(${bestFallbackLegacySatisfied}/${bestFallbackLegacy.depends.length} deps met, ` +
1493
+ `unmet: ${unmet.join(", ")})`,
1494
+ );
1495
+ activeSlice = { id: bestFallbackLegacy.id, title: bestFallbackLegacy.title };
1440
1496
  }
1441
1497
  }
1442
1498
 
@@ -688,8 +688,8 @@ test("autoLoop exits on terminal blocked state", async (t) => {
688
688
 
689
689
  assert.ok(deps.callLog.includes("deriveState"), "should have derived state");
690
690
  assert.ok(
691
- deps.callLog.includes("stopAuto"),
692
- "should have called stopAuto for blocked state",
691
+ deps.callLog.includes("pauseAuto"),
692
+ "should have called pauseAuto for blocked state",
693
693
  );
694
694
  assert.ok(
695
695
  !deps.callLog.includes("resolveDispatch"),
@@ -0,0 +1,53 @@
1
+ // GSD-2 — Tests for step-mode completion messages in auto-post-unit
2
+
3
+ import test from "node:test";
4
+ import assert from "node:assert/strict";
5
+
6
+ import { buildStepCompleteMessage, STEP_COMPLETE_FALLBACK_MESSAGE } from "../auto-post-unit.ts";
7
+ import type { GSDState } from "../types.ts";
8
+
9
+ function makeState(overrides: Partial<GSDState>): GSDState {
10
+ return {
11
+ activeMilestone: null,
12
+ activeSlice: null,
13
+ activeTask: null,
14
+ phase: "executing",
15
+ recentDecisions: [],
16
+ blockers: [],
17
+ nextAction: "",
18
+ registry: [],
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ test("buildStepCompleteMessage: milestone complete surfaces review guidance", () => {
24
+ const msg = buildStepCompleteMessage(makeState({ phase: "complete" }));
25
+ assert.match(msg, /milestone finished/);
26
+ assert.match(msg, /\/gsd status/);
27
+ assert.doesNotMatch(msg, /Next:/);
28
+ });
29
+
30
+ test("buildStepCompleteMessage: mid-flight step includes next unit label and /clear hint", () => {
31
+ const state = makeState({
32
+ phase: "executing",
33
+ activeSlice: { id: "S01", title: "Core" },
34
+ activeTask: { id: "T03", title: "Wire notify" },
35
+ });
36
+ const msg = buildStepCompleteMessage(state);
37
+ assert.match(msg, /Next: Execute T03: Wire notify/);
38
+ assert.match(msg, /\/clear/);
39
+ assert.match(msg, /\/gsd to continue/);
40
+ });
41
+
42
+ test("buildStepCompleteMessage: unknown phase falls back to generic continue label", () => {
43
+ // Cast to bypass Phase union so we exercise the default branch of describeNextUnit.
44
+ const state = makeState({ phase: "totally-unknown" as unknown as GSDState["phase"] });
45
+ const msg = buildStepCompleteMessage(state);
46
+ assert.match(msg, /Next: Continue/);
47
+ assert.match(msg, /\/clear/);
48
+ });
49
+
50
+ test("STEP_COMPLETE_FALLBACK_MESSAGE: used when deriveState throws, still points users at /clear + /gsd", () => {
51
+ assert.match(STEP_COMPLETE_FALLBACK_MESSAGE, /\/clear/);
52
+ assert.match(STEP_COMPLETE_FALLBACK_MESSAGE, /\/gsd/);
53
+ });
@@ -33,8 +33,12 @@ test("bootstrapAutoSession checks manual session override before preferences", (
33
33
  assert.ok(manualIdx > -1, "auto-start.ts should read session model override first");
34
34
 
35
35
  // resolveDefaultSessionModel() should still be called for fallback behavior
36
- const preferredIdx = source.indexOf("const preferredModel = resolveDefaultSessionModel(");
37
- assert.ok(preferredIdx > -1, "auto-start.ts should call resolveDefaultSessionModel()");
36
+ const preferredIdx = source.indexOf("const preferredModel = ");
37
+ assert.ok(preferredIdx > -1, "auto-start.ts should build preferredModel");
38
+ assert.ok(
39
+ source.indexOf("resolveDefaultSessionModel(") > -1,
40
+ "auto-start.ts should call resolveDefaultSessionModel()",
41
+ );
38
42
 
39
43
  // Session provider should be passed for bare model ID resolution
40
44
  const withProviderIdx = source.indexOf("resolveDefaultSessionModel(ctx.model?.provider)");
@@ -47,6 +51,51 @@ test("bootstrapAutoSession checks manual session override before preferences", (
47
51
  manualIdx < snapshotIdx && preferredIdx < snapshotIdx,
48
52
  "manual override and preference fallback must be resolved before building startModelSnapshot",
49
53
  );
54
+
55
+ // The validated preferred model must still appear as one of the snapshot
56
+ // sources so PREFERENCES.md continues to win over a stale settings.json
57
+ // default for built-in providers.
58
+ const snapshotBlock = source.slice(snapshotIdx, snapshotIdx + 400);
59
+ assert.ok(
60
+ snapshotBlock.includes("validatedPreferredModel") || snapshotBlock.includes("preferredModel"),
61
+ "startModelSnapshot must still consider preferredModel for built-in providers",
62
+ );
63
+ });
64
+
65
+ test("bootstrapAutoSession prefers session model over PREFERENCES.md when provider is custom (#4122)", () => {
66
+ // Custom providers (Ollama, vLLM, OpenAI-compatible proxies) live in
67
+ // ~/.gsd/agent/models.json, not PREFERENCES.md. When the user picks one
68
+ // via /gsd model, that selection must win over any preferredModel from
69
+ // PREFERENCES.md, otherwise auto-mode tries to start a built-in provider
70
+ // the user is not logged into and pauses with "Not logged in".
71
+ const customCheckIdx = source.indexOf("isCustomProvider(ctx.model?.provider)");
72
+ assert.ok(
73
+ customCheckIdx > -1,
74
+ "auto-start.ts should call isCustomProvider() to detect custom-model sessions",
75
+ );
76
+
77
+ // sessionProviderIsCustom must gate preferredModel resolution so that when the
78
+ // session provider is custom, preferredModel is null and PREFERENCES.md is
79
+ // skipped entirely — the snapshot then falls through to ctx.model.
80
+ const gateIdx = source.indexOf("sessionProviderIsCustom");
81
+ assert.ok(gateIdx > -1, "auto-start.ts should bind sessionProviderIsCustom");
82
+
83
+ const preferredIdx = source.indexOf("const preferredModel = ");
84
+ assert.ok(preferredIdx > -1, "auto-start.ts should build preferredModel");
85
+
86
+ const preferredBlock = source.slice(preferredIdx, preferredIdx + 200);
87
+ assert.ok(
88
+ preferredBlock.includes("sessionProviderIsCustom"),
89
+ "preferredModel must be gated on sessionProviderIsCustom so PREFERENCES.md is skipped for custom providers",
90
+ );
91
+
92
+ const snapshotIdx = source.indexOf("const startModelSnapshot = ");
93
+ assert.ok(snapshotIdx > -1, "auto-start.ts should build startModelSnapshot");
94
+
95
+ assert.ok(
96
+ customCheckIdx < preferredIdx && preferredIdx < snapshotIdx,
97
+ "isCustomProvider() must be evaluated before preferredModel, which must be resolved before startModelSnapshot",
98
+ );
50
99
  });
51
100
 
52
101
  test("bootstrapAutoSession validates preferred model against live registry auth (#unconfigured-models)", () => {
@@ -0,0 +1,142 @@
1
+ /**
2
+ * complete-milestone-false-merge.test.ts — Regression test for #4175.
3
+ *
4
+ * Before the fix, a failed complete-milestone unit could leave a stub
5
+ * SUMMARY blocker placeholder on disk. stopAuto's SUMMARY-presence check
6
+ * then treated the milestone as complete and merged the worktree branch
7
+ * into main — emitting a misleading metadata-only merge warning for a
8
+ * milestone that was never legitimately finished.
9
+ *
10
+ * The fix has three cooperating parts:
11
+ * 1. stopAuto uses DB status (authoritative) instead of SUMMARY presence
12
+ * when the project DB is available.
13
+ * 2. postUnitPreVerification pauses auto-mode for complete-milestone
14
+ * after retries are exhausted instead of writing a blocker placeholder.
15
+ * 3. recoverTimedOutUnit pauses for complete-milestone instead of
16
+ * writing a blocker placeholder.
17
+ *
18
+ * This test guards all three via source inspection so a future refactor
19
+ * cannot silently reintroduce the false-merge path.
20
+ */
21
+
22
+ import test from "node:test";
23
+ import assert from "node:assert/strict";
24
+ import { readFileSync } from "node:fs";
25
+ import { join } from "node:path";
26
+
27
+ const gsdDir = join(import.meta.dirname, "..");
28
+ const autoSrc = readFileSync(join(gsdDir, "auto.ts"), "utf-8");
29
+ const postUnitSrc = readFileSync(join(gsdDir, "auto-post-unit.ts"), "utf-8");
30
+ const timeoutSrc = readFileSync(join(gsdDir, "auto-timeout-recovery.ts"), "utf-8");
31
+
32
+ test("#4175: stopAuto uses DB status as the authoritative milestone-complete signal", () => {
33
+ const step4Idx = autoSrc.indexOf("Step 4: Auto-worktree exit");
34
+ assert.ok(step4Idx !== -1, "Step 4 comment exists in stopAuto");
35
+ const step5Idx = autoSrc.indexOf("Step 5:", step4Idx);
36
+ const step4Block = autoSrc.slice(step4Idx, step5Idx);
37
+
38
+ assert.ok(
39
+ step4Block.includes("isDbAvailable()"),
40
+ "Step 4 should branch on isDbAvailable() so DB is consulted when present",
41
+ );
42
+ assert.ok(
43
+ step4Block.includes("getMilestone(s.currentMilestoneId)"),
44
+ "Step 4 should read authoritative milestone status via getMilestone()",
45
+ );
46
+ assert.ok(
47
+ /status\s*===\s*"complete"/.test(step4Block),
48
+ 'Step 4 should compare the DB row status to "complete"',
49
+ );
50
+ });
51
+
52
+ test("#4175: stopAuto imports getMilestone from gsd-db", () => {
53
+ assert.ok(
54
+ /import\s*\{[^}]*\bgetMilestone\b[^}]*\}\s*from\s*"\.\/gsd-db\.js"/.test(autoSrc),
55
+ "auto.ts should import getMilestone from ./gsd-db.js",
56
+ );
57
+ });
58
+
59
+ test("#4175: stopAuto still falls back to SUMMARY presence when DB is unavailable", () => {
60
+ const step4Idx = autoSrc.indexOf("Step 4: Auto-worktree exit");
61
+ const step5Idx = autoSrc.indexOf("Step 5:", step4Idx);
62
+ const step4Block = autoSrc.slice(step4Idx, step5Idx);
63
+
64
+ assert.ok(
65
+ step4Block.includes("resolveMilestoneFile"),
66
+ "Step 4 should keep SUMMARY-file resolution for DB-unavailable projects",
67
+ );
68
+ assert.ok(
69
+ step4Block.includes("preserveBranch"),
70
+ "Step 4 should still preserve branch for incomplete milestones (fallback path)",
71
+ );
72
+ });
73
+
74
+ test("#4175: postUnitPreVerification pauses complete-milestone after retries exhausted", () => {
75
+ // The pause branch must live inside the retries-exhausted block, above the
76
+ // writeBlockerPlaceholder call — otherwise the stub SUMMARY is still written.
77
+ const retriesExhaustedIdx = postUnitSrc.indexOf(
78
+ "if (attempt > MAX_VERIFICATION_RETRIES)",
79
+ );
80
+ assert.ok(
81
+ retriesExhaustedIdx !== -1,
82
+ "retries-exhausted guard exists in postUnitPreVerification",
83
+ );
84
+
85
+ const blockerCallIdx = postUnitSrc.indexOf("writeBlockerPlaceholder", retriesExhaustedIdx);
86
+ assert.ok(
87
+ blockerCallIdx !== -1,
88
+ "blocker placeholder call still exists for non-milestone units",
89
+ );
90
+
91
+ const exhaustedBlock = postUnitSrc.slice(retriesExhaustedIdx, blockerCallIdx);
92
+
93
+ assert.ok(
94
+ /s\.currentUnit\.type\s*===\s*"complete-milestone"/.test(exhaustedBlock),
95
+ "retries-exhausted block should specifically handle complete-milestone",
96
+ );
97
+ assert.ok(
98
+ /pauseAuto\s*\(\s*ctx\s*,\s*pi\s*\)/.test(exhaustedBlock),
99
+ "complete-milestone path should call pauseAuto instead of falling through",
100
+ );
101
+ // The pause branch must return so execution never reaches writeBlockerPlaceholder.
102
+ assert.ok(
103
+ /return\s+"dispatched"\s*;/.test(exhaustedBlock),
104
+ "complete-milestone pause branch should return before the placeholder call",
105
+ );
106
+ });
107
+
108
+ test("#4175: recoverTimedOutUnit pauses complete-milestone instead of writing a blocker placeholder", () => {
109
+ // The complete-milestone pause branch must sit immediately above the
110
+ // "retries exhausted" writeBlockerPlaceholder call so a failed
111
+ // complete-milestone never produces a stub SUMMARY. Anchor on the
112
+ // comment that precedes that specific placeholder call rather than the
113
+ // function's earlier writeBlockerPlaceholder use sites or its import.
114
+ // Use lastIndexOf so we find the final retries-exhausted block in
115
+ // recoverTimedOutUnit, not an earlier helper with the same comment.
116
+ const exhaustedAnchor = "Retries exhausted — write a blocker placeholder";
117
+ const exhaustedIdx = timeoutSrc.lastIndexOf(exhaustedAnchor);
118
+ assert.ok(
119
+ exhaustedIdx !== -1,
120
+ "retries-exhausted blocker-placeholder path still exists for non-milestone units",
121
+ );
122
+
123
+ const guardIdx = timeoutSrc.lastIndexOf(
124
+ 'unitType === "complete-milestone"',
125
+ exhaustedIdx,
126
+ );
127
+ assert.ok(
128
+ guardIdx !== -1,
129
+ "complete-milestone guard should appear above the retries-exhausted placeholder call",
130
+ );
131
+
132
+ const guardBlock = timeoutSrc.slice(guardIdx, exhaustedIdx);
133
+ assert.ok(
134
+ /return\s+"paused"\s*;/.test(guardBlock),
135
+ "complete-milestone guard should return 'paused' before the placeholder call",
136
+ );
137
+ // The guard itself must not call writeBlockerPlaceholder.
138
+ assert.ok(
139
+ !guardBlock.includes("writeBlockerPlaceholder"),
140
+ "complete-milestone guard must not write a blocker placeholder",
141
+ );
142
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Regression test for #4129: tasks.completed_at stays NULL when status is
3
+ * reconciled to 'complete' via the file-existence path in state.ts.
4
+ *
5
+ * Root cause: reconcileSliceTasks called
6
+ * updateTaskStatus(milestoneId, sliceId, t.id, "complete")
7
+ * without a completedAt timestamp, so the column stays NULL.
8
+ *
9
+ * Fix: pass new Date().toISOString() as the 5th argument.
10
+ */
11
+
12
+ import { describe, test } from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import { readFileSync } from "node:fs";
15
+ import { join, dirname } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url));
19
+ const stateSource = readFileSync(join(__dirname, "..", "state.ts"), "utf-8");
20
+
21
+ describe("completed-at reconcile (#4129)", () => {
22
+ test("reconcileSliceTasks passes a completedAt timestamp when setting status to complete", () => {
23
+ // Before the fix, state.ts had:
24
+ // updateTaskStatus(milestoneId, sliceId, t.id, "complete")
25
+ // which leaves completed_at NULL in the DB.
26
+ // After the fix, a timestamp must be passed as the 5th argument.
27
+ assert.doesNotMatch(
28
+ stateSource,
29
+ /updateTaskStatus\(\s*milestoneId\s*,\s*sliceId\s*,\s*t\.id\s*,\s*["']complete["']\s*\)/,
30
+ "updateTaskStatus must not be called without a completedAt timestamp when reconciling tasks to 'complete' (#4129)",
31
+ );
32
+ });
33
+
34
+ test("reconcileSliceTasks passes new Date().toISOString() as the completedAt argument", () => {
35
+ // Positive assertion: the fixed call must include a timestamp.
36
+ assert.match(
37
+ stateSource,
38
+ /updateTaskStatus\(\s*milestoneId\s*,\s*sliceId\s*,\s*t\.id\s*,\s*["']complete["']\s*,\s*new Date\(\)\.toISOString\(\)\s*\)/,
39
+ "reconcileSliceTasks must pass new Date().toISOString() as completedAt when setting task status to 'complete' (#4129)",
40
+ );
41
+ });
42
+ });