gsd-pi 2.78.1-dev.e9d88a536 → 2.78.1-dev.eccf86e27

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 (212) hide show
  1. package/README.md +5 -7
  2. package/dist/help-text.js +1 -1
  3. package/dist/resource-loader.js +6 -1
  4. package/dist/resources/.managed-resources-content-hash +1 -1
  5. package/dist/resources/extensions/gsd/auto/detect-stuck.js +41 -5
  6. package/dist/resources/extensions/gsd/auto/loop.js +235 -36
  7. package/dist/resources/extensions/gsd/auto/phases.js +14 -7
  8. package/dist/resources/extensions/gsd/auto/session.js +36 -0
  9. package/dist/resources/extensions/gsd/auto-dispatch.js +49 -4
  10. package/dist/resources/extensions/gsd/auto-post-unit.js +26 -12
  11. package/dist/resources/extensions/gsd/auto-worktree.js +185 -201
  12. package/dist/resources/extensions/gsd/auto.js +139 -49
  13. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +1 -1
  14. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +26 -20
  15. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +67 -55
  16. package/dist/resources/extensions/gsd/crash-recovery.js +160 -47
  17. package/dist/resources/extensions/gsd/db/auto-workers.js +227 -0
  18. package/dist/resources/extensions/gsd/db/command-queue.js +105 -0
  19. package/dist/resources/extensions/gsd/db/milestone-leases.js +210 -0
  20. package/dist/resources/extensions/gsd/db/runtime-kv.js +91 -0
  21. package/dist/resources/extensions/gsd/db/unit-dispatches.js +322 -0
  22. package/dist/resources/extensions/gsd/db-writer.js +96 -16
  23. package/dist/resources/extensions/gsd/delegation-policy.js +155 -0
  24. package/dist/resources/extensions/gsd/docs/COORDINATION.md +42 -0
  25. package/dist/resources/extensions/gsd/doctor-proactive.js +4 -0
  26. package/dist/resources/extensions/gsd/doctor-runtime-checks.js +22 -6
  27. package/dist/resources/extensions/gsd/doctor.js +12 -2
  28. package/dist/resources/extensions/gsd/gsd-db.js +355 -3
  29. package/dist/resources/extensions/gsd/guided-flow-queue.js +1 -1
  30. package/dist/resources/extensions/gsd/guided-flow.js +116 -26
  31. package/dist/resources/extensions/gsd/interrupted-session.js +18 -15
  32. package/dist/resources/extensions/gsd/metrics.js +287 -1
  33. package/dist/resources/extensions/gsd/paths.js +79 -8
  34. package/dist/resources/extensions/gsd/prompts/complete-slice.md +4 -4
  35. package/dist/resources/extensions/gsd/prompts/execute-task.md +3 -3
  36. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +8 -1
  37. package/dist/resources/extensions/gsd/prompts/guided-discuss-project.md +22 -7
  38. package/dist/resources/extensions/gsd/prompts/guided-discuss-requirements.md +6 -2
  39. package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -1
  40. package/dist/resources/extensions/gsd/state.js +21 -6
  41. package/dist/resources/extensions/gsd/templates/project.md +10 -0
  42. package/dist/resources/extensions/gsd/workflow-mcp.js +2 -2
  43. package/dist/resources/extensions/gsd/workspace.js +59 -0
  44. package/dist/resources/extensions/gsd/worktree-resolver.js +79 -2
  45. package/dist/resources/extensions/gsd/write-intercept.js +3 -3
  46. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  47. package/dist/web/standalone/.next/BUILD_ID +1 -1
  48. package/dist/web/standalone/.next/app-path-routes-manifest.json +14 -14
  49. package/dist/web/standalone/.next/build-manifest.json +2 -2
  50. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  51. package/dist/web/standalone/.next/required-server-files.json +1 -1
  52. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  53. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  61. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  62. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  63. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  64. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  65. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  66. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  67. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  68. package/dist/web/standalone/.next/server/app/index.html +1 -1
  69. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  70. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  71. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  72. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  73. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  74. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  75. package/dist/web/standalone/.next/server/app-paths-manifest.json +14 -14
  76. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  77. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  78. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  79. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  80. package/dist/web/standalone/server.js +1 -1
  81. package/package.json +1 -1
  82. package/packages/mcp-server/README.md +2 -11
  83. package/packages/mcp-server/dist/remote-questions.d.ts +27 -0
  84. package/packages/mcp-server/dist/remote-questions.d.ts.map +1 -1
  85. package/packages/mcp-server/dist/remote-questions.js +28 -0
  86. package/packages/mcp-server/dist/remote-questions.js.map +1 -1
  87. package/packages/mcp-server/dist/server.d.ts +28 -0
  88. package/packages/mcp-server/dist/server.d.ts.map +1 -1
  89. package/packages/mcp-server/dist/server.js +94 -4
  90. package/packages/mcp-server/dist/server.js.map +1 -1
  91. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  92. package/packages/mcp-server/src/mcp-server.test.ts +226 -0
  93. package/packages/mcp-server/src/remote-questions.test.ts +103 -0
  94. package/packages/mcp-server/src/remote-questions.ts +35 -0
  95. package/packages/mcp-server/src/server.ts +129 -6
  96. package/packages/mcp-server/src/workflow-tools.ts +1 -1
  97. package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
  98. package/src/resources/extensions/gsd/auto/detect-stuck.ts +37 -5
  99. package/src/resources/extensions/gsd/auto/loop.ts +263 -41
  100. package/src/resources/extensions/gsd/auto/phases.ts +15 -7
  101. package/src/resources/extensions/gsd/auto/session.ts +40 -0
  102. package/src/resources/extensions/gsd/auto-dispatch.ts +63 -4
  103. package/src/resources/extensions/gsd/auto-post-unit.ts +27 -12
  104. package/src/resources/extensions/gsd/auto-worktree.ts +218 -225
  105. package/src/resources/extensions/gsd/auto.ts +166 -43
  106. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +1 -1
  107. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +26 -21
  108. package/src/resources/extensions/gsd/bootstrap/tests/write-gate-basepath.test.ts +103 -0
  109. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +80 -55
  110. package/src/resources/extensions/gsd/crash-recovery.ts +177 -43
  111. package/src/resources/extensions/gsd/db/auto-workers.ts +273 -0
  112. package/src/resources/extensions/gsd/db/command-queue.ts +149 -0
  113. package/src/resources/extensions/gsd/db/milestone-leases.ts +274 -0
  114. package/src/resources/extensions/gsd/db/runtime-kv.ts +127 -0
  115. package/src/resources/extensions/gsd/db/unit-dispatches.ts +446 -0
  116. package/src/resources/extensions/gsd/db-writer.ts +113 -17
  117. package/src/resources/extensions/gsd/delegation-policy.ts +197 -0
  118. package/src/resources/extensions/gsd/docs/COORDINATION.md +42 -0
  119. package/src/resources/extensions/gsd/doctor-proactive.ts +4 -0
  120. package/src/resources/extensions/gsd/doctor-runtime-checks.ts +24 -6
  121. package/src/resources/extensions/gsd/doctor.ts +10 -2
  122. package/src/resources/extensions/gsd/gsd-db.ts +354 -3
  123. package/src/resources/extensions/gsd/guided-flow-queue.ts +1 -1
  124. package/src/resources/extensions/gsd/guided-flow.ts +152 -26
  125. package/src/resources/extensions/gsd/interrupted-session.ts +19 -12
  126. package/src/resources/extensions/gsd/metrics.ts +321 -1
  127. package/src/resources/extensions/gsd/paths.ts +67 -8
  128. package/src/resources/extensions/gsd/prompts/complete-slice.md +4 -4
  129. package/src/resources/extensions/gsd/prompts/execute-task.md +3 -3
  130. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +8 -1
  131. package/src/resources/extensions/gsd/prompts/guided-discuss-project.md +22 -7
  132. package/src/resources/extensions/gsd/prompts/guided-discuss-requirements.md +6 -2
  133. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -1
  134. package/src/resources/extensions/gsd/state.ts +44 -6
  135. package/src/resources/extensions/gsd/templates/project.md +10 -0
  136. package/src/resources/extensions/gsd/tests/auto-discuss-milestone-deadlock-4973.test.ts +14 -14
  137. package/src/resources/extensions/gsd/tests/auto-loop-no-copy-artifacts.test.ts +72 -0
  138. package/src/resources/extensions/gsd/tests/auto-loop-symlink-worktree.test.ts +190 -0
  139. package/src/resources/extensions/gsd/tests/auto-session-scope.test.ts +331 -0
  140. package/src/resources/extensions/gsd/tests/auto-workers.test.ts +105 -0
  141. package/src/resources/extensions/gsd/tests/auto-worktree-registry.test.ts +176 -0
  142. package/src/resources/extensions/gsd/tests/command-queue.test.ts +141 -0
  143. package/src/resources/extensions/gsd/tests/crash-recovery-via-db.test.ts +203 -0
  144. package/src/resources/extensions/gsd/tests/crash-recovery.test.ts +169 -59
  145. package/src/resources/extensions/gsd/tests/db-writer-path-containment.test.ts +152 -0
  146. package/src/resources/extensions/gsd/tests/db-writer-root-artifact.test.ts +221 -0
  147. package/src/resources/extensions/gsd/tests/db-writer-scope.test.ts +230 -0
  148. package/src/resources/extensions/gsd/tests/delegation-policy.test.ts +151 -0
  149. package/src/resources/extensions/gsd/tests/detect-stuck-respects-retry.test.ts +173 -0
  150. package/src/resources/extensions/gsd/tests/dispatch-backgroundable-annotation.test.ts +55 -0
  151. package/src/resources/extensions/gsd/tests/draft-promotion.test.ts +3 -23
  152. package/src/resources/extensions/gsd/tests/gate-1b-orphan-discrimination.test.ts +193 -0
  153. package/src/resources/extensions/gsd/tests/gate-1b-recovery-bound-corrections.test.ts +246 -0
  154. package/src/resources/extensions/gsd/tests/gate-1b-recovery-bound.test.ts +218 -0
  155. package/src/resources/extensions/gsd/tests/gsd-db-failed-open-restore.test.ts +117 -0
  156. package/src/resources/extensions/gsd/tests/gsd-db-workspace-scope.test.ts +226 -0
  157. package/src/resources/extensions/gsd/tests/gsd-root-canonical.test.ts +66 -0
  158. package/src/resources/extensions/gsd/tests/gsd-root-home-guard.test.ts +68 -5
  159. package/src/resources/extensions/gsd/tests/guided-flow-prompt-consolidation.test.ts +4 -4
  160. package/src/resources/extensions/gsd/tests/integration/auto-worktree.test.ts +22 -12
  161. package/src/resources/extensions/gsd/tests/integration/doctor-proactive.test.ts +24 -10
  162. package/src/resources/extensions/gsd/tests/integration/doctor-runtime.test.ts +35 -23
  163. package/src/resources/extensions/gsd/tests/integration/workspace-collapse-integration.test.ts +369 -0
  164. package/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts +72 -25
  165. package/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts +72 -25
  166. package/src/resources/extensions/gsd/tests/memory-pressure-stuck-state.test.ts +9 -6
  167. package/src/resources/extensions/gsd/tests/metrics-atomic-merge.test.ts +222 -0
  168. package/src/resources/extensions/gsd/tests/metrics-lock-hardening.test.ts +400 -0
  169. package/src/resources/extensions/gsd/tests/metrics-lock-not-acquired.test.ts +141 -0
  170. package/src/resources/extensions/gsd/tests/metrics-lock-retry-sleep.test.ts +287 -0
  171. package/src/resources/extensions/gsd/tests/metrics-prune-cache-invalidation.test.ts +149 -0
  172. package/src/resources/extensions/gsd/tests/metrics-scope.test.ts +378 -0
  173. package/src/resources/extensions/gsd/tests/milestone-leases.test.ts +152 -0
  174. package/src/resources/extensions/gsd/tests/originalbase-path-comparison.test.ts +329 -0
  175. package/src/resources/extensions/gsd/tests/parallel-milestone-isolation.test.ts +106 -0
  176. package/src/resources/extensions/gsd/tests/path-cache-decoupled.test.ts +209 -0
  177. package/src/resources/extensions/gsd/tests/path-normalization-unified.test.ts +175 -0
  178. package/src/resources/extensions/gsd/tests/paths-cache.test.ts +170 -0
  179. package/src/resources/extensions/gsd/tests/paused-session-via-db.test.ts +119 -0
  180. package/src/resources/extensions/gsd/tests/pending-autostart-scope.test.ts +120 -0
  181. package/src/resources/extensions/gsd/tests/pipeline-variant-dispatch.test.ts +58 -0
  182. package/src/resources/extensions/gsd/tests/preferences-worktree-sync.test.ts +3 -17
  183. package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +150 -7
  184. package/src/resources/extensions/gsd/tests/register-hooks-depth-verification.test.ts +138 -16
  185. package/src/resources/extensions/gsd/tests/resume-missing-worktree-warning.test.ts +209 -0
  186. package/src/resources/extensions/gsd/tests/runtime-kv.test.ts +120 -0
  187. package/src/resources/extensions/gsd/tests/skipped-validation-completion.test.ts +133 -28
  188. package/src/resources/extensions/gsd/tests/skipped-validation-db-atomicity.test.ts +17 -0
  189. package/src/resources/extensions/gsd/tests/stuck-state-via-db.test.ts +134 -0
  190. package/src/resources/extensions/gsd/tests/sync-layer-scope.test.ts +434 -0
  191. package/src/resources/extensions/gsd/tests/teardown-chdir-failure-clears-registry.test.ts +162 -0
  192. package/src/resources/extensions/gsd/tests/teardown-cleanup-parity.test.ts +98 -0
  193. package/src/resources/extensions/gsd/tests/teardown-failure-clears-registry.test.ts +186 -0
  194. package/src/resources/extensions/gsd/tests/tool-invocation-error-loop-break.test.ts +1 -1
  195. package/src/resources/extensions/gsd/tests/unit-dispatches.test.ts +247 -0
  196. package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +41 -1
  197. package/src/resources/extensions/gsd/tests/validator-scope-parity.test.ts +239 -0
  198. package/src/resources/extensions/gsd/tests/workflow-mcp.test.ts +2 -2
  199. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +9 -15
  200. package/src/resources/extensions/gsd/tests/workspace.test.ts +196 -0
  201. package/src/resources/extensions/gsd/tests/write-gate-predicates.test.ts +35 -35
  202. package/src/resources/extensions/gsd/tests/write-gate.test.ts +94 -71
  203. package/src/resources/extensions/gsd/tests/write-intercept.test.ts +1 -1
  204. package/src/resources/extensions/gsd/workflow-mcp.ts +2 -2
  205. package/src/resources/extensions/gsd/workspace.ts +95 -0
  206. package/src/resources/extensions/gsd/worktree-resolver.ts +78 -2
  207. package/src/resources/extensions/gsd/write-intercept.ts +3 -3
  208. package/src/resources/extensions/gsd/tests/auto-lock-creation.test.ts +0 -213
  209. package/src/resources/extensions/gsd/tests/auto-stale-lock-self-kill.test.ts +0 -87
  210. package/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts +0 -159
  211. /package/dist/web/standalone/.next/static/{oZGTPvJBQX_IDKKnuV8Bt → Y5UeGFkXTYM9WIQOWHkot}/_buildManifest.js +0 -0
  212. /package/dist/web/standalone/.next/static/{oZGTPvJBQX_IDKKnuV8Bt → Y5UeGFkXTYM9WIQOWHkot}/_ssgManifest.js +0 -0
@@ -948,6 +948,232 @@ describe('createMcpServer tool registration', () => {
948
948
  assert.equal(result.content[0]?.text, 'remote response');
949
949
  });
950
950
 
951
+ it('ask_user_questions surfaces remote success answers as structuredContent (regression #5267)', async () => {
952
+ const questions = [
953
+ {
954
+ id: 'depth_verification_M001',
955
+ header: 'Depth Check',
956
+ question: 'Did I capture the depth right?',
957
+ options: [
958
+ { label: 'Yes, you got it (Recommended)', description: 'Continue.' },
959
+ { label: 'Not quite', description: 'Clarify.' },
960
+ ],
961
+ },
962
+ ];
963
+
964
+ const result = await askUserQuestionsHandler(questions, undefined, {
965
+ async elicitInput() {
966
+ throw new Error('MCP host does not support elicitation');
967
+ },
968
+ isRemoteConfigured() {
969
+ return true;
970
+ },
971
+ async tryRemoteQuestions() {
972
+ return {
973
+ content: [{ type: 'text', text: '{"answers":{"depth_verification_M001":{"answers":["Yes, you got it (Recommended)"]}}}' }],
974
+ details: {
975
+ remote: true,
976
+ channel: 'discord',
977
+ timed_out: false,
978
+ promptId: 'p1',
979
+ threadUrl: null,
980
+ questions,
981
+ response: {
982
+ endInterview: false,
983
+ answers: {
984
+ depth_verification_M001: { selected: 'Yes, you got it (Recommended)', notes: '' },
985
+ },
986
+ },
987
+ status: 'answered',
988
+ },
989
+ };
990
+ },
991
+ });
992
+
993
+ assert.deepEqual(
994
+ (result as { structuredContent?: unknown }).structuredContent,
995
+ {
996
+ questions,
997
+ response: {
998
+ // endInterview mirrors the local RoundResult shape so register-hooks
999
+ // sees identical payloads on both code paths.
1000
+ endInterview: false,
1001
+ answers: {
1002
+ depth_verification_M001: { selected: 'Yes, you got it (Recommended)', notes: '' },
1003
+ },
1004
+ },
1005
+ cancelled: false,
1006
+ },
1007
+ );
1008
+ });
1009
+
1010
+ it('ask_user_questions surfaces remote timeout as cancelled structuredContent (regression #5267)', async () => {
1011
+ const questions = [
1012
+ {
1013
+ id: 'depth_verification_M001',
1014
+ header: 'Depth Check',
1015
+ question: 'Did I capture the depth right?',
1016
+ options: [
1017
+ { label: 'Yes, you got it (Recommended)', description: 'Continue.' },
1018
+ { label: 'Not quite', description: 'Clarify.' },
1019
+ ],
1020
+ },
1021
+ ];
1022
+
1023
+ const result = await askUserQuestionsHandler(questions, undefined, {
1024
+ async elicitInput() {
1025
+ throw new Error('MCP host does not support elicitation');
1026
+ },
1027
+ isRemoteConfigured() {
1028
+ return true;
1029
+ },
1030
+ async tryRemoteQuestions() {
1031
+ return {
1032
+ content: [{ type: 'text', text: '{"timed_out":true,"channel":"discord","message":"User did not respond within 5 minutes."}' }],
1033
+ details: { remote: true, channel: 'discord', timed_out: true, status: 'timed_out' },
1034
+ };
1035
+ },
1036
+ });
1037
+
1038
+ assert.deepEqual(
1039
+ (result as { structuredContent?: unknown }).structuredContent,
1040
+ { questions, response: null, cancelled: true },
1041
+ );
1042
+ });
1043
+
1044
+ it('ask_user_questions reports a malformed remote response as cancelled, not silent success (regression #5267)', async () => {
1045
+ const questions = [
1046
+ {
1047
+ id: 'depth_verification_M001',
1048
+ header: 'Depth Check',
1049
+ question: 'Did I capture the depth right?',
1050
+ options: [
1051
+ { label: 'Yes, you got it (Recommended)', description: 'Continue.' },
1052
+ { label: 'Not quite', description: 'Clarify.' },
1053
+ ],
1054
+ },
1055
+ ];
1056
+
1057
+ const result = await askUserQuestionsHandler(questions, undefined, {
1058
+ async elicitInput() {
1059
+ throw new Error('MCP host does not support elicitation');
1060
+ },
1061
+ isRemoteConfigured() {
1062
+ return true;
1063
+ },
1064
+ async tryRemoteQuestions() {
1065
+ // Simulates a remote module returning a non-conforming `details.response`
1066
+ // (e.g. a stale build, a wire mismatch). The handler must not surface
1067
+ // this as `cancelled: false, response: null` — that would lie to any
1068
+ // consumer reading `structuredContent.cancelled`.
1069
+ return {
1070
+ content: [{ type: 'text', text: '{}' }],
1071
+ details: { remote: true, channel: 'discord', timed_out: false, response: 'not-an-object' },
1072
+ };
1073
+ },
1074
+ });
1075
+
1076
+ assert.deepEqual(
1077
+ (result as { structuredContent?: unknown }).structuredContent,
1078
+ { questions, response: null, cancelled: true },
1079
+ );
1080
+ });
1081
+
1082
+ it('ask_user_questions returns cancelled structuredContent when remote is unconfigured and local declines (regression #5267)', async () => {
1083
+ const questions = [
1084
+ {
1085
+ id: 'depth_verification_M001',
1086
+ header: 'Depth Check',
1087
+ question: 'Did I capture the depth right?',
1088
+ options: [
1089
+ { label: 'Yes, you got it (Recommended)', description: 'Continue.' },
1090
+ { label: 'Not quite', description: 'Clarify.' },
1091
+ ],
1092
+ },
1093
+ ];
1094
+
1095
+ const result = await askUserQuestionsHandler(questions, undefined, {
1096
+ async elicitInput() {
1097
+ return { action: 'decline' };
1098
+ },
1099
+ isRemoteConfigured() {
1100
+ return false;
1101
+ },
1102
+ async tryRemoteQuestions() {
1103
+ throw new Error('should not be called when remote is unconfigured');
1104
+ },
1105
+ });
1106
+
1107
+ assert.deepEqual(
1108
+ (result as { structuredContent?: unknown }).structuredContent,
1109
+ { questions, response: null, cancelled: true },
1110
+ );
1111
+ assert.equal(result.content[0]?.text, 'ask_user_questions was cancelled before receiving a response');
1112
+ });
1113
+
1114
+ it('ask_user_questions returns cancelled structuredContent when configured remote returns null (regression #5267)', async () => {
1115
+ const questions = [
1116
+ {
1117
+ id: 'depth_verification_M001',
1118
+ header: 'Depth Check',
1119
+ question: 'Did I capture the depth right?',
1120
+ options: [
1121
+ { label: 'Yes, you got it (Recommended)', description: 'Continue.' },
1122
+ { label: 'Not quite', description: 'Clarify.' },
1123
+ ],
1124
+ },
1125
+ ];
1126
+
1127
+ const result = await askUserQuestionsHandler(questions, undefined, {
1128
+ async elicitInput() {
1129
+ return { action: 'cancel' };
1130
+ },
1131
+ isRemoteConfigured() {
1132
+ return true;
1133
+ },
1134
+ async tryRemoteQuestions() {
1135
+ return null;
1136
+ },
1137
+ });
1138
+
1139
+ assert.deepEqual(
1140
+ (result as { structuredContent?: unknown }).structuredContent,
1141
+ { questions, response: null, cancelled: true },
1142
+ );
1143
+ });
1144
+
1145
+ it('ask_user_questions re-throws non-fallback local errors (regression #5267)', async () => {
1146
+ const questions = [
1147
+ {
1148
+ id: 'depth_verification_M001',
1149
+ header: 'Depth Check',
1150
+ question: 'Did I capture the depth right?',
1151
+ options: [
1152
+ { label: 'Yes, you got it (Recommended)', description: 'Continue.' },
1153
+ { label: 'Not quite', description: 'Clarify.' },
1154
+ ],
1155
+ },
1156
+ ];
1157
+
1158
+ const result = await askUserQuestionsHandler(questions, undefined, {
1159
+ async elicitInput() {
1160
+ throw new TypeError('schema validation blew up');
1161
+ },
1162
+ isRemoteConfigured() {
1163
+ return false;
1164
+ },
1165
+ async tryRemoteQuestions() {
1166
+ throw new Error('should not be called');
1167
+ },
1168
+ });
1169
+
1170
+ // Non-fallback errors propagate to the outer try/catch and surface as an
1171
+ // MCP `isError` result — no `structuredContent` is attached because the
1172
+ // error path predates the structured success/cancel branches.
1173
+ assert.equal('isError' in result && result.isError, true);
1174
+ assert.match(result.content[0]?.text ?? '', /schema validation blew up/);
1175
+ });
1176
+
951
1177
  it('ask_user_questions reports both local and remote errors when both paths fail', async () => {
952
1178
  const questions = [
953
1179
  {
@@ -23,6 +23,7 @@ import { join } from 'node:path';
23
23
  // ask_user_questions handler integration test further below.
24
24
  import {
25
25
  isRemoteConfigured,
26
+ toRoundResultResponse,
26
27
  tryRemoteQuestions,
27
28
  type RemoteQuestion,
28
29
  } from './remote-questions.js';
@@ -47,6 +48,108 @@ function makePrefsFile(dir: string, content: string): void {
47
48
  writeFileSync(join(dir, 'PREFERENCES.md'), content, 'utf-8');
48
49
  }
49
50
 
51
+ // ---------------------------------------------------------------------------
52
+ // toRoundResultResponse — regression #5267
53
+ // ---------------------------------------------------------------------------
54
+
55
+ describe('toRoundResultResponse', () => {
56
+ const singleSelectQuestion: RemoteQuestion = {
57
+ id: 'approach',
58
+ header: 'Approach',
59
+ question: 'Pick one',
60
+ options: [
61
+ { label: 'Option A (Recommended)', description: '' },
62
+ { label: 'Option B', description: '' },
63
+ ],
64
+ };
65
+
66
+ const multiSelectQuestion: RemoteQuestion = {
67
+ id: 'focus',
68
+ header: 'Focus',
69
+ question: 'Pick all',
70
+ options: [
71
+ { label: 'Frontend', description: '' },
72
+ { label: 'Backend', description: '' },
73
+ ],
74
+ allowMultiple: true,
75
+ };
76
+
77
+ const noteQuestion: RemoteQuestion = {
78
+ id: 'confirm',
79
+ header: 'Confirm',
80
+ question: 'Proceed?',
81
+ options: [
82
+ { label: 'Yes', description: '' },
83
+ { label: 'No', description: '' },
84
+ ],
85
+ };
86
+
87
+ it('normalizes a single-answer RemoteAnswer into RoundResult shape', () => {
88
+ const result = toRoundResultResponse(
89
+ { answers: { approach: { answers: ['Option A (Recommended)'] } } },
90
+ [singleSelectQuestion],
91
+ );
92
+ assert.deepEqual(result, {
93
+ endInterview: false,
94
+ answers: {
95
+ approach: { selected: 'Option A (Recommended)', notes: '' },
96
+ },
97
+ });
98
+ });
99
+
100
+ it('keeps multi-answer arrays intact for multi-select questions', () => {
101
+ const result = toRoundResultResponse(
102
+ { answers: { focus: { answers: ['Frontend', 'Backend'] } } },
103
+ [multiSelectQuestion],
104
+ );
105
+ assert.deepEqual(result.answers.focus, { selected: ['Frontend', 'Backend'], notes: '' });
106
+ });
107
+
108
+ it('preserves the array shape for a multi-select question with a single selection (regression #5267)', () => {
109
+ // Without consulting `allowMultiple`, the previous length-based inference
110
+ // collapsed `['Frontend']` into the string `'Frontend'`, breaking any
111
+ // consumer that does `selected.includes(...)` on a multi-select answer.
112
+ const result = toRoundResultResponse(
113
+ { answers: { focus: { answers: ['Frontend'] } } },
114
+ [multiSelectQuestion],
115
+ );
116
+ assert.deepEqual(result.answers.focus, { selected: ['Frontend'], notes: '' });
117
+ });
118
+
119
+ it('lifts user_note into the notes field', () => {
120
+ const result = toRoundResultResponse(
121
+ { answers: { confirm: { answers: ['None of the above'], user_note: 'Need a hybrid path.' } } },
122
+ [noteQuestion],
123
+ );
124
+ assert.deepEqual(result.answers.confirm, { selected: 'None of the above', notes: 'Need a hybrid path.' });
125
+ });
126
+
127
+ it('returns an empty selected string when the channel produced no answer for a single-select', () => {
128
+ const result = toRoundResultResponse(
129
+ { answers: { approach: { answers: [] } } },
130
+ [singleSelectQuestion],
131
+ );
132
+ assert.deepEqual(result.answers.approach, { selected: '', notes: '' });
133
+ });
134
+
135
+ it('returns an empty array when the channel produced no answer for a multi-select', () => {
136
+ const result = toRoundResultResponse(
137
+ { answers: { focus: { answers: [] } } },
138
+ [multiSelectQuestion],
139
+ );
140
+ assert.deepEqual(result.answers.focus, { selected: [], notes: '' });
141
+ });
142
+
143
+ it('falls back to single-select shape when the answer id is not in the questions list', () => {
144
+ // Defensive: an unknown id (channel desync) should not wedge the helper.
145
+ const result = toRoundResultResponse(
146
+ { answers: { ghost: { answers: ['anything'] } } },
147
+ [singleSelectQuestion],
148
+ );
149
+ assert.deepEqual(result.answers.ghost, { selected: 'anything', notes: '' });
150
+ });
151
+ });
152
+
50
153
  // ---------------------------------------------------------------------------
51
154
  // isRemoteConfigured — unit tests
52
155
  // ---------------------------------------------------------------------------
@@ -832,6 +832,40 @@ function formatForTool(answer: RemoteAnswer): Record<string, { answers: string[]
832
832
  return out;
833
833
  }
834
834
 
835
+ /**
836
+ * Normalize a `RemoteAnswer` into the `RoundResult` shape the GSD
837
+ * discussion-gate hook reads from `tool_result` `details.response`. Mirrors
838
+ * `src/resources/extensions/remote-questions/manager.ts:toRoundResultResponse`
839
+ * and the local-path helper `buildAskUserQuestionsRoundResult` in server.ts.
840
+ * Without this, the remote channel (Discord / Slack / Telegram) would have
841
+ * the same gate-stuck problem as the local elicitation path. See #5267.
842
+ *
843
+ * `questions` is required so the multi-select contract is preserved: a
844
+ * `allowMultiple` question with a single selection must still surface
845
+ * `selected: [label]` so consumers reading `selected.includes(...)` keep
846
+ * working. Falling back to length-based inference (the previous behavior)
847
+ * silently demoted single-pick multi-select answers to strings.
848
+ */
849
+ export function toRoundResultResponse(
850
+ answer: RemoteAnswer,
851
+ questions: RemoteQuestion[],
852
+ ): {
853
+ endInterview: false;
854
+ answers: Record<string, { selected: string | string[]; notes: string }>;
855
+ } {
856
+ const allowMultipleById = new Map<string, boolean>();
857
+ for (const q of questions) allowMultipleById.set(q.id, q.allowMultiple ?? false);
858
+
859
+ const normalized: Record<string, { selected: string | string[]; notes: string }> = {};
860
+ for (const [id, data] of Object.entries(answer.answers)) {
861
+ const list = data.answers ?? [];
862
+ const allowMultiple = allowMultipleById.get(id) ?? false;
863
+ const selected: string | string[] = allowMultiple ? list : (list[0] ?? '');
864
+ normalized[id] = { selected, notes: data.user_note ?? '' };
865
+ }
866
+ return { endInterview: false, answers: normalized };
867
+ }
868
+
835
869
  /**
836
870
  * Dispatch questions to the configured remote channel and wait for a response.
837
871
  *
@@ -926,6 +960,7 @@ export async function tryRemoteQuestions(
926
960
  promptId: prompt.id,
927
961
  threadUrl: ref.threadUrl ?? null,
928
962
  questions,
963
+ response: toRoundResultResponse(answer, questions),
929
964
  status: 'answered',
930
965
  },
931
966
  };
@@ -329,6 +329,32 @@ interface AskUserQuestionsElicitRequest {
329
329
  };
330
330
  }
331
331
 
332
+ /**
333
+ * Structured payload mirrored to the MCP `structuredContent` field on
334
+ * `ask_user_questions` results. Mirrors the `LocalResultDetails` shape that
335
+ * src/resources/extensions/ask-user-questions.ts already produces, so the
336
+ * GSD discussion-gate hook in register-hooks.ts can treat the MCP path
337
+ * identically to the in-process extension path. Without this, the bridge
338
+ * surfaces `details = undefined` and the gate hook's
339
+ * `if (details?.cancelled || !details?.response)` branch HARD-BLOCKs every
340
+ * user answer, including successful confirmations. See #5267.
341
+ */
342
+ interface AskUserQuestionsRoundResultAnswer {
343
+ selected: string | string[];
344
+ notes: string;
345
+ }
346
+
347
+ interface AskUserQuestionsRoundResult {
348
+ endInterview: false;
349
+ answers: Record<string, AskUserQuestionsRoundResultAnswer>;
350
+ }
351
+
352
+ interface AskUserQuestionsStructuredContent {
353
+ questions: AskUserQuestion[];
354
+ response: AskUserQuestionsRoundResult | null;
355
+ cancelled: boolean;
356
+ }
357
+
332
358
  const OTHER_OPTION_LABEL = 'None of the above';
333
359
 
334
360
  function normalizeAskUserQuestionsNote(value: AskUserQuestionsContentValue | undefined): string {
@@ -434,6 +460,41 @@ export function formatAskUserQuestionsElicitResult(
434
460
  return JSON.stringify({ answers });
435
461
  }
436
462
 
463
+ /**
464
+ * Normalize an MCP elicitation form result into the `RoundResult` shape the
465
+ * GSD discussion-gate hook reads from `tool_result` `details.response`. The
466
+ * elicitation `content` map carries `{ [id]: label, [id]__note?: string }`;
467
+ * the hook expects `{ answers: { [id]: { selected, notes } } }`. Mirrored into
468
+ * `structuredContent` by `askUserQuestionsHandler`. See #5267.
469
+ */
470
+ export function buildAskUserQuestionsRoundResult(
471
+ questions: AskUserQuestion[],
472
+ result: AskUserQuestionsElicitResult,
473
+ ): AskUserQuestionsRoundResult {
474
+ const answers: Record<string, AskUserQuestionsRoundResultAnswer> = {};
475
+ const content = result.content ?? {};
476
+
477
+ for (const question of questions) {
478
+ if (question.allowMultiple) {
479
+ const list = normalizeAskUserQuestionsAnswers(content[question.id], true);
480
+ answers[question.id] = { selected: list, notes: '' };
481
+ continue;
482
+ }
483
+
484
+ const list = normalizeAskUserQuestionsAnswers(content[question.id], false);
485
+ const selected = list[0] ?? '';
486
+ const notes = selected === OTHER_OPTION_LABEL
487
+ ? normalizeAskUserQuestionsNote(content[`${question.id}__note`])
488
+ : '';
489
+ answers[question.id] = { selected, notes };
490
+ }
491
+
492
+ // `endInterview: false` mirrors the local extension's `RoundResult` shape and
493
+ // matches the remote path's `toRoundResultResponse` so register-hooks reads
494
+ // identical payloads regardless of channel. See peer review #5267-Q2.
495
+ return { endInterview: false, answers };
496
+ }
497
+
437
498
  interface AskUserQuestionsHandlerDeps {
438
499
  elicitInput(params: AskUserQuestionsElicitRequest): Promise<AskUserQuestionsElicitResult>;
439
500
  isRemoteConfigured(): boolean;
@@ -458,6 +519,18 @@ function formatErrorMessage(err: unknown): string {
458
519
  return err instanceof Error ? err.message : String(err);
459
520
  }
460
521
 
522
+ /**
523
+ * Defensive guard for the `details.response` payload from `tryRemoteQuestions`.
524
+ * Accepts only an object with a plain `answers` map; anything else (null,
525
+ * stringified JSON, missing) falls back to `null` so the gate hook routes
526
+ * the cancel branch instead of crashing on `details.response.answers[id]`.
527
+ */
528
+ function isRoundResultLike(value: unknown): boolean {
529
+ if (!value || typeof value !== 'object') return false;
530
+ const answers = (value as Record<string, unknown>)['answers'];
531
+ return !!answers && typeof answers === 'object' && !Array.isArray(answers);
532
+ }
533
+
461
534
  export async function askUserQuestionsHandler(
462
535
  questions: AskUserQuestion[],
463
536
  extra: McpToolExtra | undefined,
@@ -478,7 +551,15 @@ export async function askUserQuestionsHandler(
478
551
  'ask_user_questions',
479
552
  );
480
553
  if (elicitation.action === 'accept' && elicitation.content) {
481
- return textContent(formatAskUserQuestionsElicitResult(questions, elicitation));
554
+ const structured: AskUserQuestionsStructuredContent = {
555
+ questions,
556
+ response: buildAskUserQuestionsRoundResult(questions, elicitation),
557
+ cancelled: false,
558
+ };
559
+ return {
560
+ content: [{ type: 'text' as const, text: formatAskUserQuestionsElicitResult(questions, elicitation) }],
561
+ structuredContent: structured as unknown as Record<string, unknown>,
562
+ };
482
563
  }
483
564
  } catch (err) {
484
565
  if (!isLocalElicitFallbackError(err)) throw err;
@@ -503,15 +584,57 @@ export async function askUserQuestionsHandler(
503
584
  if (remoteResult) {
504
585
  const details = remoteResult.details as Record<string, unknown> | undefined;
505
586
  if (details?.['timed_out'] || details?.['error']) {
506
- return textContent(remoteResult.content[0]?.text ?? 'Remote questions timed out or failed');
587
+ // Mirror the timeout/error into structuredContent so the gate hook's
588
+ // `details?.cancelled || !details?.response` branch fires correctly
589
+ // (gate stays pending, model re-asks) instead of silently dropping
590
+ // because no `details` made it across the MCP wire. See #5267.
591
+ const failedStructured: AskUserQuestionsStructuredContent = {
592
+ questions,
593
+ response: null,
594
+ cancelled: true,
595
+ };
596
+ return {
597
+ content: [{ type: 'text' as const, text: remoteResult.content[0]?.text ?? 'Remote questions timed out or failed' }],
598
+ structuredContent: failedStructured as unknown as Record<string, unknown>,
599
+ };
507
600
  }
508
- return textContent(remoteResult.content[0]?.text ?? '');
601
+ // Successful remote answer — surface the normalized RoundResult that
602
+ // remote-questions.ts attached to `details.response` so the gate hook
603
+ // sees `details.response.answers[id].selected` on this path too.
604
+ // A malformed `response` (failing isRoundResultLike) is reported as
605
+ // an explicit cancellation rather than a silent `cancelled: false`
606
+ // with `response: null` — the latter would lie to any consumer that
607
+ // reads `structuredContent.cancelled` independently of `.response`.
608
+ const hasValidResponse = isRoundResultLike(details?.['response']);
609
+ const acceptedStructured: AskUserQuestionsStructuredContent = hasValidResponse
610
+ ? {
611
+ questions,
612
+ response: details!['response'] as AskUserQuestionsRoundResult,
613
+ cancelled: false,
614
+ }
615
+ : {
616
+ questions,
617
+ response: null,
618
+ cancelled: true,
619
+ };
620
+ return {
621
+ content: [{ type: 'text' as const, text: remoteResult.content[0]?.text ?? '' }],
622
+ structuredContent: acceptedStructured as unknown as Record<string, unknown>,
623
+ };
509
624
  }
510
625
  }
511
626
 
512
627
  if (localElicitError) throw localElicitError;
513
628
 
514
- return textContent('ask_user_questions was cancelled before receiving a response');
629
+ const cancelledStructured: AskUserQuestionsStructuredContent = {
630
+ questions,
631
+ response: null,
632
+ cancelled: true,
633
+ };
634
+ return {
635
+ content: [{ type: 'text' as const, text: 'ask_user_questions was cancelled before receiving a response' }],
636
+ structuredContent: cancelledStructured as unknown as Record<string, unknown>,
637
+ };
515
638
  } catch (err) {
516
639
  return errorContent(err instanceof Error ? err.message : String(err));
517
640
  }
@@ -527,8 +650,8 @@ export type ElicitInputFn = (params: {
527
650
  }) => Promise<{ action: 'accept' | 'cancel' | 'decline'; content?: Record<string, unknown> }>;
528
651
 
529
652
  type ToolContent =
530
- | { content: Array<{ type: 'text'; text: string }> }
531
- | { isError: true; content: Array<{ type: 'text'; text: string }> };
653
+ | { content: Array<{ type: 'text'; text: string }>; structuredContent?: Record<string, unknown> }
654
+ | { isError: true; content: Array<{ type: 'text'; text: string }>; structuredContent?: Record<string, unknown> };
532
655
 
533
656
  export async function secureEnvCollectHandler(
534
657
  args: Record<string, unknown>,
@@ -230,7 +230,7 @@ type WorkflowToolExecutors = {
230
230
  };
231
231
 
232
232
  type WorkflowWriteGateModule = {
233
- loadWriteGateSnapshot: (basePath?: string) => {
233
+ loadWriteGateSnapshot: (basePath: string) => {
234
234
  verifiedDepthMilestones: string[];
235
235
  activeQueuePhase: boolean;
236
236
  pendingGateId: string | null;