gsd-pi 2.70.1-dev.ec24142 → 2.71.0-dev.977c553

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 (81) hide show
  1. package/README.md +24 -17
  2. package/dist/resources/extensions/gsd/custom-workflow-engine.js +16 -12
  3. package/dist/resources/extensions/gsd/file-lock.js +60 -0
  4. package/dist/resources/extensions/gsd/state.js +234 -332
  5. package/dist/resources/extensions/gsd/workflow-events.js +25 -13
  6. package/dist/web/standalone/.next/BUILD_ID +1 -1
  7. package/dist/web/standalone/.next/app-path-routes-manifest.json +9 -9
  8. package/dist/web/standalone/.next/build-manifest.json +2 -2
  9. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  10. package/dist/web/standalone/.next/required-server-files.json +1 -1
  11. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  12. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  13. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  14. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  15. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  16. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  17. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  18. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  19. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  20. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  21. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/index.html +1 -1
  28. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app-paths-manifest.json +9 -9
  35. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  36. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  37. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  38. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  39. package/dist/web/standalone/server.js +1 -1
  40. package/package.json +1 -1
  41. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js +202 -1
  42. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js.map +1 -1
  43. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.d.ts +19 -2
  44. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.d.ts.map +1 -1
  45. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.js +50 -1
  46. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.js.map +1 -1
  47. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  48. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +90 -2
  49. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  50. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts +1 -0
  51. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts.map +1 -1
  52. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.js.map +1 -1
  53. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +6 -0
  54. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  55. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +57 -1
  56. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  57. package/packages/pi-coding-agent/package.json +1 -1
  58. package/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts +249 -1
  59. package/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.ts +58 -2
  60. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +96 -2
  61. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode-state.ts +1 -0
  62. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +65 -1
  63. package/packages/pi-tui/dist/components/__tests__/markdown-maxlines.test.d.ts +2 -0
  64. package/packages/pi-tui/dist/components/__tests__/markdown-maxlines.test.d.ts.map +1 -0
  65. package/packages/pi-tui/dist/components/__tests__/markdown-maxlines.test.js +66 -0
  66. package/packages/pi-tui/dist/components/__tests__/markdown-maxlines.test.js.map +1 -0
  67. package/packages/pi-tui/dist/components/markdown.d.ts +3 -0
  68. package/packages/pi-tui/dist/components/markdown.d.ts.map +1 -1
  69. package/packages/pi-tui/dist/components/markdown.js +17 -1
  70. package/packages/pi-tui/dist/components/markdown.js.map +1 -1
  71. package/packages/pi-tui/src/components/__tests__/markdown-maxlines.test.ts +75 -0
  72. package/packages/pi-tui/src/components/markdown.ts +22 -1
  73. package/pkg/package.json +1 -1
  74. package/src/resources/extensions/gsd/custom-workflow-engine.ts +19 -14
  75. package/src/resources/extensions/gsd/file-lock.ts +59 -0
  76. package/src/resources/extensions/gsd/state.ts +274 -344
  77. package/src/resources/extensions/gsd/tests/derive-state-helpers.test.ts +436 -0
  78. package/src/resources/extensions/gsd/tests/file-lock.test.ts +103 -0
  79. package/src/resources/extensions/gsd/workflow-events.ts +34 -25
  80. /package/dist/web/standalone/.next/static/{20e8bFnNjxQJflHNodEve → 4xyaXTn7-shVHaGMcl75o}/_buildManifest.js +0 -0
  81. /package/dist/web/standalone/.next/static/{20e8bFnNjxQJflHNodEve → 4xyaXTn7-shVHaGMcl75o}/_ssgManifest.js +0 -0
@@ -246,15 +246,8 @@ const isStatusDone = isClosedStatus;
246
246
  *
247
247
  * Must produce field-identical GSDState to _deriveStateImpl() for the same project.
248
248
  */
249
- export async function deriveStateFromDb(basePath) {
250
- const requirements = parseRequirementCounts(await loadFile(resolveGsdRootFile(basePath, "REQUIREMENTS")));
249
+ function reconcileDiskToDb(basePath) {
251
250
  let allMilestones = getAllMilestones();
252
- // Incremental disk→DB sync: milestone directories created outside the DB
253
- // write path (via /gsd queue, manual mkdir, or complete-milestone writing the
254
- // next CONTEXT.md) are never inserted by the initial migration guard in
255
- // auto-start.ts because that guard only runs when gsd.db doesn't exist yet.
256
- // Reconcile here so deriveStateFromDb never silently misses queued milestones.
257
- // insertMilestone uses INSERT OR IGNORE, so this is safe to call every time.
258
251
  const dbIdSet = new Set(allMilestones.map(m => m.id));
259
252
  const diskIds = findMilestoneIds(basePath);
260
253
  let synced = false;
@@ -266,11 +259,6 @@ export async function deriveStateFromDb(basePath) {
266
259
  }
267
260
  if (synced)
268
261
  allMilestones = getAllMilestones();
269
- // Disk→DB slice reconciliation (#2533): slices defined in ROADMAP.md but
270
- // missing from the DB cause permanent "No slice eligible" blocks because
271
- // the dependency resolver only sees DB rows. Parse each milestone's roadmap
272
- // and insert any missing slices, checking SUMMARY files to set correct status.
273
- // insertSlice uses INSERT OR IGNORE, so existing rows are never overwritten.
274
262
  for (const mid of diskIds) {
275
263
  if (isGhostMilestone(basePath, mid))
276
264
  continue;
@@ -299,56 +287,12 @@ export async function deriveStateFromDb(basePath) {
299
287
  });
300
288
  }
301
289
  }
302
- // Reconcile: discover milestones that exist on disk but are missing from
303
- // the DB. This happens when milestones were created before the DB migration
304
- // or were manually added to the filesystem. Without this, disk-only
305
- // milestones are invisible after migration (#2416).
306
- const dbMilestoneIds = new Set(allMilestones.map(m => m.id));
307
- const diskMilestoneIds = findMilestoneIds(basePath);
308
- for (const diskId of diskMilestoneIds) {
309
- if (!dbMilestoneIds.has(diskId)) {
310
- // Synthesize a minimal MilestoneRow for the disk-only milestone.
311
- // Title and status will be resolved from disk files in the loop below.
312
- allMilestones.push({
313
- id: diskId,
314
- title: diskId,
315
- status: 'active',
316
- depends_on: [],
317
- created_at: new Date().toISOString(),
318
- });
319
- }
320
- }
321
- // Re-sort so milestones follow queue order (same as dispatch guard) (#2556)
322
- const customOrder = loadQueueOrder(basePath);
323
- const sortedIds = sortByQueueOrder(allMilestones.map(m => m.id), customOrder);
324
- const byId = new Map(allMilestones.map(m => [m.id, m]));
325
- allMilestones.length = 0;
326
- for (const id of sortedIds)
327
- allMilestones.push(byId.get(id));
328
- // Parallel worker isolation: when locked, filter to just the locked milestone
329
- const milestoneLock = process.env.GSD_MILESTONE_LOCK;
330
- const milestones = milestoneLock
331
- ? allMilestones.filter(m => m.id === milestoneLock)
332
- : allMilestones;
333
- if (milestones.length === 0) {
334
- return {
335
- activeMilestone: null,
336
- activeSlice: null,
337
- activeTask: null,
338
- phase: 'pre-planning',
339
- recentDecisions: [],
340
- blockers: [],
341
- nextAction: 'No milestones found. Run /gsd to create one.',
342
- registry: [],
343
- requirements,
344
- progress: { milestones: { done: 0, total: 0 } },
345
- };
346
- }
347
- // Phase 1: Build completeness set (which milestones count as "done" for dep resolution)
290
+ return allMilestones;
291
+ }
292
+ function buildCompletenessSet(basePath, milestones) {
348
293
  const completeMilestoneIds = new Set();
349
294
  const parkedMilestoneIds = new Set();
350
295
  for (const m of milestones) {
351
- // Check disk for PARKED flag (not stored in DB status reliably — disk is truth for flag files)
352
296
  const parkedFile = resolveMilestoneFile(basePath, m.id, "PARKED");
353
297
  if (parkedFile || m.status === 'parked') {
354
298
  parkedMilestoneIds.add(m.id);
@@ -358,44 +302,33 @@ export async function deriveStateFromDb(basePath) {
358
302
  completeMilestoneIds.add(m.id);
359
303
  continue;
360
304
  }
361
- // Check if milestone has a summary on disk (terminal artifact per #864)
362
305
  const summaryFile = resolveMilestoneFile(basePath, m.id, "SUMMARY");
363
306
  if (summaryFile) {
364
307
  completeMilestoneIds.add(m.id);
365
308
  continue;
366
309
  }
367
- // Milestones with all slices done but no SUMMARY file are in
368
- // validating/completing state — intentionally NOT added to
369
- // completeMilestoneIds. The SUMMARY file (checked above) is the
370
- // terminal artifact that proves completion per #864.
371
310
  }
372
- // Phase 2: Build registry and find active milestone
311
+ return { completeMilestoneIds, parkedMilestoneIds };
312
+ }
313
+ async function buildRegistryAndFindActive(basePath, milestones, completeMilestoneIds, parkedMilestoneIds) {
373
314
  const registry = [];
374
315
  let activeMilestone = null;
375
316
  let activeMilestoneSlices = [];
376
317
  let activeMilestoneFound = false;
377
318
  let activeMilestoneHasDraft = false;
378
- // Queued shells (DB row, no slices, no content files) are deferred during
379
- // the main loop so they don't eclipse real active milestones (#3470).
380
- // If no real active milestone is found, the first deferred shell is promoted.
381
319
  let firstDeferredQueuedShell = null;
382
320
  for (const m of milestones) {
383
321
  if (parkedMilestoneIds.has(m.id)) {
384
322
  registry.push({ id: m.id, title: stripMilestonePrefix(m.title) || m.id, status: 'parked' });
385
323
  continue;
386
324
  }
387
- // Ghost milestone check: no slices in DB AND no substantive files on disk.
388
- // Skip queued milestones — they are handled by the deferred-shell logic below (#3470).
389
325
  const slices = getMilestoneSlices(m.id);
390
326
  if (slices.length === 0 && !isStatusDone(m.status) && m.status !== 'queued') {
391
- // Check disk for ghost detection
392
327
  if (isGhostMilestone(basePath, m.id))
393
328
  continue;
394
329
  }
395
330
  const summaryFile = resolveMilestoneFile(basePath, m.id, "SUMMARY");
396
- // Determine if this milestone is complete
397
331
  if (completeMilestoneIds.has(m.id) || (summaryFile !== null)) {
398
- // Get title from DB or summary
399
332
  let title = stripMilestonePrefix(m.title) || m.id;
400
333
  if (summaryFile && !m.title) {
401
334
  const summaryContent = await loadFile(summaryFile);
@@ -404,12 +337,10 @@ export async function deriveStateFromDb(basePath) {
404
337
  }
405
338
  }
406
339
  registry.push({ id: m.id, title, status: 'complete' });
407
- completeMilestoneIds.add(m.id); // ensure it's in the set
340
+ completeMilestoneIds.add(m.id);
408
341
  continue;
409
342
  }
410
- // Not complete — determine if it should be active
411
343
  const allSlicesDone = slices.length > 0 && slices.every(s => isStatusDone(s.status));
412
- // Get title — prefer DB, fall back to context file extraction
413
344
  let title = stripMilestonePrefix(m.title) || m.id;
414
345
  if (title === m.id) {
415
346
  const contextFile = resolveMilestoneFile(basePath, m.id, "CONTEXT");
@@ -419,18 +350,12 @@ export async function deriveStateFromDb(basePath) {
419
350
  title = extractContextTitle(contextContent || draftContent, m.id);
420
351
  }
421
352
  if (!activeMilestoneFound) {
422
- // Check milestone-level dependencies
423
353
  const deps = m.depends_on;
424
354
  const depsUnmet = deps.some(dep => !completeMilestoneIds.has(dep));
425
355
  if (depsUnmet) {
426
356
  registry.push({ id: m.id, title, status: 'pending', dependsOn: deps });
427
357
  continue;
428
358
  }
429
- // Defer queued shell milestones with no substantive content (#3470).
430
- // A queued milestone with no slices and no context/draft file is a
431
- // placeholder that should not block later real active milestones.
432
- // If no real active milestone is found after the loop, the first
433
- // deferred shell is promoted to active (#2921).
434
359
  if (m.status === 'queued' && slices.length === 0) {
435
360
  const contextFile = resolveMilestoneFile(basePath, m.id, "CONTEXT");
436
361
  const draftFile = resolveMilestoneFile(basePath, m.id, "CONTEXT-DRAFT");
@@ -442,13 +367,11 @@ export async function deriveStateFromDb(basePath) {
442
367
  continue;
443
368
  }
444
369
  }
445
- // Handle all-slices-done case (validating/completing)
446
370
  if (allSlicesDone) {
447
371
  const validationFile = resolveMilestoneFile(basePath, m.id, "VALIDATION");
448
372
  const validationContent = validationFile ? await loadFile(validationFile) : null;
449
373
  const validationTerminal = validationContent ? isValidationTerminal(validationContent) : false;
450
374
  if (!validationTerminal || (validationTerminal && !summaryFile)) {
451
- // Validating or completing — still active
452
375
  activeMilestone = { id: m.id, title };
453
376
  activeMilestoneSlices = slices;
454
377
  activeMilestoneFound = true;
@@ -456,7 +379,6 @@ export async function deriveStateFromDb(basePath) {
456
379
  continue;
457
380
  }
458
381
  }
459
- // Check for context draft (needs-discussion phase)
460
382
  const contextFile = resolveMilestoneFile(basePath, m.id, "CONTEXT");
461
383
  const draftFile = resolveMilestoneFile(basePath, m.id, "CONTEXT-DRAFT");
462
384
  if (!contextFile && draftFile)
@@ -467,12 +389,10 @@ export async function deriveStateFromDb(basePath) {
467
389
  registry.push({ id: m.id, title, status: 'active', ...(deps.length > 0 ? { dependsOn: deps } : {}) });
468
390
  }
469
391
  else {
470
- // After active milestone found — rest are pending
471
392
  const deps = m.depends_on;
472
393
  registry.push({ id: m.id, title, status: 'pending', ...(deps.length > 0 ? { dependsOn: deps } : {}) });
473
394
  }
474
395
  }
475
- // Promote deferred queued shell if no real active milestone was found (#3470/#2921).
476
396
  if (!activeMilestoneFound && firstDeferredQueuedShell) {
477
397
  const shell = firstDeferredQueuedShell;
478
398
  activeMilestone = { id: shell.id, title: shell.title };
@@ -482,204 +402,113 @@ export async function deriveStateFromDb(basePath) {
482
402
  if (entry)
483
403
  entry.status = 'active';
484
404
  }
485
- const milestoneProgress = {
486
- done: registry.filter(e => e.status === 'complete').length,
487
- total: registry.length,
488
- };
489
- // ── No active milestone ──────────────────────────────────────────────
490
- if (!activeMilestone) {
491
- const pendingEntries = registry.filter(e => e.status === 'pending');
492
- const parkedEntries = registry.filter(e => e.status === 'parked');
493
- if (pendingEntries.length > 0) {
494
- const blockerDetails = pendingEntries
495
- .filter(e => e.dependsOn && e.dependsOn.length > 0)
496
- .map(e => `${e.id} is waiting on unmet deps: ${e.dependsOn.join(', ')}`);
497
- return {
498
- activeMilestone: null, activeSlice: null, activeTask: null,
499
- phase: 'blocked',
500
- recentDecisions: [], blockers: blockerDetails.length > 0
501
- ? blockerDetails
502
- : ['All remaining milestones are dep-blocked but no deps listed — check CONTEXT.md files'],
503
- nextAction: 'Resolve milestone dependencies before proceeding.',
504
- registry, requirements,
505
- progress: { milestones: milestoneProgress },
506
- };
507
- }
508
- if (parkedEntries.length > 0) {
509
- const parkedIds = parkedEntries.map(e => e.id).join(', ');
510
- return {
511
- activeMilestone: null, activeSlice: null, activeTask: null,
512
- phase: 'pre-planning',
513
- recentDecisions: [], blockers: [],
514
- nextAction: `All remaining milestones are parked (${parkedIds}). Run /gsd unpark <id> or create a new milestone.`,
515
- registry, requirements,
516
- progress: { milestones: milestoneProgress },
517
- };
518
- }
519
- if (registry.length === 0) {
520
- return {
521
- activeMilestone: null, activeSlice: null, activeTask: null,
522
- phase: 'pre-planning',
523
- recentDecisions: [], blockers: [],
524
- nextAction: 'No milestones found. Run /gsd to create one.',
525
- registry: [], requirements,
526
- progress: { milestones: { done: 0, total: 0 } },
527
- };
528
- }
529
- // All milestones complete
530
- const lastEntry = registry[registry.length - 1];
531
- const activeReqs = requirements.active ?? 0;
532
- const completionNote = activeReqs > 0
533
- ? `All milestones complete. ${activeReqs} active requirement${activeReqs === 1 ? '' : 's'} in REQUIREMENTS.md ${activeReqs === 1 ? 'has' : 'have'} not been mapped to a milestone.`
534
- : 'All milestones complete.';
405
+ return { registry, activeMilestone, activeMilestoneSlices, activeMilestoneHasDraft };
406
+ }
407
+ function handleNoActiveMilestone(registry, requirements, milestoneProgress) {
408
+ const pendingEntries = registry.filter(e => e.status === 'pending');
409
+ const parkedEntries = registry.filter(e => e.status === 'parked');
410
+ if (pendingEntries.length > 0) {
411
+ const blockerDetails = pendingEntries
412
+ .filter(e => e.dependsOn && e.dependsOn.length > 0)
413
+ .map(e => `${e.id} is waiting on unmet deps: ${e.dependsOn.join(', ')}`);
535
414
  return {
536
- activeMilestone: null,
537
- lastCompletedMilestone: lastEntry ? { id: lastEntry.id, title: lastEntry.title } : null,
538
- activeSlice: null, activeTask: null,
539
- phase: 'complete',
540
- recentDecisions: [], blockers: [],
541
- nextAction: completionNote,
415
+ activeMilestone: null, activeSlice: null, activeTask: null,
416
+ phase: 'blocked',
417
+ recentDecisions: [], blockers: blockerDetails.length > 0
418
+ ? blockerDetails
419
+ : ['All remaining milestones are dep-blocked but no deps listed — check CONTEXT.md files'],
420
+ nextAction: 'Resolve milestone dependencies before proceeding.',
542
421
  registry, requirements,
543
422
  progress: { milestones: milestoneProgress },
544
423
  };
545
424
  }
546
- // ── Active milestone has no slices or no roadmap ────────────────────
547
- const hasRoadmap = resolveMilestoneFile(basePath, activeMilestone.id, "ROADMAP") !== null;
548
- if (activeMilestoneSlices.length === 0) {
549
- if (!hasRoadmap) {
550
- const phase = activeMilestoneHasDraft ? 'needs-discussion' : 'pre-planning';
551
- const nextAction = activeMilestoneHasDraft
552
- ? `Discuss draft context for milestone ${activeMilestone.id}.`
553
- : `Plan milestone ${activeMilestone.id}.`;
554
- return {
555
- activeMilestone, activeSlice: null, activeTask: null,
556
- phase, recentDecisions: [], blockers: [],
557
- nextAction, registry, requirements,
558
- progress: { milestones: milestoneProgress },
559
- };
560
- }
561
- // Has roadmap file but zero slices in DB — pre-planning (zero-slice roadmap guard)
425
+ if (parkedEntries.length > 0) {
426
+ const parkedIds = parkedEntries.map(e => e.id).join(', ');
562
427
  return {
563
- activeMilestone, activeSlice: null, activeTask: null,
428
+ activeMilestone: null, activeSlice: null, activeTask: null,
564
429
  phase: 'pre-planning',
565
430
  recentDecisions: [], blockers: [],
566
- nextAction: `Milestone ${activeMilestone.id} has a roadmap but no slices defined. Add slices to the roadmap.`,
431
+ nextAction: `All remaining milestones are parked (${parkedIds}). Run /gsd unpark <id> or create a new milestone.`,
567
432
  registry, requirements,
568
- progress: {
569
- milestones: milestoneProgress,
570
- slices: { done: 0, total: 0 },
571
- },
433
+ progress: { milestones: milestoneProgress },
572
434
  };
573
435
  }
574
- // ── All slices done → validating/completing ─────────────────────────
575
- const allSlicesDone = activeMilestoneSlices.every(s => isStatusDone(s.status));
576
- if (allSlicesDone) {
577
- const validationFile = resolveMilestoneFile(basePath, activeMilestone.id, "VALIDATION");
578
- const validationContent = validationFile ? await loadFile(validationFile) : null;
579
- const validationTerminal = validationContent ? isValidationTerminal(validationContent) : false;
580
- const verdict = validationContent ? extractVerdict(validationContent) : undefined;
581
- const sliceProgress = {
582
- done: activeMilestoneSlices.length,
583
- total: activeMilestoneSlices.length,
436
+ if (registry.length === 0) {
437
+ return {
438
+ activeMilestone: null, activeSlice: null, activeTask: null,
439
+ phase: 'pre-planning',
440
+ recentDecisions: [], blockers: [],
441
+ nextAction: 'No milestones found. Run /gsd to create one.',
442
+ registry: [], requirements,
443
+ progress: { milestones: { done: 0, total: 0 } },
584
444
  };
585
- // Force re-validation when verdict is needs-remediation — remediation slices
586
- // may have completed since the stale validation was written (#3596).
587
- if (!validationTerminal || verdict === 'needs-remediation') {
588
- return {
589
- activeMilestone, activeSlice: null, activeTask: null,
590
- phase: 'validating-milestone',
591
- recentDecisions: [], blockers: [],
592
- nextAction: `Validate milestone ${activeMilestone.id} before completion.`,
593
- registry, requirements,
594
- progress: { milestones: milestoneProgress, slices: sliceProgress },
595
- };
596
- }
445
+ }
446
+ const lastEntry = registry[registry.length - 1];
447
+ const activeReqs = requirements.active ?? 0;
448
+ const completionNote = activeReqs > 0
449
+ ? `All milestones complete. ${activeReqs} active requirement${activeReqs === 1 ? '' : 's'} in REQUIREMENTS.md ${activeReqs === 1 ? 'has' : 'have'} not been mapped to a milestone.`
450
+ : 'All milestones complete.';
451
+ return {
452
+ activeMilestone: null,
453
+ lastCompletedMilestone: lastEntry ? { id: lastEntry.id, title: lastEntry.title } : null,
454
+ activeSlice: null, activeTask: null,
455
+ phase: 'complete',
456
+ recentDecisions: [], blockers: [],
457
+ nextAction: completionNote,
458
+ registry, requirements,
459
+ progress: { milestones: milestoneProgress },
460
+ };
461
+ }
462
+ async function handleAllSlicesDone(basePath, activeMilestone, registry, requirements, milestoneProgress, sliceProgress) {
463
+ const validationFile = resolveMilestoneFile(basePath, activeMilestone.id, "VALIDATION");
464
+ const validationContent = validationFile ? await loadFile(validationFile) : null;
465
+ const validationTerminal = validationContent ? isValidationTerminal(validationContent) : false;
466
+ const verdict = validationContent ? extractVerdict(validationContent) : undefined;
467
+ if (!validationTerminal || verdict === 'needs-remediation') {
597
468
  return {
598
469
  activeMilestone, activeSlice: null, activeTask: null,
599
- phase: 'completing-milestone',
470
+ phase: 'validating-milestone',
600
471
  recentDecisions: [], blockers: [],
601
- nextAction: `All slices complete in ${activeMilestone.id}. Write milestone summary.`,
472
+ nextAction: `Validate milestone ${activeMilestone.id} before completion.`,
602
473
  registry, requirements,
603
474
  progress: { milestones: milestoneProgress, slices: sliceProgress },
604
475
  };
605
476
  }
606
- // ── Find active slice (first incomplete with deps satisfied) ─────────
607
- const sliceProgress = {
608
- done: activeMilestoneSlices.filter(s => isStatusDone(s.status)).length,
609
- total: activeMilestoneSlices.length,
477
+ return {
478
+ activeMilestone, activeSlice: null, activeTask: null,
479
+ phase: 'completing-milestone',
480
+ recentDecisions: [], blockers: [],
481
+ nextAction: `All slices complete in ${activeMilestone.id}. Write milestone summary.`,
482
+ registry, requirements,
483
+ progress: { milestones: milestoneProgress, slices: sliceProgress },
610
484
  };
485
+ }
486
+ function resolveSliceDependencies(activeMilestoneSlices) {
611
487
  const doneSliceIds = new Set(activeMilestoneSlices.filter(s => isStatusDone(s.status)).map(s => s.id));
612
- let activeSlice = null;
613
- let activeSliceRow = null;
614
- // ── Slice-level parallel worker isolation ─────────────────────────────
615
- // When GSD_SLICE_LOCK is set, this process is a parallel worker scoped
616
- // to a single slice. Override activeSlice to only the locked slice ID.
617
488
  const sliceLock = process.env.GSD_SLICE_LOCK;
618
489
  if (sliceLock) {
619
490
  const lockedSlice = activeMilestoneSlices.find(s => s.id === sliceLock);
620
491
  if (lockedSlice) {
621
- activeSlice = { id: lockedSlice.id, title: lockedSlice.title };
622
- activeSliceRow = lockedSlice;
492
+ return { activeSlice: { id: lockedSlice.id, title: lockedSlice.title }, activeSliceRow: lockedSlice };
623
493
  }
624
494
  else {
625
495
  logWarning("state", `GSD_SLICE_LOCK=${sliceLock} not found in active slices — worker has no assigned work`);
626
- // Don't silently continue this is a dispatch error
627
- return {
628
- activeMilestone, activeSlice: null, activeTask: null,
629
- phase: 'blocked',
630
- recentDecisions: [], blockers: [`GSD_SLICE_LOCK=${sliceLock} not found in active milestone slices`],
631
- nextAction: 'Slice lock references a non-existent slice — check orchestrator dispatch.',
632
- registry, requirements,
633
- progress: { milestones: milestoneProgress, slices: sliceProgress },
634
- };
496
+ return { activeSlice: null, activeSliceRow: null };
635
497
  }
636
498
  }
637
- else {
638
- for (const s of activeMilestoneSlices) {
639
- if (isStatusDone(s.status))
640
- continue;
641
- // #2661: Skip deferred slices — a decision explicitly deferred this work.
642
- // Without this guard the dispatcher would keep dispatching deferred slices
643
- // because DECISIONS.md is only contextual, not authoritative for dispatch.
644
- if (isDeferredStatus(s.status))
645
- continue;
646
- if (s.depends.every(dep => doneSliceIds.has(dep))) {
647
- activeSlice = { id: s.id, title: s.title };
648
- activeSliceRow = s;
649
- break;
650
- }
499
+ for (const s of activeMilestoneSlices) {
500
+ if (isStatusDone(s.status))
501
+ continue;
502
+ if (isDeferredStatus(s.status))
503
+ continue;
504
+ if (s.depends.every(dep => doneSliceIds.has(dep))) {
505
+ return { activeSlice: { id: s.id, title: s.title }, activeSliceRow: s };
651
506
  }
652
507
  }
653
- if (!activeSlice) {
654
- return {
655
- activeMilestone, activeSlice: null, activeTask: null,
656
- phase: 'blocked',
657
- recentDecisions: [], blockers: ['No slice eligible — check dependency ordering'],
658
- nextAction: 'Resolve dependency blockers or plan next slice.',
659
- registry, requirements,
660
- progress: { milestones: milestoneProgress, slices: sliceProgress },
661
- };
662
- }
663
- // ── Check for slice plan file on disk ────────────────────────────────
664
- const planFile = resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "PLAN");
665
- if (!planFile) {
666
- return {
667
- activeMilestone, activeSlice, activeTask: null,
668
- phase: 'planning',
669
- recentDecisions: [], blockers: [],
670
- nextAction: `Plan slice ${activeSlice.id} (${activeSlice.title}).`,
671
- registry, requirements,
672
- progress: { milestones: milestoneProgress, slices: sliceProgress },
673
- };
674
- }
675
- // ── Get tasks from DB ────────────────────────────────────────────────
676
- let tasks = getSliceTasks(activeMilestone.id, activeSlice.id);
677
- // ── Reconcile missing tasks: plan file has tasks but DB is empty (#3600) ──
678
- // When the planning agent writes S##-PLAN.md with task entries but never
679
- // calls the gsd_plan_slice persistence tool, the DB has zero task rows
680
- // even though the plan file contains valid tasks. Without this reconciliation,
681
- // deriveState returns phase='planning' forever — the dispatcher re-dispatches
682
- // plan-slice in an infinite loop.
508
+ return { activeSlice: null, activeSliceRow: null };
509
+ }
510
+ async function reconcileSliceTasks(basePath, milestoneId, sliceId, planFile) {
511
+ let tasks = getSliceTasks(milestoneId, sliceId);
683
512
  if (tasks.length === 0 && planFile) {
684
513
  try {
685
514
  const planContent = await loadFile(planFile);
@@ -691,143 +520,227 @@ export async function deriveStateFromDb(basePath) {
691
520
  try {
692
521
  insertTask({
693
522
  id: t.id,
694
- sliceId: activeSlice.id,
695
- milestoneId: activeMilestone.id,
523
+ sliceId,
524
+ milestoneId,
696
525
  title: t.title,
697
526
  status: t.done ? 'complete' : 'pending',
698
527
  sequence: i + 1,
699
528
  });
700
529
  }
701
530
  catch (insertErr) {
702
- // Task may already exist from a partial previous import — skip
703
531
  logWarning("reconcile", `failed to insert task ${t.id} from plan file: ${insertErr instanceof Error ? insertErr.message : String(insertErr)}`);
704
532
  }
705
533
  }
706
- tasks = getSliceTasks(activeMilestone.id, activeSlice.id);
707
- logWarning("reconcile", `imported ${tasks.length} tasks from plan file for ${activeMilestone.id}/${activeSlice.id} — DB was empty (#3600)`, { mid: activeMilestone.id, sid: activeSlice.id });
534
+ tasks = getSliceTasks(milestoneId, sliceId);
535
+ logWarning("reconcile", `imported ${tasks.length} tasks from plan file for ${milestoneId}/${sliceId} — DB was empty (#3600)`, { mid: milestoneId, sid: sliceId });
708
536
  }
709
537
  }
710
538
  }
711
539
  catch (err) {
712
- // Non-fatal fall through to the existing "empty plan" logic
713
- logError("reconcile", `plan-file task import failed for ${activeMilestone.id}/${activeSlice.id}: ${err instanceof Error ? err.message : String(err)}`);
540
+ logError("reconcile", `plan-file task import failed for ${milestoneId}/${sliceId}: ${err instanceof Error ? err.message : String(err)}`);
714
541
  }
715
542
  }
716
- // ── Reconcile stale task status (#2514) ──────────────────────────────
717
- // When a session disconnects after the agent writes SUMMARY + VERIFY
718
- // artifacts but before postUnitPostVerification updates the DB, tasks
719
- // remain "pending" in the DB despite being complete on disk. Without
720
- // reconciliation, deriveState keeps returning the stale task as active,
721
- // causing the dispatcher to re-dispatch the same completed task forever.
722
543
  let reconciled = false;
723
544
  for (const t of tasks) {
724
545
  if (isStatusDone(t.status))
725
546
  continue;
726
- const summaryPath = resolveTaskFile(basePath, activeMilestone.id, activeSlice.id, t.id, "SUMMARY");
547
+ const summaryPath = resolveTaskFile(basePath, milestoneId, sliceId, t.id, "SUMMARY");
727
548
  if (summaryPath && existsSync(summaryPath)) {
728
549
  try {
729
- updateTaskStatus(activeMilestone.id, activeSlice.id, t.id, "complete");
730
- logWarning("reconcile", `task ${activeMilestone.id}/${activeSlice.id}/${t.id} status reconciled from "${t.status}" to "complete" (#2514)`, { mid: activeMilestone.id, sid: activeSlice.id, tid: t.id });
550
+ updateTaskStatus(milestoneId, sliceId, t.id, "complete");
551
+ logWarning("reconcile", `task ${milestoneId}/${sliceId}/${t.id} status reconciled from "${t.status}" to "complete" (#2514)`, { mid: milestoneId, sid: sliceId, tid: t.id });
731
552
  reconciled = true;
732
553
  }
733
554
  catch (e) {
734
- // DB write failed — continue with stale status rather than crash
735
555
  logError("reconcile", `failed to update task ${t.id}`, { tid: t.id, error: e.message });
736
556
  }
737
557
  }
738
558
  }
739
- // Re-fetch tasks if any were reconciled so downstream logic sees fresh status
740
559
  if (reconciled) {
741
- tasks = getSliceTasks(activeMilestone.id, activeSlice.id);
560
+ tasks = getSliceTasks(milestoneId, sliceId);
561
+ }
562
+ return tasks;
563
+ }
564
+ async function detectBlockers(basePath, milestoneId, sliceId, tasks) {
565
+ const completedTasks = tasks.filter(t => isStatusDone(t.status));
566
+ for (const ct of completedTasks) {
567
+ if (ct.blocker_discovered) {
568
+ return ct.id;
569
+ }
570
+ const summaryFile = resolveTaskFile(basePath, milestoneId, sliceId, ct.id, "SUMMARY");
571
+ if (!summaryFile)
572
+ continue;
573
+ const summaryContent = await loadFile(summaryFile);
574
+ if (!summaryContent)
575
+ continue;
576
+ const summary = parseSummary(summaryContent);
577
+ if (summary.frontmatter.blocker_discovered) {
578
+ return ct.id;
579
+ }
580
+ }
581
+ return null;
582
+ }
583
+ function checkReplanTrigger(basePath, milestoneId, sliceId) {
584
+ const sliceRow = getSlice(milestoneId, sliceId);
585
+ const dbTriggered = !!sliceRow?.replan_triggered_at;
586
+ const diskTriggered = !dbTriggered &&
587
+ !!resolveSliceFile(basePath, milestoneId, sliceId, "REPLAN-TRIGGER");
588
+ return dbTriggered || diskTriggered;
589
+ }
590
+ async function checkInterruptedWork(basePath, milestoneId, sliceId) {
591
+ const sDir = resolveSlicePath(basePath, milestoneId, sliceId);
592
+ const continueFile = sDir ? resolveSliceFile(basePath, milestoneId, sliceId, "CONTINUE") : null;
593
+ return !!(continueFile && await loadFile(continueFile)) ||
594
+ !!(sDir && await loadFile(join(sDir, "continue.md")));
595
+ }
596
+ export async function deriveStateFromDb(basePath) {
597
+ const requirements = parseRequirementCounts(await loadFile(resolveGsdRootFile(basePath, "REQUIREMENTS")));
598
+ let allMilestones = reconcileDiskToDb(basePath);
599
+ const customOrder = loadQueueOrder(basePath);
600
+ const sortedIds = sortByQueueOrder(allMilestones.map(m => m.id), customOrder);
601
+ const byId = new Map(allMilestones.map(m => [m.id, m]));
602
+ allMilestones.length = 0;
603
+ for (const id of sortedIds)
604
+ allMilestones.push(byId.get(id));
605
+ const milestoneLock = process.env.GSD_MILESTONE_LOCK;
606
+ const milestones = milestoneLock
607
+ ? allMilestones.filter(m => m.id === milestoneLock)
608
+ : allMilestones;
609
+ if (milestones.length === 0) {
610
+ return {
611
+ activeMilestone: null, activeSlice: null, activeTask: null,
612
+ phase: 'pre-planning', recentDecisions: [], blockers: [],
613
+ nextAction: 'No milestones found. Run /gsd to create one.',
614
+ registry: [], requirements,
615
+ progress: { milestones: { done: 0, total: 0 } },
616
+ };
617
+ }
618
+ const { completeMilestoneIds, parkedMilestoneIds } = buildCompletenessSet(basePath, milestones);
619
+ const registryContext = await buildRegistryAndFindActive(basePath, milestones, completeMilestoneIds, parkedMilestoneIds);
620
+ const { registry, activeMilestone, activeMilestoneSlices, activeMilestoneHasDraft } = registryContext;
621
+ const milestoneProgress = {
622
+ done: registry.filter(e => e.status === 'complete').length,
623
+ total: registry.length,
624
+ };
625
+ if (!activeMilestone) {
626
+ return handleNoActiveMilestone(registry, requirements, milestoneProgress);
627
+ }
628
+ const hasRoadmap = resolveMilestoneFile(basePath, activeMilestone.id, "ROADMAP") !== null;
629
+ if (activeMilestoneSlices.length === 0) {
630
+ if (!hasRoadmap) {
631
+ const phase = activeMilestoneHasDraft ? 'needs-discussion' : 'pre-planning';
632
+ const nextAction = activeMilestoneHasDraft
633
+ ? `Discuss draft context for milestone ${activeMilestone.id}.`
634
+ : `Plan milestone ${activeMilestone.id}.`;
635
+ return {
636
+ activeMilestone, activeSlice: null, activeTask: null,
637
+ phase, recentDecisions: [], blockers: [],
638
+ nextAction, registry, requirements,
639
+ progress: { milestones: milestoneProgress },
640
+ };
641
+ }
642
+ return {
643
+ activeMilestone, activeSlice: null, activeTask: null,
644
+ phase: 'pre-planning', recentDecisions: [], blockers: [],
645
+ nextAction: `Milestone ${activeMilestone.id} has a roadmap but no slices defined. Add slices to the roadmap.`,
646
+ registry, requirements,
647
+ progress: { milestones: milestoneProgress, slices: { done: 0, total: 0 } },
648
+ };
649
+ }
650
+ const allSlicesDone = activeMilestoneSlices.every(s => isStatusDone(s.status));
651
+ const sliceProgress = {
652
+ done: activeMilestoneSlices.filter(s => isStatusDone(s.status)).length,
653
+ total: activeMilestoneSlices.length,
654
+ };
655
+ if (allSlicesDone) {
656
+ return handleAllSlicesDone(basePath, activeMilestone, registry, requirements, milestoneProgress, sliceProgress);
657
+ }
658
+ const activeSliceContext = resolveSliceDependencies(activeMilestoneSlices);
659
+ if (!activeSliceContext.activeSlice) {
660
+ // If locked slice wasn't found, it returns null but logs warning, we need to return 'blocked'
661
+ if (process.env.GSD_SLICE_LOCK) {
662
+ return {
663
+ activeMilestone, activeSlice: null, activeTask: null,
664
+ phase: 'blocked', recentDecisions: [], blockers: [`GSD_SLICE_LOCK=${process.env.GSD_SLICE_LOCK} not found in active milestone slices`],
665
+ nextAction: 'Slice lock references a non-existent slice — check orchestrator dispatch.',
666
+ registry, requirements,
667
+ progress: { milestones: milestoneProgress, slices: sliceProgress },
668
+ };
669
+ }
670
+ return {
671
+ activeMilestone, activeSlice: null, activeTask: null,
672
+ phase: 'blocked', recentDecisions: [], blockers: ['No slice eligible — check dependency ordering'],
673
+ nextAction: 'Resolve dependency blockers or plan next slice.',
674
+ registry, requirements,
675
+ progress: { milestones: milestoneProgress, slices: sliceProgress },
676
+ };
677
+ }
678
+ const { activeSlice } = activeSliceContext;
679
+ const planFile = resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "PLAN");
680
+ if (!planFile) {
681
+ return {
682
+ activeMilestone, activeSlice, activeTask: null,
683
+ phase: 'planning', recentDecisions: [], blockers: [],
684
+ nextAction: `Plan slice ${activeSlice.id} (${activeSlice.title}).`,
685
+ registry, requirements,
686
+ progress: { milestones: milestoneProgress, slices: sliceProgress },
687
+ };
742
688
  }
689
+ const tasks = await reconcileSliceTasks(basePath, activeMilestone.id, activeSlice.id, planFile);
743
690
  const taskProgress = {
744
691
  done: tasks.filter(t => isStatusDone(t.status)).length,
745
692
  total: tasks.length,
746
693
  };
747
694
  const activeTaskRow = tasks.find(t => !isStatusDone(t.status));
748
695
  if (!activeTaskRow && tasks.length > 0) {
749
- // All tasks done but slice not marked complete → summarizing
750
696
  return {
751
697
  activeMilestone, activeSlice, activeTask: null,
752
- phase: 'summarizing',
753
- recentDecisions: [], blockers: [],
698
+ phase: 'summarizing', recentDecisions: [], blockers: [],
754
699
  nextAction: `All tasks done in ${activeSlice.id}. Write slice summary and complete slice.`,
755
700
  registry, requirements,
756
701
  progress: { milestones: milestoneProgress, slices: sliceProgress, tasks: taskProgress },
757
702
  };
758
703
  }
759
- // Empty plan — no tasks defined yet
760
704
  if (!activeTaskRow) {
761
705
  return {
762
706
  activeMilestone, activeSlice, activeTask: null,
763
- phase: 'planning',
764
- recentDecisions: [], blockers: [],
707
+ phase: 'planning', recentDecisions: [], blockers: [],
765
708
  nextAction: `Slice ${activeSlice.id} has a plan file but no tasks. Add tasks to the plan.`,
766
709
  registry, requirements,
767
710
  progress: { milestones: milestoneProgress, slices: sliceProgress, tasks: taskProgress },
768
711
  };
769
712
  }
770
713
  const activeTask = { id: activeTaskRow.id, title: activeTaskRow.title };
771
- // ── Task plan file check (#909) ─────────────────────────────────────
772
714
  const tasksDir = resolveTasksDir(basePath, activeMilestone.id, activeSlice.id);
773
715
  if (tasksDir && existsSync(tasksDir) && tasks.length > 0) {
774
716
  const allFiles = readdirSync(tasksDir).filter(f => f.endsWith(".md"));
775
717
  if (allFiles.length === 0) {
776
718
  return {
777
719
  activeMilestone, activeSlice, activeTask: null,
778
- phase: 'planning',
779
- recentDecisions: [], blockers: [],
720
+ phase: 'planning', recentDecisions: [], blockers: [],
780
721
  nextAction: `Task plan files missing for ${activeSlice.id}. Run plan-slice to generate task plans.`,
781
722
  registry, requirements,
782
723
  progress: { milestones: milestoneProgress, slices: sliceProgress, tasks: taskProgress },
783
724
  };
784
725
  }
785
726
  }
786
- // ── Quality gate evaluation check ──────────────────────────────────
787
- // If slice-scoped gates (Q3/Q4) are still pending, pause before execution
788
- // so the gate-evaluate dispatch rule can run parallel sub-agents.
789
- // Slices with zero gate rows (pre-feature or simple) skip straight through.
790
727
  const pendingGateCount = getPendingSliceGateCount(activeMilestone.id, activeSlice.id);
791
728
  if (pendingGateCount > 0) {
792
729
  return {
793
730
  activeMilestone, activeSlice, activeTask: null,
794
- phase: 'evaluating-gates',
795
- recentDecisions: [], blockers: [],
731
+ phase: 'evaluating-gates', recentDecisions: [], blockers: [],
796
732
  nextAction: `Evaluate ${pendingGateCount} quality gate(s) for ${activeSlice.id} before execution.`,
797
733
  registry, requirements,
798
734
  progress: { milestones: milestoneProgress, slices: sliceProgress, tasks: taskProgress },
799
735
  };
800
736
  }
801
- // ── Blocker detection: check completed tasks for blocker_discovered ──
802
- const completedTasks = tasks.filter(t => isStatusDone(t.status));
803
- let blockerTaskId = null;
804
- for (const ct of completedTasks) {
805
- if (ct.blocker_discovered) {
806
- blockerTaskId = ct.id;
807
- break;
808
- }
809
- // Also check disk summary in case DB doesn't have the flag
810
- const summaryFile = resolveTaskFile(basePath, activeMilestone.id, activeSlice.id, ct.id, "SUMMARY");
811
- if (!summaryFile)
812
- continue;
813
- const summaryContent = await loadFile(summaryFile);
814
- if (!summaryContent)
815
- continue;
816
- const summary = parseSummary(summaryContent);
817
- if (summary.frontmatter.blocker_discovered) {
818
- blockerTaskId = ct.id;
819
- break;
820
- }
821
- }
737
+ const blockerTaskId = await detectBlockers(basePath, activeMilestone.id, activeSlice.id, tasks);
822
738
  if (blockerTaskId) {
823
- // Loop protection: if replan_history has entries for this slice, a replan
824
- // was already performed — don't re-enter replanning phase.
825
739
  const replanHistory = getReplanHistory(activeMilestone.id, activeSlice.id);
826
740
  if (replanHistory.length === 0) {
827
741
  return {
828
742
  activeMilestone, activeSlice, activeTask,
829
- phase: 'replanning-slice',
830
- recentDecisions: [],
743
+ phase: 'replanning-slice', recentDecisions: [],
831
744
  blockers: [`Task ${blockerTaskId} discovered a blocker requiring slice replan`],
832
745
  nextAction: `Task ${blockerTaskId} reported blocker_discovered. Replan slice ${activeSlice.id} before continuing.`,
833
746
  activeWorkspace: undefined,
@@ -836,22 +749,14 @@ export async function deriveStateFromDb(basePath) {
836
749
  };
837
750
  }
838
751
  }
839
- // ── REPLAN-TRIGGER detection ─────────────────────────────────────────
840
752
  if (!blockerTaskId) {
841
- const sliceRow = getSlice(activeMilestone.id, activeSlice.id);
842
- // Check DB column first, fall back to disk trigger file when DB write
843
- // was best-effort and failed (triage-resolution.ts dual-write gap).
844
- const dbTriggered = !!sliceRow?.replan_triggered_at;
845
- const diskTriggered = !dbTriggered &&
846
- !!resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "REPLAN-TRIGGER");
847
- if (dbTriggered || diskTriggered) {
848
- // Loop protection: if replan_history has entries, replan was already done
753
+ const isTriggered = checkReplanTrigger(basePath, activeMilestone.id, activeSlice.id);
754
+ if (isTriggered) {
849
755
  const replanHistory = getReplanHistory(activeMilestone.id, activeSlice.id);
850
756
  if (replanHistory.length === 0) {
851
757
  return {
852
758
  activeMilestone, activeSlice, activeTask,
853
- phase: 'replanning-slice',
854
- recentDecisions: [],
759
+ phase: 'replanning-slice', recentDecisions: [],
855
760
  blockers: ['Triage replan trigger detected — slice replan required'],
856
761
  nextAction: `Triage replan triggered for slice ${activeSlice.id}. Replan before continuing.`,
857
762
  activeWorkspace: undefined,
@@ -861,15 +766,10 @@ export async function deriveStateFromDb(basePath) {
861
766
  }
862
767
  }
863
768
  }
864
- // ── Check for interrupted work ───────────────────────────────────────
865
- const sDir = resolveSlicePath(basePath, activeMilestone.id, activeSlice.id);
866
- const continueFile = sDir ? resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "CONTINUE") : null;
867
- const hasInterrupted = !!(continueFile && await loadFile(continueFile)) ||
868
- !!(sDir && await loadFile(join(sDir, "continue.md")));
769
+ const hasInterrupted = await checkInterruptedWork(basePath, activeMilestone.id, activeSlice.id);
869
770
  return {
870
771
  activeMilestone, activeSlice, activeTask,
871
- phase: 'executing',
872
- recentDecisions: [], blockers: [],
772
+ phase: 'executing', recentDecisions: [], blockers: [],
873
773
  nextAction: hasInterrupted
874
774
  ? `Resume interrupted work on ${activeTask.id}: ${activeTask.title} in slice ${activeSlice.id}. Read continue.md first.`
875
775
  : `Execute ${activeTask.id}: ${activeTask.title} in slice ${activeSlice.id}.`,
@@ -881,7 +781,9 @@ export async function deriveStateFromDb(basePath) {
881
781
  // DB-backed projects use deriveStateFromDb() above. Target: extract to
882
782
  // state-legacy.ts when all projects are DB-backed.
883
783
  export async function _deriveStateImpl(basePath) {
884
- const milestoneIds = findMilestoneIds(basePath);
784
+ const diskIds = findMilestoneIds(basePath);
785
+ const customOrder = loadQueueOrder(basePath);
786
+ const milestoneIds = sortByQueueOrder(diskIds, customOrder);
885
787
  // ── Parallel worker isolation ──────────────────────────────────────────
886
788
  // When GSD_MILESTONE_LOCK is set, this process is a parallel worker
887
789
  // scoped to a single milestone. Filter the milestone list so this worker