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
@@ -19,7 +19,7 @@ import { gsdRoot, resolveMilestoneFile, resolveMilestonePath, resolveDir, milest
19
19
  import { invalidateAllCaches } from "./cache.js";
20
20
  import { clearActivityLogState } from "./activity-log.js";
21
21
  import { synthesizeCrashRecovery, getDeepDiagnostic, readActiveMilestoneId, } from "./session-forensics.js";
22
- import { writeLock, clearLock, readCrashLock, isLockProcessAlive, formatCrashInfo, } from "./crash-recovery.js";
22
+ import { writeLock, clearLock, readCrashLock, isLockProcessAlive, formatCrashInfo, emitCrashRecoveredUnitEnd, } from "./crash-recovery.js";
23
23
  import { acquireSessionLock, getSessionLockStatus, releaseSessionLock, updateSessionLock, } from "./session-lock.js";
24
24
  import { resolveAutoSupervisorConfig, loadEffectiveGSDPreferences, getIsolationMode, } from "./preferences.js";
25
25
  import { sendDesktopNotification } from "./notifications.js";
@@ -55,7 +55,7 @@ import { initRegistry, convertDispatchRules } from "./rule-registry.js";
55
55
  import { emitJournalEvent as _emitJournalEvent } from "./journal.js";
56
56
  import { updateProgressWidget as _updateProgressWidget, updateSliceProgressCache, clearSliceProgressCache, hideFooter, } from "./auto-dashboard.js";
57
57
  import { registerSigtermHandler as _registerSigtermHandler, deregisterSigtermHandler as _deregisterSigtermHandler, } from "./auto-supervisor.js";
58
- import { isDbAvailable } from "./gsd-db.js";
58
+ import { isDbAvailable, getMilestone } from "./gsd-db.js";
59
59
  import { countPendingCaptures } from "./captures.js";
60
60
  import { clearCmuxSidebar, logCmuxEvent, syncCmuxSidebar } from "../cmux/index.js";
61
61
  // ── Extracted modules ──────────────────────────────────────────────────────
@@ -63,6 +63,7 @@ import { startUnitSupervision } from "./auto-timers.js";
63
63
  import { runPostUnitVerification } from "./auto-verification.js";
64
64
  import { postUnitPreVerification, postUnitPostVerification, } from "./auto-post-unit.js";
65
65
  import { bootstrapAutoSession, openProjectDbIfPresent } from "./auto-start.js";
66
+ import { initHealthWidget } from "./health-widget.js";
66
67
  import { autoLoop, resolveAgentEnd, resolveAgentEndCancelled, _resetPendingResolve, isSessionSwitchInFlight } from "./auto-loop.js";
67
68
  import { WorktreeResolver, } from "./worktree-resolver.js";
68
69
  import { reorderForCaching } from "./prompt-ordering.js";
@@ -397,6 +398,8 @@ function handleLostSessionLock(ctx, lockStatus) {
397
398
  ctx?.ui.setStatus("gsd-auto", undefined);
398
399
  ctx?.ui.setWidget("gsd-progress", undefined);
399
400
  ctx?.ui.setFooter(undefined);
401
+ if (ctx)
402
+ initHealthWidget(ctx);
400
403
  }
401
404
  /**
402
405
  * Lightweight cleanup after autoLoop exits via step-wizard break.
@@ -431,6 +434,7 @@ function cleanupAfterLoopExit(ctx) {
431
434
  ctx.ui.setStatus("gsd-auto", undefined);
432
435
  ctx.ui.setWidget("gsd-progress", undefined);
433
436
  ctx.ui.setFooter(undefined);
437
+ initHealthWidget(ctx);
434
438
  }
435
439
  // Restore CWD out of worktree back to original project root
436
440
  if (s.originalBasePath) {
@@ -501,17 +505,30 @@ export async function stopAuto(ctx, pi, reason) {
501
505
  ? { notify: ctx.ui.notify.bind(ctx.ui) }
502
506
  : { notify: () => { } };
503
507
  const resolver = buildResolver();
504
- // Check if the milestone is complete SUMMARY file is the authoritative signal.
508
+ // Check if the milestone is complete. DB status is the authoritative
509
+ // signal — only a successful gsd_complete_milestone call flips it to
510
+ // "complete" (tools/complete-milestone.ts). SUMMARY file presence is
511
+ // NOT sufficient: a blocker placeholder stub or a partial write can
512
+ // leave a file behind without the milestone actually being done,
513
+ // which previously caused stopAuto to merge a failed milestone and
514
+ // emit a misleading metadata-only merge warning (#4175).
515
+ // DB-unavailable projects fall back to SUMMARY-file presence.
505
516
  let milestoneComplete = false;
506
517
  try {
507
- const summaryPath = resolveMilestoneFile(s.originalBasePath || s.basePath, s.currentMilestoneId, "SUMMARY");
508
- if (!summaryPath) {
509
- // Also check in the worktree path (SUMMARY may not be synced yet)
510
- const wtSummaryPath = resolveMilestoneFile(s.basePath, s.currentMilestoneId, "SUMMARY");
511
- milestoneComplete = wtSummaryPath !== null;
518
+ if (isDbAvailable()) {
519
+ const dbRow = getMilestone(s.currentMilestoneId);
520
+ milestoneComplete = dbRow?.status === "complete";
512
521
  }
513
522
  else {
514
- milestoneComplete = true;
523
+ const summaryPath = resolveMilestoneFile(s.originalBasePath || s.basePath, s.currentMilestoneId, "SUMMARY");
524
+ if (!summaryPath) {
525
+ // Also check in the worktree path (SUMMARY may not be synced yet)
526
+ const wtSummaryPath = resolveMilestoneFile(s.basePath, s.currentMilestoneId, "SUMMARY");
527
+ milestoneComplete = wtSummaryPath !== null;
528
+ }
529
+ else {
530
+ milestoneComplete = true;
531
+ }
515
532
  }
516
533
  }
517
534
  catch (err) {
@@ -676,6 +693,8 @@ export async function stopAuto(ctx, pi, reason) {
676
693
  ctx?.ui.setStatus("gsd-auto", undefined);
677
694
  ctx?.ui.setWidget("gsd-progress", undefined);
678
695
  ctx?.ui.setFooter(undefined);
696
+ if (ctx)
697
+ initHealthWidget(ctx);
679
698
  restoreProjectRootEnv();
680
699
  restoreMilestoneLockEnv();
681
700
  // Reset all session state in one call
@@ -762,6 +781,8 @@ export async function pauseAuto(ctx, _pi, _errorContext) {
762
781
  ctx?.ui.setStatus("gsd-auto", "paused");
763
782
  ctx?.ui.setWidget("gsd-progress", undefined);
764
783
  ctx?.ui.setFooter(undefined);
784
+ if (ctx)
785
+ initHealthWidget(ctx);
765
786
  const resumeCmd = s.stepMode ? "/gsd next" : "/gsd auto";
766
787
  ctx?.ui.notify(`${s.stepMode ? "Step" : "Auto"}-mode paused (Escape). Type to interact, or ${resumeCmd} to resume.`, "info");
767
788
  }
@@ -1014,6 +1035,10 @@ export async function startAuto(ctx, pi, base, verboseMode, options) {
1014
1035
  s.stepMode = requestedStepMode;
1015
1036
  }
1016
1037
  if (freshStartAssessment.lock) {
1038
+ // Emit a synthetic unit-end for any unit-start that has no closing event.
1039
+ // This closes the journal gap reported in #3348 where the worker wrote side
1040
+ // effects (SUMMARY.md, DB updates) but died before emitting unit-end.
1041
+ emitCrashRecoveredUnitEnd(base, freshStartAssessment.lock);
1017
1042
  clearLock(base);
1018
1043
  }
1019
1044
  if (!s.paused) {
@@ -0,0 +1,31 @@
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
+ import { appendFileSync, mkdirSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+ /**
12
+ * Write a crash log to ~/.gsd/crash/<timestamp>.log (or $GSD_HOME/crash/).
13
+ * Never throws — must be safe to call from any error handler.
14
+ */
15
+ export function writeCrashLog(err, source) {
16
+ try {
17
+ const crashDir = join(process.env.GSD_HOME ?? join(homedir(), ".gsd"), "crash");
18
+ mkdirSync(crashDir, { recursive: true });
19
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
20
+ const logPath = join(crashDir, `${ts}.log`);
21
+ const lines = [
22
+ `[gsd] ${source}: ${err.message}`,
23
+ `timestamp: ${new Date().toISOString()}`,
24
+ `pid: ${process.pid}`,
25
+ err.stack ?? "(no stack trace available)",
26
+ "",
27
+ ];
28
+ appendFileSync(logPath, lines.join("\n"));
29
+ }
30
+ catch { /* never throw from crash handler */ }
31
+ }
@@ -8,6 +8,8 @@ import { registerJournalTools } from "./journal-tools.js";
8
8
  import { registerQueryTools } from "./query-tools.js";
9
9
  import { registerHooks } from "./register-hooks.js";
10
10
  import { registerShortcuts } from "./register-shortcuts.js";
11
+ import { writeCrashLog } from "./crash-log.js";
12
+ export { writeCrashLog } from "./crash-log.js";
11
13
  export function handleRecoverableExtensionProcessError(err) {
12
14
  if (err.code === "EPIPE") {
13
15
  process.exit(0);
@@ -28,17 +30,26 @@ export function handleRecoverableExtensionProcessError(err) {
28
30
  function installEpipeGuard() {
29
31
  if (!process.listeners("uncaughtException").some((listener) => listener.name === "_gsdEpipeGuard")) {
30
32
  const _gsdEpipeGuard = (err) => {
31
- if (handleRecoverableExtensionProcessError(err)) {
33
+ if (handleRecoverableExtensionProcessError(err))
32
34
  return;
33
- }
34
- // Log unhandled errors instead of re-throwing throwing inside an
35
- // uncaughtException handler is a fatal double-fault in Node.js (#3163).
36
- process.stderr.write(`[gsd] uncaught extension error (non-fatal): ${err.message}\n`);
37
- if (err.stack)
38
- process.stderr.write(`${err.stack}\n`);
35
+ // Write crash log and exit cleanly for unrecoverable errors.
36
+ // Logging and continuing was the original double-fault fix (#3163), but
37
+ // continuing in an indeterminate state is worse than a clean exit (#3348).
38
+ writeCrashLog(err, "uncaughtException");
39
+ process.exit(1);
39
40
  };
40
41
  process.on("uncaughtException", _gsdEpipeGuard);
41
42
  }
43
+ if (!process.listeners("unhandledRejection").some((listener) => listener.name === "_gsdRejectionGuard")) {
44
+ const _gsdRejectionGuard = (reason, _promise) => {
45
+ const err = reason instanceof Error ? reason : new Error(String(reason));
46
+ if (handleRecoverableExtensionProcessError(err))
47
+ return;
48
+ writeCrashLog(err, "unhandledRejection");
49
+ process.exit(1);
50
+ };
51
+ process.on("unhandledRejection", _gsdRejectionGuard);
52
+ }
42
53
  }
43
54
  export function registerGsdExtension(pi) {
44
55
  registerGSDCommand(pi);
@@ -17,6 +17,11 @@ import { projectRoot } from "./commands/context.js";
17
17
  import { loadPrompt } from "./prompt-loader.js";
18
18
  const UPDATE_REGISTRY_URL = "https://registry.npmjs.org/gsd-pi/latest";
19
19
  const UPDATE_FETCH_TIMEOUT_MS = 5000;
20
+ function resolveInstallCommand(pkg) {
21
+ if ('bun' in process.versions)
22
+ return `bun add -g ${pkg}`;
23
+ return `npm install -g ${pkg}`;
24
+ }
20
25
  async function fetchLatestVersionForCommand() {
21
26
  const controller = new AbortController();
22
27
  const timeout = setTimeout(() => controller.abort(), UPDATE_FETCH_TIMEOUT_MS);
@@ -344,13 +349,14 @@ export async function handleUpdate(ctx) {
344
349
  return;
345
350
  }
346
351
  ctx.ui.notify(`Updating: v${current} → v${latest}...`, "info");
352
+ const installCmd = resolveInstallCommand(`${NPM_PACKAGE}@latest`);
347
353
  try {
348
- execSync(`npm install -g ${NPM_PACKAGE}@latest`, {
354
+ execSync(installCmd, {
349
355
  stdio: ["ignore", "pipe", "ignore"],
350
356
  });
351
357
  ctx.ui.notify(`Updated to v${latest}. Restart your GSD session to use the new version.`, "info");
352
358
  }
353
359
  catch {
354
- ctx.ui.notify(`Update failed. Try manually: npm install -g ${NPM_PACKAGE}@latest`, "error");
360
+ ctx.ui.notify(`Update failed. Try manually: ${installCmd}`, "error");
355
361
  }
356
362
  }
@@ -14,6 +14,7 @@ import { join } from "node:path";
14
14
  import { gsdRoot } from "./paths.js";
15
15
  import { atomicWriteSync } from "./atomic-write.js";
16
16
  import { effectiveLockFile } from "./session-lock.js";
17
+ import { emitJournalEvent, queryJournal } from "./journal.js";
17
18
  function lockPath(basePath) {
18
19
  return join(gsdRoot(basePath), effectiveLockFile());
19
20
  }
@@ -110,3 +111,53 @@ export function formatCrashInfo(lock) {
110
111
  }
111
112
  return lines.join("\n");
112
113
  }
114
+ /**
115
+ * Emit a synthetic unit-end event for a unit that crashed without emitting its own.
116
+ *
117
+ * Queries the journal to find the most recent unit-start for the crashed unit.
118
+ * If a matching unit-end already exists (e.g. the hard timeout fired), this is a
119
+ * no-op. Called during crash recovery, before clearing the stale lock.
120
+ *
121
+ * Addresses the gap reported in #3348 where `unit-start` was emitted but no
122
+ * `unit-end` followed — side effects landed but the worker died before closeout.
123
+ */
124
+ export function emitCrashRecoveredUnitEnd(basePath, lock) {
125
+ // Skip bootstrap / starting pseudo-units — they have no meaningful unit-start event.
126
+ if (!lock.unitType || !lock.unitId || lock.unitType === "starting")
127
+ return;
128
+ try {
129
+ const all = queryJournal(basePath);
130
+ // Find the most recent unit-start for this unitId
131
+ const starts = all.filter((e) => e.eventType === "unit-start" && e.data?.unitId === lock.unitId);
132
+ if (starts.length === 0)
133
+ return;
134
+ const lastStart = starts[starts.length - 1];
135
+ // Check if a unit-end was already emitted (e.g. hard timeout fired after the crash)
136
+ const alreadyClosed = all.some((e) => e.eventType === "unit-end" &&
137
+ e.data?.unitId === lock.unitId &&
138
+ e.causedBy?.flowId === lastStart.flowId &&
139
+ e.causedBy?.seq === lastStart.seq);
140
+ if (alreadyClosed)
141
+ return;
142
+ // Find the highest seq in this flow for monotonic ordering
143
+ const maxSeq = all
144
+ .filter((e) => e.flowId === lastStart.flowId)
145
+ .reduce((max, e) => Math.max(max, e.seq), lastStart.seq);
146
+ emitJournalEvent(basePath, {
147
+ ts: new Date().toISOString(),
148
+ flowId: lastStart.flowId,
149
+ seq: maxSeq + 1,
150
+ eventType: "unit-end",
151
+ data: {
152
+ unitType: lock.unitType,
153
+ unitId: lock.unitId,
154
+ status: "crash-recovered",
155
+ artifactVerified: false,
156
+ },
157
+ causedBy: { flowId: lastStart.flowId, seq: lastStart.seq },
158
+ });
159
+ }
160
+ catch {
161
+ // Never throw from crash recovery path — journal failure must not block recovery
162
+ }
163
+ }
@@ -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
 
@@ -1352,6 +1352,25 @@ export function setSliceSummaryMd(milestoneId, sliceId, summaryMd, uatMd) {
1352
1352
  throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
1353
1353
  currentDb.prepare(`UPDATE slices SET full_summary_md = :summary_md, full_uat_md = :uat_md WHERE milestone_id = :mid AND id = :sid`).run({ ":mid": milestoneId, ":sid": sliceId, ":summary_md": summaryMd, ":uat_md": uatMd });
1354
1354
  }
1355
+ function parseTaskArrayColumn(raw) {
1356
+ if (typeof raw !== "string" || raw.trim() === "")
1357
+ return [];
1358
+ try {
1359
+ const parsed = JSON.parse(raw);
1360
+ if (Array.isArray(parsed))
1361
+ return parsed.map((value) => String(value));
1362
+ if (parsed === null || parsed === undefined || parsed === "")
1363
+ return [];
1364
+ return [String(parsed)];
1365
+ }
1366
+ catch {
1367
+ // Older/corrupt rows may contain comma-separated strings instead of JSON.
1368
+ return raw
1369
+ .split(",")
1370
+ .map((value) => value.trim())
1371
+ .filter(Boolean);
1372
+ }
1373
+ }
1355
1374
  function rowToTask(row) {
1356
1375
  const parseTaskArray = (value) => {
1357
1376
  if (Array.isArray(value)) {
@@ -1390,8 +1409,8 @@ function rowToTask(row) {
1390
1409
  blocker_discovered: row["blocker_discovered"] === 1,
1391
1410
  deviations: row["deviations"],
1392
1411
  known_issues: row["known_issues"],
1393
- key_files: JSON.parse(row["key_files"] || "[]"),
1394
- key_decisions: JSON.parse(row["key_decisions"] || "[]"),
1412
+ key_files: parseTaskArrayColumn(row["key_files"]),
1413
+ key_decisions: parseTaskArrayColumn(row["key_decisions"]),
1395
1414
  full_summary_md: row["full_summary_md"],
1396
1415
  description: row["description"] ?? "",
1397
1416
  estimate: row["estimate"] ?? "",
@@ -1855,6 +1874,21 @@ export function deleteSlice(milestoneId, sliceId) {
1855
1874
  currentDb.prepare(`DELETE FROM slices WHERE milestone_id = :mid AND id = :sid`).run({ ":mid": milestoneId, ":sid": sliceId });
1856
1875
  });
1857
1876
  }
1877
+ export function deleteMilestone(milestoneId) {
1878
+ if (!currentDb)
1879
+ throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
1880
+ transaction(() => {
1881
+ currentDb.prepare(`DELETE FROM verification_evidence WHERE milestone_id = :mid`).run({ ":mid": milestoneId });
1882
+ currentDb.prepare(`DELETE FROM quality_gates WHERE milestone_id = :mid`).run({ ":mid": milestoneId });
1883
+ currentDb.prepare(`DELETE FROM tasks WHERE milestone_id = :mid`).run({ ":mid": milestoneId });
1884
+ currentDb.prepare(`DELETE FROM slice_dependencies WHERE milestone_id = :mid`).run({ ":mid": milestoneId });
1885
+ currentDb.prepare(`DELETE FROM slices WHERE milestone_id = :mid`).run({ ":mid": milestoneId });
1886
+ currentDb.prepare(`DELETE FROM replan_history WHERE milestone_id = :mid`).run({ ":mid": milestoneId });
1887
+ currentDb.prepare(`DELETE FROM assessments WHERE milestone_id = :mid`).run({ ":mid": milestoneId });
1888
+ currentDb.prepare(`DELETE FROM artifacts WHERE milestone_id = :mid`).run({ ":mid": milestoneId });
1889
+ currentDb.prepare(`DELETE FROM milestones WHERE id = :mid`).run({ ":mid": milestoneId });
1890
+ });
1891
+ }
1858
1892
  export function updateSliceFields(milestoneId, sliceId, fields) {
1859
1893
  if (!currentDb)
1860
1894
  throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
@@ -15,7 +15,8 @@ import { join } from "node:path";
15
15
  import { resolveMilestonePath, resolveMilestoneFile, buildMilestoneFileName, } from "./paths.js";
16
16
  import { invalidateAllCaches } from "./cache.js";
17
17
  import { loadQueueOrder, saveQueueOrder } from "./queue-order.js";
18
- import { getMilestone, isDbAvailable, updateMilestoneStatus } from "./gsd-db.js";
18
+ import { deleteMilestone, getMilestone, isDbAvailable, updateMilestoneStatus } from "./gsd-db.js";
19
+ import { removeWorktree } from "./worktree-manager.js";
19
20
  import { logWarning } from "./workflow-logger.js";
20
21
  // ─── Park ──────────────────────────────────────────────────────────────────
21
22
  /**
@@ -99,12 +100,29 @@ export function discardMilestone(basePath, milestoneId) {
99
100
  const mDir = resolveMilestonePath(basePath, milestoneId);
100
101
  if (!mDir || !existsSync(mDir))
101
102
  return false;
103
+ try {
104
+ removeWorktree(basePath, milestoneId, {
105
+ branch: `milestone/${milestoneId}`,
106
+ deleteBranch: true,
107
+ });
108
+ }
109
+ catch (err) {
110
+ logWarning("engine", `discardMilestone worktree cleanup failed for ${milestoneId}: ${err.message}`);
111
+ }
102
112
  rmSync(mDir, { recursive: true, force: true });
103
113
  // Prune from queue order if present
104
114
  const order = loadQueueOrder(basePath);
105
115
  if (order && order.includes(milestoneId)) {
106
116
  saveQueueOrder(basePath, order.filter(id => id !== milestoneId));
107
117
  }
118
+ if (isDbAvailable()) {
119
+ try {
120
+ deleteMilestone(milestoneId);
121
+ }
122
+ catch (err) {
123
+ logWarning("engine", `discardMilestone DB cleanup failed for ${milestoneId}: ${err.message}`);
124
+ }
125
+ }
108
126
  invalidateAllCaches();
109
127
  return true;
110
128
  }
@@ -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
  import { getUnreadCount, onNotificationStoreChange } from "./notification-store.js";
6
6
  import { formattedShortcutPair } from "./shortcut-defs.js";
@@ -12,7 +12,7 @@ export function buildNotificationWidgetLines() {
12
12
  return [` 🔔 Notifications: ${unread} unread (${formattedShortcutPair("notifications")})`];
13
13
  }
14
14
  // ─── Widget init ────────────────────────────────────────────────────────
15
- const REFRESH_INTERVAL_MS = 5_000;
15
+ const REFRESH_INTERVAL_MS = 30_000;
16
16
  /**
17
17
  * Initialize the always-on notification widget (belowEditor).
18
18
  * Call once from session_start after the notification store is initialized.
@@ -6,6 +6,8 @@
6
6
  * and dynamic routing configuration.
7
7
  */
8
8
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
9
11
  import { defaultRoutingConfig } from "./model-router.js";
10
12
  import { loadEffectiveGSDPreferences, getGlobalGSDPreferencesPath } from "./preferences.js";
11
13
  /**
@@ -161,6 +163,47 @@ export function resolveDefaultSessionModel(sessionProvider) {
161
163
  }
162
164
  return undefined;
163
165
  }
166
+ /**
167
+ * Returns true if `provider` is defined as a custom provider in the user's
168
+ * `~/.gsd/agent/models.json` (Ollama, vLLM, LM Studio, OpenAI-compatible
169
+ * proxies, etc.).
170
+ *
171
+ * Used by auto-mode bootstrap to decide whether the session model
172
+ * (set via `/gsd model`) should override `PREFERENCES.md`. Custom providers
173
+ * are never reachable from `PREFERENCES.md` (which only knows built-in
174
+ * providers), so when the user has explicitly selected one, it must take
175
+ * priority — otherwise auto-mode tries to start the built-in provider from
176
+ * PREFERENCES.md and fails with "Not logged in · Please run /login" (#4122).
177
+ *
178
+ * Reads models.json directly with a lightweight JSON parse to avoid
179
+ * pulling in the full model-registry at this call site. Falls back to
180
+ * `~/.pi/agent/models.json` for parity with `resolveModelsJsonPath()`.
181
+ * Any read or parse error yields `false` (treat as not-custom) so a
182
+ * malformed models.json never breaks the session bootstrap.
183
+ */
184
+ export function isCustomProvider(provider) {
185
+ if (!provider)
186
+ return false;
187
+ const candidates = [
188
+ join(homedir(), ".gsd", "agent", "models.json"),
189
+ join(homedir(), ".pi", "agent", "models.json"),
190
+ ];
191
+ for (const path of candidates) {
192
+ if (!existsSync(path))
193
+ continue;
194
+ try {
195
+ const raw = readFileSync(path, "utf-8");
196
+ const parsed = JSON.parse(raw);
197
+ if (parsed?.providers && Object.prototype.hasOwnProperty.call(parsed.providers, provider)) {
198
+ return true;
199
+ }
200
+ }
201
+ catch {
202
+ // Ignore — malformed models.json must not break bootstrap.
203
+ }
204
+ }
205
+ return false;
206
+ }
164
207
  /**
165
208
  * Determines the next fallback model to try when the current model fails.
166
209
  * If the current model is not in the configured list, returns the primary model.
@@ -83,6 +83,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set([
83
83
  "discuss_preparation",
84
84
  "discuss_web_research",
85
85
  "discuss_depth",
86
+ "flat_rate_providers",
86
87
  ]);
87
88
  /** Canonical list of all dispatch unit types. */
88
89
  export const KNOWN_UNIT_TYPES = [
@@ -155,6 +155,28 @@ export function validatePreferences(preferences) {
155
155
  errors.push(`search_provider must be one of: brave, tavily, ollama, native, auto`);
156
156
  }
157
157
  }
158
+ // ─── Flat-rate Providers ────────────────────────────────────────────
159
+ // User-declared flat-rate providers for dynamic routing suppression.
160
+ // Built-in providers (github-copilot, copilot, claude-code) and any
161
+ // externalCli provider are already auto-detected; this list layers on
162
+ // top for private subscription proxies and custom CLI wrappers.
163
+ if (preferences.flat_rate_providers !== undefined) {
164
+ if (Array.isArray(preferences.flat_rate_providers)) {
165
+ const allStrings = preferences.flat_rate_providers.every((item) => typeof item === "string");
166
+ if (allStrings) {
167
+ // Strip empty/whitespace-only entries to avoid false matches.
168
+ validated.flat_rate_providers = preferences.flat_rate_providers
169
+ .map((s) => s.trim())
170
+ .filter((s) => s.length > 0);
171
+ }
172
+ else {
173
+ errors.push("flat_rate_providers must be an array of strings");
174
+ }
175
+ }
176
+ else {
177
+ errors.push("flat_rate_providers must be an array of strings");
178
+ }
179
+ }
158
180
  // ─── Phase Skip Preferences ─────────────────────────────────────────
159
181
  if (preferences.phases !== undefined) {
160
182
  if (typeof preferences.phases === "object" && preferences.phases !== null) {
@@ -312,6 +312,10 @@ function reconcileDiskToDb(basePath) {
312
312
  function buildCompletenessSet(basePath, milestones) {
313
313
  const completeMilestoneIds = new Set();
314
314
  const parkedMilestoneIds = new Set();
315
+ // DB-authoritative: a milestone is only "complete" when its DB row says so.
316
+ // SUMMARY-file presence is NOT a completion signal here — an orphan SUMMARY
317
+ // (crashed complete-milestone turn, partial merge, manual edit) must not
318
+ // flip derived state to complete and cascade into a false auto-merge (#4179).
315
319
  for (const m of milestones) {
316
320
  const parkedFile = resolveMilestoneFile(basePath, m.id, "PARKED");
317
321
  if (parkedFile || m.status === 'parked') {
@@ -322,11 +326,6 @@ function buildCompletenessSet(basePath, milestones) {
322
326
  completeMilestoneIds.add(m.id);
323
327
  continue;
324
328
  }
325
- const summaryFile = resolveMilestoneFile(basePath, m.id, "SUMMARY");
326
- if (summaryFile) {
327
- completeMilestoneIds.add(m.id);
328
- continue;
329
- }
330
329
  }
331
330
  return { completeMilestoneIds, parkedMilestoneIds };
332
331
  }
@@ -347,17 +346,22 @@ async function buildRegistryAndFindActive(basePath, milestones, completeMileston
347
346
  if (isGhostMilestone(basePath, m.id))
348
347
  continue;
349
348
  }
350
- const summaryFile = resolveMilestoneFile(basePath, m.id, "SUMMARY");
351
- if (completeMilestoneIds.has(m.id) || (summaryFile !== null)) {
349
+ // DB-authoritative completeness (#4179): only trust completeMilestoneIds,
350
+ // which is itself derived from DB status. SUMMARY-file presence alone must
351
+ // not imply completion. The summary file may still be consulted below as a
352
+ // title source for legitimately-complete milestones whose DB row has no title.
353
+ if (completeMilestoneIds.has(m.id)) {
352
354
  let title = stripMilestonePrefix(m.title) || m.id;
353
- if (summaryFile && !m.title) {
354
- const summaryContent = await loadFile(summaryFile);
355
- if (summaryContent) {
356
- title = parseSummary(summaryContent).title || m.id;
355
+ if (!m.title) {
356
+ const summaryFile = resolveMilestoneFile(basePath, m.id, "SUMMARY");
357
+ if (summaryFile) {
358
+ const summaryContent = await loadFile(summaryFile);
359
+ if (summaryContent) {
360
+ title = parseSummary(summaryContent).title || m.id;
361
+ }
357
362
  }
358
363
  }
359
364
  registry.push({ id: m.id, title, status: 'complete' });
360
- completeMilestoneIds.add(m.id);
361
365
  continue;
362
366
  }
363
367
  const allSlicesDone = slices.length > 0 && slices.every(s => isStatusDone(s.status));
@@ -391,7 +395,14 @@ async function buildRegistryAndFindActive(basePath, milestones, completeMileston
391
395
  const validationFile = resolveMilestoneFile(basePath, m.id, "VALIDATION");
392
396
  const validationContent = validationFile ? await loadFile(validationFile) : null;
393
397
  const validationTerminal = validationContent ? isValidationTerminal(validationContent) : false;
394
- if (!validationTerminal || (validationTerminal && !summaryFile)) {
398
+ // DB-authoritative (#4179): completeness is already decided by
399
+ // completeMilestoneIds above. If we reached this branch, the DB says
400
+ // the milestone is NOT complete — so any SUMMARY file on disk is an
401
+ // orphan (crashed complete-milestone, partial merge, manual edit) and
402
+ // must not short-circuit this path. When validation is terminal, fall
403
+ // through to the default active-push below so `complete-milestone` can
404
+ // re-run idempotently.
405
+ if (!validationTerminal) {
395
406
  activeMilestone = { id: m.id, title };
396
407
  activeMilestoneSlices = slices;
397
408
  activeMilestoneFound = true;
@@ -516,6 +527,9 @@ function resolveSliceDependencies(activeMilestoneSlices) {
516
527
  return { activeSlice: null, activeSliceRow: null };
517
528
  }
518
529
  }
530
+ // First pass: find a slice with ALL dependencies satisfied (strict)
531
+ let bestFallback = null;
532
+ let bestFallbackSatisfied = -1;
519
533
  for (const s of activeMilestoneSlices) {
520
534
  if (isStatusDone(s.status))
521
535
  continue;
@@ -524,6 +538,23 @@ function resolveSliceDependencies(activeMilestoneSlices) {
524
538
  if (s.depends.every(dep => doneSliceIds.has(dep))) {
525
539
  return { activeSlice: { id: s.id, title: s.title }, activeSliceRow: s };
526
540
  }
541
+ // Track the slice with the most satisfied dependencies as fallback
542
+ const satisfied = s.depends.filter(dep => doneSliceIds.has(dep)).length;
543
+ if (satisfied > bestFallbackSatisfied || (satisfied === bestFallbackSatisfied && !bestFallback)) {
544
+ bestFallback = s;
545
+ bestFallbackSatisfied = satisfied;
546
+ }
547
+ }
548
+ // Fallback: if no slice has all deps met but there ARE incomplete non-deferred
549
+ // slices, pick the one with the most deps satisfied. This prevents hard-blocking
550
+ // when dependency metadata is stale (e.g. after reassessment added/removed slices)
551
+ // or when deps reference slices from previous milestones.
552
+ if (bestFallback) {
553
+ const unmet = bestFallback.depends.filter(dep => !doneSliceIds.has(dep));
554
+ logWarning("state", `No slice has all deps satisfied — falling back to ${bestFallback.id} ` +
555
+ `(${bestFallbackSatisfied}/${bestFallback.depends.length} deps met, ` +
556
+ `unmet: ${unmet.join(", ")})`, { mid: activeMilestoneSlices[0]?.milestone_id, sid: bestFallback.id });
557
+ return { activeSlice: { id: bestFallback.id, title: bestFallback.title }, activeSliceRow: bestFallback };
527
558
  }
528
559
  return { activeSlice: null, activeSliceRow: null };
529
560
  }
@@ -567,7 +598,7 @@ async function reconcileSliceTasks(basePath, milestoneId, sliceId, planFile) {
567
598
  const summaryPath = resolveTaskFile(basePath, milestoneId, sliceId, t.id, "SUMMARY");
568
599
  if (summaryPath && existsSync(summaryPath)) {
569
600
  try {
570
- updateTaskStatus(milestoneId, sliceId, t.id, "complete");
601
+ updateTaskStatus(milestoneId, sliceId, t.id, "complete", new Date().toISOString());
571
602
  logWarning("reconcile", `task ${milestoneId}/${sliceId}/${t.id} status reconciled from "${t.status}" to "complete" (#2514)`, { mid: milestoneId, sid: sliceId, tid: t.id });
572
603
  reconciled = true;
573
604
  }
@@ -1269,6 +1300,8 @@ export async function _deriveStateImpl(basePath) {
1269
1300
  }
1270
1301
  }
1271
1302
  else {
1303
+ let bestFallbackLegacy = null;
1304
+ let bestFallbackLegacySatisfied = -1;
1272
1305
  for (const s of activeRoadmap.slices) {
1273
1306
  if (s.done)
1274
1307
  continue;
@@ -1276,6 +1309,20 @@ export async function _deriveStateImpl(basePath) {
1276
1309
  activeSlice = { id: s.id, title: s.title };
1277
1310
  break;
1278
1311
  }
1312
+ // Track best fallback
1313
+ const satisfied = s.depends.filter(dep => doneSliceIds.has(dep)).length;
1314
+ if (satisfied > bestFallbackLegacySatisfied) {
1315
+ bestFallbackLegacy = s;
1316
+ bestFallbackLegacySatisfied = satisfied;
1317
+ }
1318
+ }
1319
+ // Fallback: if no slice has all deps met, pick the one with the most deps satisfied
1320
+ if (!activeSlice && bestFallbackLegacy) {
1321
+ const unmet = bestFallbackLegacy.depends.filter(dep => !doneSliceIds.has(dep));
1322
+ logWarning("state", `No slice has all deps satisfied — falling back to ${bestFallbackLegacy.id} ` +
1323
+ `(${bestFallbackLegacySatisfied}/${bestFallbackLegacy.depends.length} deps met, ` +
1324
+ `unmet: ${unmet.join(", ")})`);
1325
+ activeSlice = { id: bestFallbackLegacy.id, title: bestFallbackLegacy.title };
1279
1326
  }
1280
1327
  }
1281
1328
  if (!activeSlice) {
@@ -9,6 +9,7 @@ export declare function compareSemver(a: string, b: string): number;
9
9
  export declare function readUpdateCache(cachePath?: string): UpdateCheckCache | null;
10
10
  export declare function writeUpdateCache(cache: UpdateCheckCache, cachePath?: string): void;
11
11
  export declare function fetchLatestVersionFromRegistry(registryUrl?: string, fetchTimeoutMs?: number): Promise<string | null>;
12
+ export declare function resolveInstallCommand(pkg: string): string;
12
13
  export interface UpdateCheckOptions {
13
14
  currentVersion?: string;
14
15
  cachePath?: string;