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.
- package/README.md +24 -17
- package/dist/resources/extensions/gsd/custom-workflow-engine.js +16 -12
- package/dist/resources/extensions/gsd/file-lock.js +60 -0
- package/dist/resources/extensions/gsd/state.js +234 -332
- package/dist/resources/extensions/gsd/workflow-events.js +25 -13
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +9 -9
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/required-server-files.json +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +9 -9
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/dist/web/standalone/server.js +1 -1
- package/package.json +1 -1
- package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js +202 -1
- package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.d.ts +19 -2
- package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.js +50 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +90 -2
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts +1 -0
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +6 -0
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +57 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts +249 -1
- package/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.ts +58 -2
- package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +96 -2
- package/packages/pi-coding-agent/src/modes/interactive/interactive-mode-state.ts +1 -0
- package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +65 -1
- package/packages/pi-tui/dist/components/__tests__/markdown-maxlines.test.d.ts +2 -0
- package/packages/pi-tui/dist/components/__tests__/markdown-maxlines.test.d.ts.map +1 -0
- package/packages/pi-tui/dist/components/__tests__/markdown-maxlines.test.js +66 -0
- package/packages/pi-tui/dist/components/__tests__/markdown-maxlines.test.js.map +1 -0
- package/packages/pi-tui/dist/components/markdown.d.ts +3 -0
- package/packages/pi-tui/dist/components/markdown.d.ts.map +1 -1
- package/packages/pi-tui/dist/components/markdown.js +17 -1
- package/packages/pi-tui/dist/components/markdown.js.map +1 -1
- package/packages/pi-tui/src/components/__tests__/markdown-maxlines.test.ts +75 -0
- package/packages/pi-tui/src/components/markdown.ts +22 -1
- package/pkg/package.json +1 -1
- package/src/resources/extensions/gsd/custom-workflow-engine.ts +19 -14
- package/src/resources/extensions/gsd/file-lock.ts +59 -0
- package/src/resources/extensions/gsd/state.ts +274 -344
- package/src/resources/extensions/gsd/tests/derive-state-helpers.test.ts +436 -0
- package/src/resources/extensions/gsd/tests/file-lock.test.ts +103 -0
- package/src/resources/extensions/gsd/workflow-events.ts +34 -25
- /package/dist/web/standalone/.next/static/{20e8bFnNjxQJflHNodEve → 4xyaXTn7-shVHaGMcl75o}/_buildManifest.js +0 -0
- /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
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
if (
|
|
491
|
-
const
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
nextAction:
|
|
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
|
-
|
|
547
|
-
|
|
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: `
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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: '
|
|
470
|
+
phase: 'validating-milestone',
|
|
600
471
|
recentDecisions: [], blockers: [],
|
|
601
|
-
nextAction: `
|
|
472
|
+
nextAction: `Validate milestone ${activeMilestone.id} before completion.`,
|
|
602
473
|
registry, requirements,
|
|
603
474
|
progress: { milestones: milestoneProgress, slices: sliceProgress },
|
|
604
475
|
};
|
|
605
476
|
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
|
695
|
-
milestoneId
|
|
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(
|
|
707
|
-
logWarning("reconcile", `imported ${tasks.length} tasks from plan file for ${
|
|
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
|
-
|
|
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,
|
|
547
|
+
const summaryPath = resolveTaskFile(basePath, milestoneId, sliceId, t.id, "SUMMARY");
|
|
727
548
|
if (summaryPath && existsSync(summaryPath)) {
|
|
728
549
|
try {
|
|
729
|
-
updateTaskStatus(
|
|
730
|
-
logWarning("reconcile", `task ${
|
|
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(
|
|
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
|
-
|
|
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
|
|
842
|
-
|
|
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
|
-
|
|
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
|
|
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
|