gsd-pi 2.80.0-dev.2be7caf18 → 2.80.0-dev.42bd34838

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 (112) hide show
  1. package/README.md +1 -0
  2. package/dist/resources/.managed-resources-content-hash +1 -1
  3. package/dist/resources/extensions/gsd/auto/phases.js +29 -15
  4. package/dist/resources/extensions/gsd/auto/resolve.js +17 -0
  5. package/dist/resources/extensions/gsd/auto/run-unit.js +13 -1
  6. package/dist/resources/extensions/gsd/auto-recovery.js +43 -1
  7. package/dist/resources/extensions/gsd/auto-supervisor.js +8 -1
  8. package/dist/resources/extensions/gsd/auto-timeout-recovery.js +2 -2
  9. package/dist/resources/extensions/gsd/auto.js +61 -2
  10. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +21 -2
  11. package/dist/resources/extensions/gsd/context-budget.js +37 -2
  12. package/dist/resources/extensions/gsd/db/unit-dispatches.js +39 -0
  13. package/dist/resources/extensions/gsd/db-base-schema.js +4 -2
  14. package/dist/resources/extensions/gsd/db-migration-steps.js +6 -0
  15. package/dist/resources/extensions/gsd/git-service.js +36 -4
  16. package/dist/resources/extensions/gsd/gsd-db.js +46 -13
  17. package/dist/resources/extensions/gsd/guided-flow.js +19 -4
  18. package/dist/resources/extensions/gsd/memory-store.js +69 -12
  19. package/dist/resources/extensions/gsd/prompt-loader.js +28 -2
  20. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +14 -13
  21. package/dist/resources/extensions/gsd/tools/memory-tools.js +1 -0
  22. package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +1 -1
  23. package/dist/resources/extensions/gsd/unit-runtime.js +11 -0
  24. package/dist/resources/extensions/gsd/worktree-resolver.js +33 -17
  25. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  26. package/dist/web/standalone/.next/BUILD_ID +1 -1
  27. package/dist/web/standalone/.next/app-path-routes-manifest.json +11 -11
  28. package/dist/web/standalone/.next/build-manifest.json +2 -2
  29. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  30. package/dist/web/standalone/.next/required-server-files.json +1 -1
  31. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  32. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  40. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/index.html +1 -1
  48. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app-paths-manifest.json +11 -11
  55. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  56. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  57. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  58. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  59. package/dist/web/standalone/server.js +1 -1
  60. package/package.json +3 -3
  61. package/packages/mcp-server/src/workflow-tools.test.ts +9 -2
  62. package/packages/native/tsconfig.tsbuildinfo +1 -1
  63. package/packages/pi-coding-agent/dist/core/agent-session-abort-order.test.js +32 -0
  64. package/packages/pi-coding-agent/dist/core/agent-session-abort-order.test.js.map +1 -1
  65. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  66. package/packages/pi-coding-agent/dist/core/agent-session.js +5 -0
  67. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  68. package/packages/pi-coding-agent/src/core/agent-session-abort-order.test.ts +36 -0
  69. package/packages/pi-coding-agent/src/core/agent-session.ts +5 -0
  70. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  71. package/src/resources/extensions/gsd/auto/phases.ts +35 -20
  72. package/src/resources/extensions/gsd/auto/resolve.ts +23 -1
  73. package/src/resources/extensions/gsd/auto/run-unit.ts +18 -1
  74. package/src/resources/extensions/gsd/auto-recovery.ts +54 -0
  75. package/src/resources/extensions/gsd/auto-supervisor.ts +7 -0
  76. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +2 -2
  77. package/src/resources/extensions/gsd/auto.ts +69 -1
  78. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +21 -1
  79. package/src/resources/extensions/gsd/context-budget.ts +44 -2
  80. package/src/resources/extensions/gsd/db/unit-dispatches.ts +41 -0
  81. package/src/resources/extensions/gsd/db-base-schema.ts +4 -2
  82. package/src/resources/extensions/gsd/db-migration-steps.ts +8 -0
  83. package/src/resources/extensions/gsd/git-service.ts +46 -8
  84. package/src/resources/extensions/gsd/gsd-db.ts +50 -13
  85. package/src/resources/extensions/gsd/guided-flow.ts +32 -4
  86. package/src/resources/extensions/gsd/memory-store.ts +77 -12
  87. package/src/resources/extensions/gsd/prompt-loader.ts +27 -2
  88. package/src/resources/extensions/gsd/prompts/complete-milestone.md +14 -13
  89. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +71 -0
  90. package/src/resources/extensions/gsd/tests/auto-phases-lifecycle.test.ts +56 -13
  91. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +14 -1
  92. package/src/resources/extensions/gsd/tests/compaction-snapshot.test.ts +3 -1
  93. package/src/resources/extensions/gsd/tests/context-budget.test.ts +10 -1
  94. package/src/resources/extensions/gsd/tests/dispatch-rule-coverage.test.ts +313 -0
  95. package/src/resources/extensions/gsd/tests/integration/git-service.test.ts +54 -0
  96. package/src/resources/extensions/gsd/tests/journal-integration.test.ts +234 -0
  97. package/src/resources/extensions/gsd/tests/memory-decay-factor.test.ts +90 -0
  98. package/src/resources/extensions/gsd/tests/prompt-step-ordering.test.ts +19 -0
  99. package/src/resources/extensions/gsd/tests/schema-v27-v28-sequence.test.ts +156 -0
  100. package/src/resources/extensions/gsd/tests/signal-handlers.test.ts +27 -0
  101. package/src/resources/extensions/gsd/tests/stalled-tool-recovery.test.ts +49 -1
  102. package/src/resources/extensions/gsd/tests/start-auto-detached.test.ts +38 -0
  103. package/src/resources/extensions/gsd/tests/unit-dispatches.test.ts +30 -0
  104. package/src/resources/extensions/gsd/tests/unit-runtime.test.ts +30 -0
  105. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +3 -0
  106. package/src/resources/extensions/gsd/tests/worktree-resolver.test.ts +63 -1
  107. package/src/resources/extensions/gsd/tools/memory-tools.ts +1 -0
  108. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +1 -1
  109. package/src/resources/extensions/gsd/unit-runtime.ts +11 -0
  110. package/src/resources/extensions/gsd/worktree-resolver.ts +36 -15
  111. /package/dist/web/standalone/.next/static/{3EDDd9ULaybSupPoA_vf- → tht0ltP8EIjsohPOA_WZf}/_buildManifest.js +0 -0
  112. /package/dist/web/standalone/.next/static/{3EDDd9ULaybSupPoA_vf- → tht0ltP8EIjsohPOA_WZf}/_ssgManifest.js +0 -0
@@ -399,6 +399,240 @@ test("runDispatch pauses when complete-milestone summary exists on disk but the
399
399
  assert.equal(stopCalls, 0, "mismatch pause should not hard-stop the loop");
400
400
  });
401
401
 
402
+ test("runDispatch pauses when execute-task artifacts exist but DB status is still open", async (t) => {
403
+ const capture = createEventCapture();
404
+ let pauseCalls = 0;
405
+ let stopCalls = 0;
406
+ let invalidateCalls = 0;
407
+ const base = join(tmpdir(), `gsd-stuck-execute-task-${randomUUID()}`);
408
+ t.after(() => {
409
+ closeDatabase();
410
+ rmSync(base, { recursive: true, force: true });
411
+ });
412
+
413
+ const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
414
+ const tasksDir = join(sliceDir, "tasks");
415
+ mkdirSync(tasksDir, { recursive: true });
416
+ openDatabase(join(base, ".gsd", "gsd.db"));
417
+ insertMilestone({ id: "M001", title: "Test", status: "active" });
418
+ insertSlice({ id: "S01", milestoneId: "M001", title: "Slice", status: "in_progress" });
419
+ insertTask({ id: "T01", milestoneId: "M001", sliceId: "S01", title: "First task", status: "pending" });
420
+ writeFileSync(
421
+ join(sliceDir, "S01-PLAN.md"),
422
+ [
423
+ "# S01",
424
+ "",
425
+ "## Tasks",
426
+ "",
427
+ "- [x] **T01: First task** `est:1h`",
428
+ "",
429
+ ].join("\n"),
430
+ );
431
+ writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan\n");
432
+ writeFileSync(join(tasksDir, "T01-SUMMARY.md"), "# T01 Summary\n\nDone on disk.\n");
433
+
434
+ const deps = makeMockDeps(capture, {
435
+ pauseAuto: async () => { pauseCalls++; },
436
+ stopAuto: async () => { stopCalls++; },
437
+ invalidateAllCaches: () => { invalidateCalls++; },
438
+ resolveDispatch: async () => ({
439
+ action: "dispatch" as const,
440
+ unitType: "execute-task",
441
+ unitId: "M001/S01/T01",
442
+ prompt: "execute the task",
443
+ matchedRule: "executing → execute-task",
444
+ }),
445
+ });
446
+ const ic = makeIC(deps, {
447
+ s: {
448
+ ...makeSession(),
449
+ basePath: base,
450
+ originalBasePath: base,
451
+ } as any,
452
+ });
453
+ const preData: PreDispatchData = {
454
+ state: {
455
+ phase: "executing",
456
+ activeMilestone: { id: "M001", title: "Test", status: "active" },
457
+ activeSlice: { id: "S01", title: "Slice" },
458
+ activeTask: { id: "T01", title: "First task" },
459
+ registry: [{ id: "M001", status: "active" }],
460
+ blockers: [],
461
+ } as any,
462
+ mid: "M001",
463
+ midTitle: "Test Milestone",
464
+ };
465
+ const loopState: LoopState = {
466
+ recentUnits: [
467
+ { key: "execute-task/M001/S01/T01" },
468
+ { key: "execute-task/M001/S01/T01" },
469
+ ],
470
+ stuckRecoveryAttempts: 0,
471
+ consecutiveFinalizeTimeouts: 0,
472
+ };
473
+
474
+ const result = await runDispatch(ic, preData, loopState);
475
+
476
+ assert.equal(result.action, "break");
477
+ assert.equal((result as any).reason, "execute-task-artifact-db-mismatch");
478
+ assert.equal(pauseCalls, 1, "execute-task disk/db mismatch should pause auto-mode");
479
+ assert.equal(stopCalls, 0, "execute-task disk/db mismatch should not hard-stop the loop");
480
+ assert.equal(invalidateCalls, 0, "mismatch should not clear caches and continue toward redispatch");
481
+ assert.equal(loopState.recentUnits.length, 3, "mismatch should keep the stuck window intact");
482
+ assert.equal(loopState.stuckRecoveryAttempts, 1, "mismatch should not reset the recovery counter");
483
+ });
484
+
485
+ test("runDispatch pauses at Level 2 when execute-task artifacts exist but DB status is still open", async (t) => {
486
+ const capture = createEventCapture();
487
+ let pauseCalls = 0;
488
+ let stopCalls = 0;
489
+ let invalidateCalls = 0;
490
+ const base = join(tmpdir(), `gsd-stuck-execute-task-l2-${randomUUID()}`);
491
+ t.after(() => {
492
+ closeDatabase();
493
+ rmSync(base, { recursive: true, force: true });
494
+ });
495
+
496
+ const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
497
+ const tasksDir = join(sliceDir, "tasks");
498
+ mkdirSync(tasksDir, { recursive: true });
499
+ openDatabase(join(base, ".gsd", "gsd.db"));
500
+ insertMilestone({ id: "M001", title: "Test", status: "active" });
501
+ insertSlice({ id: "S01", milestoneId: "M001", title: "Slice", status: "in_progress" });
502
+ insertTask({ id: "T01", milestoneId: "M001", sliceId: "S01", title: "First task", status: "pending" });
503
+ writeFileSync(
504
+ join(sliceDir, "S01-PLAN.md"),
505
+ "# S01\n\n## Tasks\n\n- [x] **T01: First task** `est:1h`\n",
506
+ );
507
+ writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan\n");
508
+ writeFileSync(join(tasksDir, "T01-SUMMARY.md"), "# T01 Summary\n\nDone on disk.\n");
509
+
510
+ const deps = makeMockDeps(capture, {
511
+ pauseAuto: async () => { pauseCalls++; },
512
+ stopAuto: async () => { stopCalls++; },
513
+ invalidateAllCaches: () => { invalidateCalls++; },
514
+ resolveDispatch: async () => ({
515
+ action: "dispatch" as const,
516
+ unitType: "execute-task",
517
+ unitId: "M001/S01/T01",
518
+ prompt: "execute the task",
519
+ matchedRule: "executing execute-task",
520
+ }),
521
+ });
522
+ const ic = makeIC(deps, {
523
+ s: {
524
+ ...makeSession(),
525
+ basePath: base,
526
+ originalBasePath: base,
527
+ } as any,
528
+ });
529
+ const preData: PreDispatchData = {
530
+ state: {
531
+ phase: "executing",
532
+ activeMilestone: { id: "M001", title: "Test", status: "active" },
533
+ activeSlice: { id: "S01", title: "Slice" },
534
+ activeTask: { id: "T01", title: "First task" },
535
+ registry: [{ id: "M001", status: "active" }],
536
+ blockers: [],
537
+ } as any,
538
+ mid: "M001",
539
+ midTitle: "Test Milestone",
540
+ };
541
+ const loopState: LoopState = {
542
+ recentUnits: [
543
+ { key: "execute-task/M001/S01/T01" },
544
+ { key: "execute-task/M001/S01/T01" },
545
+ ],
546
+ stuckRecoveryAttempts: 1,
547
+ consecutiveFinalizeTimeouts: 0,
548
+ };
549
+
550
+ const result = await runDispatch(ic, preData, loopState);
551
+
552
+ assert.equal(result.action, "break");
553
+ assert.equal((result as any).reason, "execute-task-artifact-db-mismatch");
554
+ assert.equal(pauseCalls, 1, "Level 2 execute-task disk/db mismatch should pause auto-mode");
555
+ assert.equal(stopCalls, 0, "Level 2 execute-task disk/db mismatch should not hard-stop the loop");
556
+ assert.equal(invalidateCalls, 1, "Level 2 should invalidate caches before the final artifact recheck");
557
+ assert.equal(loopState.recentUnits.length, 3, "Level 2 mismatch should keep the stuck window intact");
558
+ assert.equal(loopState.stuckRecoveryAttempts, 1, "Level 2 mismatch should not reset the recovery counter");
559
+ });
560
+
561
+ test("runDispatch clears execute-task stuck state when artifacts and DB status are complete", async (t) => {
562
+ const capture = createEventCapture();
563
+ let pauseCalls = 0;
564
+ let stopCalls = 0;
565
+ let invalidateCalls = 0;
566
+ const base = join(tmpdir(), `gsd-stuck-execute-task-complete-${randomUUID()}`);
567
+ t.after(() => {
568
+ closeDatabase();
569
+ rmSync(base, { recursive: true, force: true });
570
+ });
571
+
572
+ const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
573
+ const tasksDir = join(sliceDir, "tasks");
574
+ mkdirSync(tasksDir, { recursive: true });
575
+ openDatabase(join(base, ".gsd", "gsd.db"));
576
+ insertMilestone({ id: "M001", title: "Test", status: "active" });
577
+ insertSlice({ id: "S01", milestoneId: "M001", title: "Slice", status: "in_progress" });
578
+ insertTask({ id: "T01", milestoneId: "M001", sliceId: "S01", title: "First task", status: "complete" });
579
+ writeFileSync(
580
+ join(sliceDir, "S01-PLAN.md"),
581
+ "# S01\n\n## Tasks\n\n- [x] **T01: First task** `est:1h`\n",
582
+ );
583
+ writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan\n");
584
+ writeFileSync(join(tasksDir, "T01-SUMMARY.md"), "# T01 Summary\n\nDone on disk.\n");
585
+
586
+ const deps = makeMockDeps(capture, {
587
+ pauseAuto: async () => { pauseCalls++; },
588
+ stopAuto: async () => { stopCalls++; },
589
+ invalidateAllCaches: () => { invalidateCalls++; },
590
+ resolveDispatch: async () => ({
591
+ action: "dispatch" as const,
592
+ unitType: "execute-task",
593
+ unitId: "M001/S01/T01",
594
+ prompt: "execute the task",
595
+ matchedRule: "executing execute-task",
596
+ }),
597
+ });
598
+ const ic = makeIC(deps, {
599
+ s: {
600
+ ...makeSession(),
601
+ basePath: base,
602
+ originalBasePath: base,
603
+ } as any,
604
+ });
605
+ const preData: PreDispatchData = {
606
+ state: {
607
+ phase: "executing",
608
+ activeMilestone: { id: "M001", title: "Test", status: "active" },
609
+ activeSlice: { id: "S01", title: "Slice" },
610
+ activeTask: { id: "T01", title: "First task" },
611
+ registry: [{ id: "M001", status: "active" }],
612
+ blockers: [],
613
+ } as any,
614
+ mid: "M001",
615
+ midTitle: "Test Milestone",
616
+ };
617
+ const loopState: LoopState = {
618
+ recentUnits: [
619
+ { key: "execute-task/M001/S01/T01" },
620
+ { key: "execute-task/M001/S01/T01" },
621
+ ],
622
+ stuckRecoveryAttempts: 0,
623
+ consecutiveFinalizeTimeouts: 0,
624
+ };
625
+
626
+ const result = await runDispatch(ic, preData, loopState);
627
+
628
+ assert.equal(result.action, "continue");
629
+ assert.equal(pauseCalls, 0, "closed DB task should not pause auto-mode");
630
+ assert.equal(stopCalls, 0, "closed DB task should not hard-stop the loop");
631
+ assert.equal(invalidateCalls, 1, "closed DB task recovery should invalidate caches once");
632
+ assert.deepEqual(loopState.recentUnits, [], "closed DB task recovery should clear the stuck window");
633
+ assert.equal(loopState.stuckRecoveryAttempts, 0, "closed DB task recovery should reset the recovery counter");
634
+ });
635
+
402
636
  test("runDispatch clears stuck state after Level 1 artifact recovery", async (t) => {
403
637
  const capture = createEventCapture();
404
638
  let invalidateCalls = 0;
@@ -0,0 +1,90 @@
1
+ // gsd-2 / memoryDecayFactor unit tests
2
+ //
3
+ // Pure-function boundary tests for the V28 time-decay scoring helper.
4
+ // The function maps last_hit_at → multiplier in [0.7, 1.0] used by
5
+ // queryMemoriesRanked to down-weight stale memories without fully suppressing
6
+ // them. These tests pin the contract:
7
+ //
8
+ // - null / invalid / future timestamps → 1.0 (no decay penalty)
9
+ // - 0 days ago → 1.0
10
+ // - linear decay between 0 and 90 days
11
+ // - 90+ days ago → 0.7 floor
12
+
13
+ import test from "node:test";
14
+ import assert from "node:assert/strict";
15
+
16
+ import { memoryDecayFactor } from "../memory-store.ts";
17
+
18
+ const DAY_MS = 86_400_000;
19
+
20
+ function isoDaysAgo(days: number): string {
21
+ return new Date(Date.now() - days * DAY_MS).toISOString();
22
+ }
23
+
24
+ test("memoryDecayFactor: null lastHitAt returns 1.0 (never-hit = no decay)", () => {
25
+ assert.equal(memoryDecayFactor(null), 1.0);
26
+ });
27
+
28
+ test("memoryDecayFactor: invalid timestamp string returns 1.0 (defensive)", () => {
29
+ assert.equal(memoryDecayFactor("not-a-date"), 1.0);
30
+ assert.equal(memoryDecayFactor(""), 1.0);
31
+ });
32
+
33
+ test("memoryDecayFactor: future timestamp clamps to daysAgo=0 → 1.0", () => {
34
+ // Clock skew or manual DB edits can yield future last_hit_at values.
35
+ // The factor must stay within [0.7, 1.0] regardless.
36
+ const future = new Date(Date.now() + 30 * DAY_MS).toISOString();
37
+ const factor = memoryDecayFactor(future);
38
+ assert.ok(factor <= 1.0, `factor must not exceed 1.0, got ${factor}`);
39
+ assert.ok(factor >= 0.7, `factor must not fall below 0.7, got ${factor}`);
40
+ assert.equal(factor, 1.0);
41
+ });
42
+
43
+ test("memoryDecayFactor: 0 days ago returns 1.0", () => {
44
+ const factor = memoryDecayFactor(new Date().toISOString());
45
+ // Tiny clock drift between now-string and Date.now() inside the function;
46
+ // assert it's effectively 1.0 within float tolerance.
47
+ assert.ok(Math.abs(factor - 1.0) < 1e-6, `expected ≈1.0, got ${factor}`);
48
+ });
49
+
50
+ test("memoryDecayFactor: 30 days ago returns ~0.90 (linear midpoint)", () => {
51
+ // Formula: 1.0 - 0.3 * (30/90) = 1.0 - 0.1 = 0.90
52
+ const factor = memoryDecayFactor(isoDaysAgo(30));
53
+ assert.ok(Math.abs(factor - 0.90) < 1e-3, `expected ≈0.90, got ${factor}`);
54
+ });
55
+
56
+ test("memoryDecayFactor: 60 days ago returns ~0.80", () => {
57
+ // Formula: 1.0 - 0.3 * (60/90) = 1.0 - 0.2 = 0.80
58
+ const factor = memoryDecayFactor(isoDaysAgo(60));
59
+ assert.ok(Math.abs(factor - 0.80) < 1e-3, `expected ≈0.80, got ${factor}`);
60
+ });
61
+
62
+ test("memoryDecayFactor: 90 days ago returns 0.70 (floor)", () => {
63
+ const factor = memoryDecayFactor(isoDaysAgo(90));
64
+ assert.ok(Math.abs(factor - 0.70) < 1e-3, `expected ≈0.70, got ${factor}`);
65
+ });
66
+
67
+ test("memoryDecayFactor: 180 days ago stays at 0.70 floor", () => {
68
+ const factor = memoryDecayFactor(isoDaysAgo(180));
69
+ assert.equal(factor, 0.70);
70
+ });
71
+
72
+ test("memoryDecayFactor: result always in [0.7, 1.0] for any input", () => {
73
+ const samples: (string | null)[] = [
74
+ null,
75
+ "",
76
+ "garbage",
77
+ new Date(0).toISOString(),
78
+ isoDaysAgo(0),
79
+ isoDaysAgo(15),
80
+ isoDaysAgo(45),
81
+ isoDaysAgo(89),
82
+ isoDaysAgo(91),
83
+ isoDaysAgo(365),
84
+ new Date(Date.now() + 365 * DAY_MS).toISOString(),
85
+ ];
86
+ for (const s of samples) {
87
+ const f = memoryDecayFactor(s);
88
+ assert.ok(f >= 0.7 && f <= 1.0, `factor out of [0.7, 1.0] for ${s}: ${f}`);
89
+ }
90
+ });
@@ -65,6 +65,25 @@ describe('prompt step ordering (#3696)', () => {
65
65
  assert.ok(learningsIdx < completeMilestoneIdx, 'learnings extraction must happen before gsd_complete_milestone');
66
66
  });
67
67
 
68
+ test('complete-milestone duplicate guard checks milestone status before durable writes', () => {
69
+ const guardMatch = completeMilestoneMd.match(/^\d+\.\s.*gsd_milestone_status/m);
70
+ const reqUpdateMatch = completeMilestoneMd.match(/^\d+\.\s.*gsd_requirement_update/m);
71
+ assert.ok(guardMatch, 'complete-milestone must start with a gsd_milestone_status duplicate guard');
72
+ assert.ok(reqUpdateMatch, 'gsd_requirement_update should appear in a numbered step');
73
+
74
+ const guardIdx = completeMilestoneMd.indexOf(guardMatch![0]);
75
+ const reqUpdateIdx = completeMilestoneMd.indexOf(reqUpdateMatch![0]);
76
+ assert.ok(
77
+ guardIdx < reqUpdateIdx,
78
+ 'duplicate guard must run before requirement/project/learnings writes',
79
+ );
80
+ assert.match(
81
+ completeMilestoneMd,
82
+ /status(?:`|\*\*)?\s+(?:is\s+)?(?:`complete`|"complete")/i,
83
+ 'duplicate guard must tell the agent to stop when status is complete',
84
+ );
85
+ });
86
+
68
87
  test('complete-slice.md uses gsd_requirement_update', () => {
69
88
  assert.match(completeSliceMd, /gsd_requirement_update/,
70
89
  'complete-slice.md should reference gsd_requirement_update');
@@ -0,0 +1,156 @@
1
+ // gsd-2 / V27 + V28 schema migration regression tests
2
+ //
3
+ // Same bug class as #4591 (schema-v21-sequence): a migration block can be
4
+ // added but the SCHEMA_VERSION constant left unchanged, causing fresh-install
5
+ // + upgrade paths to silently skip the column add. This file pins both V27
6
+ // (artifacts.content_hash) and V28 (memories.last_hit_at) at the schema and
7
+ // write-path level on fresh-install DBs.
8
+
9
+ import test from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import * as os from "node:os";
14
+
15
+ import {
16
+ openDatabase,
17
+ closeDatabase,
18
+ _getAdapter,
19
+ insertArtifact,
20
+ insertMemoryRow,
21
+ incrementMemoryHitCount,
22
+ SCHEMA_VERSION,
23
+ } from "../gsd-db.ts";
24
+
25
+ function makeTmp(): string {
26
+ return fs.mkdtempSync(path.join(os.tmpdir(), "gsd-v27v28-"));
27
+ }
28
+
29
+ function cleanup(base: string): void {
30
+ try { closeDatabase(); } catch { /* noop */ }
31
+ try { fs.rmSync(base, { recursive: true, force: true }); } catch { /* noop */ }
32
+ }
33
+
34
+ test("SCHEMA_VERSION constant is at least 28 (V28 migration committed)", () => {
35
+ assert.ok(
36
+ SCHEMA_VERSION >= 28,
37
+ `SCHEMA_VERSION must be ≥ 28 after V28 migration; got ${SCHEMA_VERSION}`,
38
+ );
39
+ });
40
+
41
+ test("fresh-install DB has artifacts.content_hash column (V27)", () => {
42
+ const base = makeTmp();
43
+ const dbPath = path.join(base, "gsd.db");
44
+ try {
45
+ openDatabase(dbPath);
46
+ const db = _getAdapter()!;
47
+ const cols = db.prepare("PRAGMA table_info(artifacts)").all() as Array<Record<string, unknown>>;
48
+ const colNames = new Set(cols.map((c) => c["name"] as string));
49
+ assert.ok(
50
+ colNames.has("content_hash"),
51
+ "V27 must add content_hash column to artifacts on fresh install",
52
+ );
53
+ } finally {
54
+ cleanup(base);
55
+ }
56
+ });
57
+
58
+ test("fresh-install DB has memories.last_hit_at column (V28)", () => {
59
+ const base = makeTmp();
60
+ const dbPath = path.join(base, "gsd.db");
61
+ try {
62
+ openDatabase(dbPath);
63
+ const db = _getAdapter()!;
64
+ const cols = db.prepare("PRAGMA table_info(memories)").all() as Array<Record<string, unknown>>;
65
+ const colNames = new Set(cols.map((c) => c["name"] as string));
66
+ assert.ok(
67
+ colNames.has("last_hit_at"),
68
+ "V28 must add last_hit_at column to memories on fresh install",
69
+ );
70
+ } finally {
71
+ cleanup(base);
72
+ }
73
+ });
74
+
75
+ test("fresh-install DB stamps SCHEMA_VERSION (≥28) in schema_version table", () => {
76
+ const base = makeTmp();
77
+ const dbPath = path.join(base, "gsd.db");
78
+ try {
79
+ openDatabase(dbPath);
80
+ const db = _getAdapter()!;
81
+ const row = db.prepare("SELECT MAX(version) as v FROM schema_version").get() as Record<string, unknown> | undefined;
82
+ const max = (row?.["v"] as number) ?? 0;
83
+ assert.ok(max >= 28, `fresh install must record schema_version ≥ 28; got ${max}`);
84
+ } finally {
85
+ cleanup(base);
86
+ }
87
+ });
88
+
89
+ test("insertArtifact populates content_hash with SHA-256 of full_content (V27 write-path)", () => {
90
+ const base = makeTmp();
91
+ const dbPath = path.join(base, "gsd.db");
92
+ try {
93
+ openDatabase(dbPath);
94
+ insertArtifact({
95
+ path: "M001/PROJECT.md",
96
+ artifact_type: "PROJECT",
97
+ milestone_id: "M001",
98
+ slice_id: null,
99
+ task_id: null,
100
+ full_content: "hello world",
101
+ });
102
+
103
+ const db = _getAdapter()!;
104
+ const row = db
105
+ .prepare("SELECT content_hash FROM artifacts WHERE path = :p")
106
+ .get({ ":p": "M001/PROJECT.md" }) as Record<string, unknown> | undefined;
107
+ const hash = row?.["content_hash"] as string | null | undefined;
108
+
109
+ // SHA-256 of "hello world" hex-encoded:
110
+ const expected = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
111
+ assert.equal(hash, expected, "content_hash must be SHA-256 hex of full_content");
112
+ } finally {
113
+ cleanup(base);
114
+ }
115
+ });
116
+
117
+ test("incrementMemoryHitCount sets last_hit_at alongside hit_count (V28 write-path)", () => {
118
+ const base = makeTmp();
119
+ const dbPath = path.join(base, "gsd.db");
120
+ try {
121
+ openDatabase(dbPath);
122
+
123
+ const created = "2026-01-01T00:00:00.000Z";
124
+ insertMemoryRow({
125
+ id: "MEM001",
126
+ category: "gotcha",
127
+ content: "test memory",
128
+ confidence: 0.9,
129
+ sourceUnitType: null,
130
+ sourceUnitId: null,
131
+ createdAt: created,
132
+ updatedAt: created,
133
+ scope: "project",
134
+ tags: [],
135
+ structuredFields: null,
136
+ });
137
+
138
+ // Before increment: last_hit_at should be NULL
139
+ const db = _getAdapter()!;
140
+ const before = db
141
+ .prepare("SELECT last_hit_at FROM memories WHERE id = :id")
142
+ .get({ ":id": "MEM001" }) as Record<string, unknown>;
143
+ assert.equal(before["last_hit_at"], null, "last_hit_at starts NULL on fresh insert");
144
+
145
+ const hitTime = "2026-02-01T00:00:00.000Z";
146
+ incrementMemoryHitCount("MEM001", hitTime);
147
+
148
+ const after = db
149
+ .prepare("SELECT hit_count, last_hit_at FROM memories WHERE id = :id")
150
+ .get({ ":id": "MEM001" }) as Record<string, unknown>;
151
+ assert.equal(after["hit_count"], 1, "hit_count increments");
152
+ assert.equal(after["last_hit_at"], hitTime, "last_hit_at set to provided timestamp");
153
+ } finally {
154
+ cleanup(base);
155
+ }
156
+ });
@@ -101,3 +101,30 @@ test("registerSigtermHandler deregisters previous handler from all signals", ()
101
101
  // Clean up
102
102
  deregisterSigtermHandler(handler2);
103
103
  });
104
+
105
+ test("registered signal handler runs best-effort cleanup before exiting", () => {
106
+ let cleanupCalled = false;
107
+ let exitCode: number | string | null | undefined;
108
+ const originalExit = process.exit;
109
+ const handler = registerSigtermHandler(
110
+ "/tmp/test-signal-cleanup",
111
+ null,
112
+ () => {
113
+ cleanupCalled = true;
114
+ },
115
+ );
116
+
117
+ (process as any).exit = ((code?: number | string | null) => {
118
+ exitCode = code;
119
+ throw new Error("process.exit intercepted");
120
+ }) as never;
121
+
122
+ try {
123
+ assert.throws(() => handler(), /process\.exit intercepted/);
124
+ assert.equal(cleanupCalled, true);
125
+ assert.equal(exitCode, 0);
126
+ } finally {
127
+ (process as any).exit = originalExit;
128
+ deregisterSigtermHandler(handler);
129
+ }
130
+ });
@@ -15,10 +15,11 @@
15
15
  * (the fix) and verifies it does not crash.
16
16
  */
17
17
 
18
- import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
18
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
19
19
  import { join } from "node:path";
20
20
  import { tmpdir } from "node:os";
21
21
  import { recoverTimedOutUnit, type RecoveryContext } from "../auto-timeout-recovery.ts";
22
+ import { closeDatabase, insertMilestone, insertSlice, insertTask, openDatabase } from "../gsd-db.ts";
22
23
  import { test } from 'node:test';
23
24
  import assert from 'node:assert/strict';
24
25
 
@@ -39,6 +40,14 @@ function makeMockPi() {
39
40
  } as any;
40
41
  }
41
42
 
43
+ function makeRecordingPi() {
44
+ const messages: unknown[] = [];
45
+ return {
46
+ messages,
47
+ sendMessage: (message: unknown) => { messages.push(message); },
48
+ } as any;
49
+ }
50
+
42
51
  // ═══ #1855: empty RecoveryContext (basePath undefined) crashes ════════════════
43
52
 
44
53
  {
@@ -63,6 +72,45 @@ function makeMockPi() {
63
72
  assert.ok(crashed, "should crash when basePath is undefined (reproduces #1855)");
64
73
  }
65
74
 
75
+ // ═══ DB-complete execute-task recovery advances without steering ═════════════
76
+
77
+ {
78
+ console.log("\n=== execute-task timeout recovery trusts closed DB status ===");
79
+ const base = mkdtempSync(join(tmpdir(), "gsd-timeout-db-complete-"));
80
+ mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true });
81
+
82
+ try {
83
+ openDatabase(join(base, ".gsd", "gsd.db"));
84
+ insertMilestone({ id: "M001", title: "Milestone", status: "active" });
85
+ insertSlice({ id: "S01", milestoneId: "M001", title: "Slice", status: "in_progress" });
86
+ insertTask({ id: "T01", milestoneId: "M001", sliceId: "S01", title: "Task", status: "complete" });
87
+ writeFileSync(
88
+ join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"),
89
+ "# S01\n\n## Tasks\n\n- [ ] **T01: Task** `est:10m`\n",
90
+ "utf-8",
91
+ );
92
+ writeFileSync(join(base, ".gsd", "STATE.md"), "## Next Action\nExecute T01 for S01: Task\n", "utf-8");
93
+
94
+ const ctx = makeMockCtx();
95
+ const pi = makeRecordingPi();
96
+ const result = await recoverTimedOutUnit(ctx, pi, "execute-task", "M001/S01/T01", "idle", {
97
+ basePath: base,
98
+ verbose: false,
99
+ currentUnitStartedAt: Date.now(),
100
+ unitRecoveryCount: new Map(),
101
+ });
102
+
103
+ assert.equal(result, "recovered", "db-complete task should recover immediately");
104
+ assert.equal(pi.messages.length, 0, "db-complete task should not send steering recovery");
105
+ const runtime = JSON.parse(readFileSync(join(base, ".gsd", "runtime", "units", "execute-task-M001-S01-T01.json"), "utf-8"));
106
+ assert.equal(runtime.phase, "finalized", "db-complete task should be finalized");
107
+ assert.equal(runtime.recovery.dbComplete, true, "runtime recovery should record DB completion");
108
+ } finally {
109
+ closeDatabase();
110
+ rmSync(base, { recursive: true, force: true });
111
+ }
112
+ }
113
+
66
114
  // ═══ #1855: valid RecoveryContext does not crash ═════════════════════════════
67
115
 
68
116
  {
@@ -4,6 +4,7 @@ import { readFileSync } from "node:fs";
4
4
  import { resolve } from "node:path";
5
5
 
6
6
  import { _withDetachedAutoKeepaliveForTest } from "../auto.ts";
7
+ import { _scheduleAutoStartAfterIdleForTest } from "../guided-flow.ts";
7
8
 
8
9
  const gsdDir = resolve(import.meta.dirname, "..");
9
10
 
@@ -202,3 +203,40 @@ test("detached auto-start preserves milestone lock across pause/stop cleanup (#3
202
203
  "AutoSession should track the detached milestone lock explicitly",
203
204
  );
204
205
  });
206
+
207
+ test("discussion auto-start waits for the current command context to become idle", async () => {
208
+ let releaseIdle!: () => void;
209
+ const idle = new Promise<void>((resolveIdle) => {
210
+ releaseIdle = resolveIdle;
211
+ });
212
+ const launches: unknown[][] = [];
213
+ const ctx = {
214
+ waitForIdle: () => idle,
215
+ ui: {
216
+ notify: () => {},
217
+ },
218
+ } as any;
219
+
220
+ _scheduleAutoStartAfterIdleForTest(
221
+ ctx,
222
+ {} as any,
223
+ "/tmp/gsd-auto-start-idle-test",
224
+ false,
225
+ { step: true },
226
+ (...args: unknown[]) => {
227
+ launches.push(args);
228
+ },
229
+ );
230
+
231
+ await Promise.resolve();
232
+ assert.equal(launches.length, 0, "auto-start must not launch before waitForIdle resolves");
233
+
234
+ releaseIdle();
235
+ await Promise.resolve();
236
+ assert.equal(launches.length, 0, "auto-start should defer launch to the next timer turn");
237
+
238
+ await new Promise((resolveTimer) => setTimeout(resolveTimer, 0));
239
+ assert.equal(launches.length, 1);
240
+ assert.equal(launches[0][2], "/tmp/gsd-auto-start-idle-test");
241
+ assert.deepEqual(launches[0][4], { step: true });
242
+ });